(Feat): Initial Commit
Some checks failed
Build & Test / build-test (push) Has been cancelled
Build & Test / swagger-codegen-cli (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled

This commit is contained in:
2026-07-03 19:41:31 +05:30
commit 7e940c83a7
461 changed files with 45002 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
package auth
import (
"net/http"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/auth"
"allaboutapps.dev/aw/go-starter/internal/data/dto"
"allaboutapps.dev/aw/go-starter/internal/types"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/go-openapi/swag"
"github.com/labstack/echo/v4"
)
func DeleteUserAccountRoute(s *api.Server) *echo.Route {
return s.Router.APIV1Auth.DELETE("/account", deleteUserAccountHandler(s))
}
func deleteUserAccountHandler(s *api.Server) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
user := auth.UserFromContext(ctx)
log := util.LogFromContext(ctx)
var body types.DeleteUserAccountPayload
if err := util.BindAndValidateBody(c, &body); err != nil {
return err
}
err := s.Auth.DeleteUserAccount(ctx, dto.DeleteUserAccountRequest{
User: *user,
CurrentPassword: swag.StringValue(body.CurrentPassword),
})
if err != nil {
log.Debug().Err(err).Msg("Failed to delete user")
return err
}
return c.NoContent(http.StatusNoContent)
}
}

View File

@@ -0,0 +1,136 @@
package auth_test
import (
"context"
"net/http"
"testing"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
"allaboutapps.dev/aw/go-starter/internal/models"
"allaboutapps.dev/aw/go-starter/internal/test"
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
"github.com/aarondl/null/v8"
"github.com/aarondl/sqlboiler/v4/boil"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/require"
)
func assertUserAndRelatedData(ctx context.Context, t *testing.T, s *api.Server, userID string, expectExists bool) {
t.Helper()
userExists, err := models.Users(
models.UserWhere.ID.EQ(userID),
).Exists(ctx, s.DB)
require.NoError(t, err)
require.Equal(t, expectExists, userExists)
appUserProfileExists, err := models.AppUserProfiles(
models.AppUserProfileWhere.UserID.EQ(userID),
).Exists(ctx, s.DB)
require.NoError(t, err)
require.Equal(t, expectExists, appUserProfileExists)
accessTokenExists, err := models.AccessTokens(
models.AccessTokenWhere.UserID.EQ(userID),
).Exists(ctx, s.DB)
require.NoError(t, err)
require.Equal(t, expectExists, accessTokenExists)
refreshTokenExists, err := models.RefreshTokens(
models.RefreshTokenWhere.UserID.EQ(userID),
).Exists(ctx, s.DB)
require.NoError(t, err)
require.Equal(t, expectExists, refreshTokenExists)
pushTokenExists, err := models.PushTokens(
models.PushTokenWhere.UserID.EQ(userID),
).Exists(ctx, s.DB)
require.NoError(t, err)
require.Equal(t, expectExists, pushTokenExists)
}
func TestDeleteUserAccount(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
// expect the user to have a app user profile and different kinds of tokens (access, refresh, push, password reset)
assertUserAndRelatedData(ctx, t, s, fix.User1.ID, true)
payload := test.GenericPayload{
"currentPassword": fixtures.PlainTestUserPassword,
}
res := test.PerformRequest(t, s, "DELETE", "/api/v1/auth/account", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
require.Equal(t, http.StatusNoContent, res.Result().StatusCode)
// expect the user and all related data to be deleted
assertUserAndRelatedData(ctx, t, s, fix.User1.ID, false)
})
}
func TestDeleteUserAccountCurrentPasswordWrong(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
fix := fixtures.Fixtures()
payload := test.GenericPayload{
"currentPassword": "wrongpassword",
}
res := test.PerformRequest(t, s, "DELETE", "/api/v1/auth/account", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
})
}
func TestDeleteUserAccountMissingCurrentPassword(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
fix := fixtures.Fixtures()
res := test.PerformRequest(t, s, "DELETE", "/api/v1/auth/account", nil, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
require.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
})
}
func TestDeleteUserAccountNoAuth(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
res := test.PerformRequest(t, s, "DELETE", "/api/v1/auth/account", nil, nil)
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
})
}
func TestDeleteUserAccountUserNotActive(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
fix := fixtures.Fixtures()
ctx := t.Context()
fix.User1.IsActive = false
_, err := fix.User1.Update(ctx, s.DB, boil.Whitelist(models.UserColumns.IsActive))
require.NoError(t, err)
payload := test.GenericPayload{
"currentPassword": "somepassword",
}
res := test.PerformRequest(t, s, "DELETE", "/api/v1/auth/account", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
test.RequireHTTPError(t, res, httperrors.ErrForbiddenUserDeactivated)
})
}
func TestDeleteUserAccountUserNotLocal(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
fix := fixtures.Fixtures()
ctx := t.Context()
fix.User1.Password = null.String{}
_, err := fix.User1.Update(ctx, s.DB, boil.Whitelist(models.UserColumns.Password))
require.NoError(t, err)
payload := test.GenericPayload{
"currentPassword": "wrongpassword",
}
res := test.PerformRequest(t, s, "DELETE", "/api/v1/auth/account", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
test.RequireHTTPError(t, res, httperrors.ErrForbiddenNotLocalUser)
})
}

View File

@@ -0,0 +1,39 @@
package auth
import (
"net/http"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/api/router/templates"
"allaboutapps.dev/aw/go-starter/internal/types/auth"
"allaboutapps.dev/aw/go-starter/internal/util/url"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/labstack/echo/v4"
)
func GetCompleteRegisterRoute(s *api.Server) *echo.Route {
return s.Router.APIV1Auth.GET("/register", getCompleteRegisterHandler(s))
}
func getCompleteRegisterHandler(s *api.Server) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
log := util.LogFromContext(ctx)
params := auth.NewGetCompleteRegisterRouteParams()
if err := util.BindAndValidatePathAndQueryParams(c, &params); err != nil {
return err
}
confirmationRequestURL, err := url.ConfirmationRequestURL(s.Config, params.Token.String())
if err != nil {
log.Debug().Err(err).Msg("Failed to generate confirmation link")
return err
}
return c.Render(http.StatusOK, templates.ViewTemplateAccountConfirmation.String(), map[string]interface{}{
"confirmationRequestURL": confirmationRequestURL.String(),
})
}
}

View File

@@ -0,0 +1,27 @@
package auth_test
import (
"fmt"
"io"
"net/http"
"testing"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/test"
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
"github.com/stretchr/testify/require"
)
func TestGetCompleteRegister(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
fix := fixtures.Fixtures()
res := test.PerformRequest(t, s, "GET", fmt.Sprintf("/api/v1/auth/register?token=%s", fix.UserRequiresConfirmationConfirmationToken.Token), nil, nil)
require.Equal(t, http.StatusOK, res.Result().StatusCode)
response, err := io.ReadAll(res.Body)
require.NoError(t, err)
test.Snapshoter.SaveString(t, string(response))
})
}

View File

@@ -0,0 +1,33 @@
package auth
import (
"errors"
"net/http"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/auth"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/labstack/echo/v4"
)
func GetUserInfoRoute(s *api.Server) *echo.Route {
return s.Router.APIV1Auth.GET("/userinfo", getUserInfoHandler(s))
}
func getUserInfoHandler(s *api.Server) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
user := auth.UserFromContext(ctx)
log := util.LogFromContext(ctx)
var err error
user.Profile, err = s.Auth.GetAppUserProfile(ctx, user.ID)
if err != nil && !errors.Is(err, auth.ErrNotFound) {
log.Debug().Err(err).Msg("Failed to get user profile")
return err
}
return util.ValidateAndReturn(c, http.StatusOK, user.ToTypes())
}
}

View File

@@ -0,0 +1,69 @@
package auth_test
import (
"net/http"
"testing"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/models"
"allaboutapps.dev/aw/go-starter/internal/test"
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
"allaboutapps.dev/aw/go-starter/internal/types"
"github.com/go-openapi/strfmt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetUserInfo(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
res := test.PerformRequest(t, s, "GET", "/api/v1/auth/userinfo", nil, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
require.Equal(t, http.StatusOK, res.Result().StatusCode)
var response types.GetUserInfoResponse
test.ParseResponseAndValidate(t, res, &response)
assert.Equal(t, fix.User1.ID, *response.Sub)
assert.Equal(t, strfmt.Email(fix.User1.Username.String), response.Email)
test.Snapshoter.Skip([]string{"UpdatedAt"}).Save(t, response)
for _, scope := range fix.User1.Scopes {
assert.Contains(t, response.Scopes, scope)
}
appUserProfile, err := models.FindAppUserProfile(ctx, s.DB, fix.User1.ID, models.AccessTokenColumns.UpdatedAt)
require.NoError(t, err)
assert.Equal(t, appUserProfile.UpdatedAt.Unix(), *response.UpdatedAt)
})
}
func TestGetUserInfoMinimal(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
_, err := models.AppUserProfiles(models.AppUserProfileWhere.UserID.EQ(fix.User1.ID)).DeleteAll(ctx, s.DB)
require.NoError(t, err)
res := test.PerformRequest(t, s, "GET", "/api/v1/auth/userinfo", nil, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
require.Equal(t, http.StatusOK, res.Result().StatusCode)
var response types.GetUserInfoResponse
test.ParseResponseAndValidate(t, res, &response)
assert.Equal(t, fix.User1.ID, *response.Sub)
assert.Equal(t, strfmt.Email(fix.User1.Username.String), response.Email)
for _, scope := range fix.User1.Scopes {
assert.Contains(t, response.Scopes, scope)
}
user, err := models.FindUser(ctx, s.DB, fix.User1.ID, models.UserColumns.UpdatedAt)
require.NoError(t, err)
assert.Equal(t, user.UpdatedAt.Unix(), *response.UpdatedAt)
})
}

View File

@@ -0,0 +1,42 @@
package auth
import (
"net/http"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/auth"
"allaboutapps.dev/aw/go-starter/internal/data/dto"
"allaboutapps.dev/aw/go-starter/internal/types"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/go-openapi/swag"
"github.com/labstack/echo/v4"
)
func PostChangePasswordRoute(s *api.Server) *echo.Route {
return s.Router.APIV1Auth.POST("/change-password", postChangePasswordHandler(s))
}
func postChangePasswordHandler(s *api.Server) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
user := auth.UserFromEchoContext(c)
log := util.LogFromContext(ctx)
var body types.PostChangePasswordPayload
if err := util.BindAndValidateBody(c, &body); err != nil {
return err
}
result, err := s.Auth.UpdatePassword(ctx, dto.UpdatePasswordRequest{
User: *user,
CurrentPassword: swag.StringValue(body.CurrentPassword),
NewPassword: swag.StringValue(body.NewPassword),
})
if err != nil {
log.Debug().Err(err).Msg("Failed to update password")
return err
}
return util.ValidateAndReturn(c, http.StatusOK, result.ToTypes())
}
}

View File

@@ -0,0 +1,187 @@
package auth_test
import (
"database/sql"
"net/http"
"testing"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/auth"
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
"allaboutapps.dev/aw/go-starter/internal/test"
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
"allaboutapps.dev/aw/go-starter/internal/types"
"github.com/aarondl/null/v8"
"github.com/aarondl/sqlboiler/v4/boil"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
newPassword = "correct horse battery staple"
)
func TestPostChangePasswordSuccess(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
payload := test.GenericPayload{
"currentPassword": fixtures.PlainTestUserPassword,
"newPassword": newPassword,
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/change-password", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
assert.Equal(t, http.StatusOK, res.Result().StatusCode)
var response types.PostLoginResponse
test.ParseResponseAndValidate(t, res, &response)
assert.NotEmpty(t, response.AccessToken)
assert.NotEqual(t, fix.User1AccessToken1.Token, *response.AccessToken)
assert.NotEmpty(t, response.RefreshToken)
assert.NotEqual(t, fix.User1RefreshToken1.Token, *response.RefreshToken)
assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response.ExpiresIn)
assert.Equal(t, auth.TokenTypeBearer, *response.TokenType)
err := fix.User1AccessToken1.Reload(ctx, s.DB)
require.ErrorIs(t, err, sql.ErrNoRows)
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
require.ErrorIs(t, err, sql.ErrNoRows)
cnt, err := fix.User1.AccessTokens().Count(ctx, s.DB)
require.NoError(t, err)
assert.Equal(t, int64(1), cnt)
cnt, err = fix.User1.RefreshTokens().Count(ctx, s.DB)
require.NoError(t, err)
assert.Equal(t, int64(1), cnt)
err = fix.User1.Reload(ctx, s.DB)
require.NoError(t, err)
assert.NotEqual(t, fixtures.HashedTestUserPassword, fix.User1.Password.String)
})
}
func TestPostChangePasswordInvalidPassword(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
payload := test.GenericPayload{
"currentPassword": "not my password",
"newPassword": newPassword,
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/change-password", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
err := fix.User1AccessToken1.Reload(ctx, s.DB)
require.NoError(t, err)
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
require.NoError(t, err)
})
}
func TestPostChangePasswordDeactivatedUser(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
payload := test.GenericPayload{
"currentPassword": fixtures.PlainTestUserPassword,
"newPassword": newPassword,
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/change-password", payload, test.HeadersWithAuth(t, fix.UserDeactivatedAccessToken1.Token))
test.RequireHTTPError(t, res, httperrors.ErrForbiddenUserDeactivated)
err := fix.User1AccessToken1.Reload(ctx, s.DB)
require.NoError(t, err)
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
require.NoError(t, err)
})
}
func TestPostChangePasswordUserWithoutPassword(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
payload := test.GenericPayload{
"currentPassword": fixtures.PlainTestUserPassword,
"newPassword": newPassword,
}
fix.User2.Password = null.String{}
rowsAff, err := fix.User2.Update(t.Context(), s.DB, boil.Infer())
require.NoError(t, err)
require.Equal(t, int64(1), rowsAff)
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/change-password", payload, test.HeadersWithAuth(t, fix.User2AccessToken1.Token))
test.RequireHTTPError(t, res, httperrors.ErrForbiddenNotLocalUser)
err = fix.User1AccessToken1.Reload(ctx, s.DB)
require.NoError(t, err)
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
require.NoError(t, err)
})
}
func TestPostChangePasswordBadRequest(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
tests := []struct {
name string
payload test.GenericPayload
}{
{
name: "MissingCurrentPassword",
payload: test.GenericPayload{
"newPassword": newPassword,
},
},
{
name: "MissingNewPassword",
payload: test.GenericPayload{
"currentPassword": fixtures.PlainTestUserPassword,
},
},
{
name: "EmptyCurrentPassword",
payload: test.GenericPayload{
"currentPassword": "",
"newPassword": newPassword,
},
},
{
name: "EmptyNewPassword",
payload: test.GenericPayload{
"currentPassword": fixtures.PlainTestUserPassword,
"newPassword": "",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/change-password", tt.payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
var response httperrors.HTTPValidationError
test.ParseResponseAndValidate(t, res, &response)
test.Snapshoter.Save(t, response)
err := fix.User1AccessToken1.Reload(ctx, s.DB)
require.NoError(t, err)
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
require.NoError(t, err)
})
}
})
}

View File

@@ -0,0 +1,40 @@
package auth
import (
"fmt"
"net/http"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/api/handlers/constants"
"allaboutapps.dev/aw/go-starter/internal/data/dto"
"allaboutapps.dev/aw/go-starter/internal/types/auth"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/labstack/echo/v4"
)
func PostCompleteRegisterRoute(s *api.Server) *echo.Route {
return s.Router.APIV1Auth.POST(fmt.Sprintf("/register/:%s", constants.RegistrationTokenParam), postCompleteRegisterHandler(s))
}
func postCompleteRegisterHandler(s *api.Server) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
log := util.LogFromContext(ctx)
params := auth.NewPostCompleteRegisterRouteParams()
if err := util.BindAndValidatePathAndQueryParams(c, &params); err != nil {
return err
}
result, err := s.Auth.CompleteRegister(ctx, dto.CompleteRegisterRequest{
ConfirmationToken: params.RegistrationToken.String(),
})
if err != nil {
log.Debug().Err(err).Msg("Failed to complete registration")
return echo.ErrUnauthorized
}
return util.ValidateAndReturn(c, http.StatusOK, result.ToTypes())
}
}

View File

@@ -0,0 +1,70 @@
package auth_test
import (
"fmt"
"net/http"
"testing"
"time"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
"allaboutapps.dev/aw/go-starter/internal/models"
"allaboutapps.dev/aw/go-starter/internal/test"
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
"allaboutapps.dev/aw/go-starter/internal/types"
"github.com/aarondl/sqlboiler/v4/boil"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPostCompleteRegister(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
fix := fixtures.Fixtures()
ctx := t.Context()
res := test.PerformRequest(t, s, "POST", fmt.Sprintf("/api/v1/auth/register/%s", fix.UserRequiresConfirmationConfirmationToken.Token), nil, nil)
require.Equal(t, http.StatusOK, res.Result().StatusCode)
var response types.PostLoginResponse
test.ParseResponseAndValidate(t, res, &response)
assert.NotEmpty(t, response.AccessToken)
assert.NotEmpty(t, response.RefreshToken)
user, err := models.Users(
models.UserWhere.ID.EQ(fix.UserRequiresConfirmationConfirmationToken.UserID),
).One(ctx, s.DB)
require.NoError(t, err)
assert.True(t, user.IsActive)
assert.False(t, user.RequiresConfirmation)
// trying again with the same token should fail
{
res := test.PerformRequest(t, s, "POST", fmt.Sprintf("/api/v1/auth/register/%s", fix.UserRequiresConfirmationConfirmationToken.Token), nil, nil)
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
}
})
}
func TestPostCompleteRegisterTokenNotFound(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/register/e45071b7-b9a0-4ed7-a5a0-16a16413d275", nil, nil)
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
})
}
func TestPostCompleteRegisterTokenExpired(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
fix := fixtures.Fixtures()
ctx := t.Context()
fix.UserRequiresConfirmationConfirmationToken.ValidUntil = s.Clock.Now().Add(-1 * time.Second)
_, err := fix.UserRequiresConfirmationConfirmationToken.Update(ctx, s.DB, boil.Whitelist(models.ConfirmationTokenColumns.ValidUntil))
require.NoError(t, err)
res := test.PerformRequest(t, s, "POST", fmt.Sprintf("/api/v1/auth/register/%s", fix.UserRequiresConfirmationConfirmationToken.Token), nil, nil)
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
})
}

View File

@@ -0,0 +1,57 @@
package auth
import (
"net/http"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/data/dto"
"allaboutapps.dev/aw/go-starter/internal/types"
"allaboutapps.dev/aw/go-starter/internal/util"
"allaboutapps.dev/aw/go-starter/internal/util/url"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
)
func PostForgotPasswordRoute(s *api.Server) *echo.Route {
return s.Router.APIV1Auth.POST("/forgot-password", postForgotPasswordHandler(s))
}
func postForgotPasswordHandler(s *api.Server) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
var body types.PostForgotPasswordPayload
if err := util.BindAndValidateBody(c, &body); err != nil {
return err
}
username := dto.NewUsername(body.Username.String())
result, err := s.Auth.InitPasswordReset(ctx, dto.InitPasswordResetRequest{
Username: username,
})
if err != nil {
log.Debug().Err(err).Msg("Failed to initiate password reset")
return err
}
if result.ResetToken.IsZero() {
log.Debug().Msg("Failed to initiate password reset, no token returned")
// Return success status to prevent user enumeration
return c.NoContent(http.StatusNoContent)
}
resetLink, err := url.PasswordResetDeeplinkURL(s.Config, result.ResetToken.String)
if err != nil {
log.Debug().Err(err).Msg("Failed to generate password reset link")
return err
}
if err := s.Mailer.SendPasswordReset(ctx, username.String(), resetLink.String()); err != nil {
log.Debug().Err(err).Msg("Failed to send password reset email")
return err
}
return c.NoContent(http.StatusNoContent)
}
}

View File

@@ -0,0 +1,39 @@
package auth
import (
"net/http"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/data/dto"
"allaboutapps.dev/aw/go-starter/internal/types"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/go-openapi/swag"
"github.com/labstack/echo/v4"
)
func PostForgotPasswordCompleteRoute(s *api.Server) *echo.Route {
return s.Router.APIV1Auth.POST("/forgot-password/complete", postForgotPasswordCompleteHandler(s))
}
func postForgotPasswordCompleteHandler(s *api.Server) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
log := util.LogFromContext(ctx)
var body types.PostForgotPasswordCompletePayload
if err := util.BindAndValidateBody(c, &body); err != nil {
return err
}
result, err := s.Auth.ResetPassword(ctx, dto.ResetPasswordRequest{
ResetToken: body.Token.String(),
NewPassword: swag.StringValue(body.Password),
})
if err != nil {
log.Debug().Err(err).Msg("Failed to reset password")
return err
}
return util.ValidateAndReturn(c, http.StatusOK, result.ToTypes())
}
}

View File

@@ -0,0 +1,280 @@
package auth_test
import (
"database/sql"
"net/http"
"testing"
"time"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
"allaboutapps.dev/aw/go-starter/internal/models"
"allaboutapps.dev/aw/go-starter/internal/test"
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
"allaboutapps.dev/aw/go-starter/internal/types"
"github.com/aarondl/null/v8"
"github.com/aarondl/sqlboiler/v4/boil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPostForgotPasswordCompleteSuccess(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
passwordResetToken := models.PasswordResetToken{
UserID: fix.User1.ID,
ValidUntil: s.Clock.Now().Add(s.Config.Auth.PasswordResetTokenValidity),
}
err := passwordResetToken.Insert(ctx, s.DB, boil.Infer())
require.NoError(t, err)
payload := test.GenericPayload{
"token": passwordResetToken.Token,
"password": newPassword,
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password/complete", payload, nil)
assert.Equal(t, http.StatusOK, res.Result().StatusCode)
var response types.PostLoginResponse
test.ParseResponseAndValidate(t, res, &response)
assert.NotEmpty(t, response.AccessToken)
assert.NotEqual(t, fix.User1AccessToken1.Token, response.AccessToken)
assert.NotEmpty(t, response.RefreshToken)
assert.NotEqual(t, fix.User1RefreshToken1.Token, *response.RefreshToken)
test.Snapshoter.Skip([]string{"AccessToken", "RefreshToken"}).Save(t, response)
err = fix.User1AccessToken1.Reload(ctx, s.DB)
require.ErrorIs(t, err, sql.ErrNoRows)
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
require.ErrorIs(t, err, sql.ErrNoRows)
cnt, err := fix.User1.AccessTokens().Count(ctx, s.DB)
require.NoError(t, err)
assert.Equal(t, int64(1), cnt)
cnt, err = fix.User1.RefreshTokens().Count(ctx, s.DB)
require.NoError(t, err)
assert.Equal(t, int64(1), cnt)
err = fix.User1.Reload(ctx, s.DB)
require.NoError(t, err)
assert.NotEqual(t, fixtures.HashedTestUserPassword, fix.User1.Password.String)
})
}
func TestPostForgotPasswordCompleteUnknownToken(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
payload := test.GenericPayload{
"token": "fd5c04ea-f39c-49e9-bb40-7f570ed1f66f",
"password": newPassword,
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password/complete", payload, nil)
test.RequireHTTPError(t, res, httperrors.ErrNotFoundTokenNotFound)
err := fix.User1AccessToken1.Reload(ctx, s.DB)
require.NoError(t, err)
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
require.NoError(t, err)
cnt, err := fix.User1.AccessTokens().Count(ctx, s.DB)
require.NoError(t, err)
assert.Equal(t, int64(1), cnt)
cnt, err = fix.User1.RefreshTokens().Count(ctx, s.DB)
require.NoError(t, err)
assert.Equal(t, int64(1), cnt)
err = fix.User1.Reload(ctx, s.DB)
require.NoError(t, err)
assert.Equal(t, fixtures.HashedTestUserPassword, fix.User1.Password.String)
})
}
func TestPostForgotPasswordCompleteExpiredToken(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
passwordResetToken := models.PasswordResetToken{
UserID: fix.User1.ID,
ValidUntil: s.Clock.Now().Add(time.Minute * -10),
}
err := passwordResetToken.Insert(ctx, s.DB, boil.Infer())
require.NoError(t, err)
payload := test.GenericPayload{
"token": passwordResetToken.Token,
"password": newPassword,
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password/complete", payload, nil)
test.RequireHTTPError(t, res, httperrors.ErrConflictTokenExpired)
err = fix.User1AccessToken1.Reload(ctx, s.DB)
require.NoError(t, err)
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
require.NoError(t, err)
cnt, err := fix.User1.AccessTokens().Count(ctx, s.DB)
require.NoError(t, err)
assert.Equal(t, int64(1), cnt)
cnt, err = fix.User1.RefreshTokens().Count(ctx, s.DB)
require.NoError(t, err)
assert.Equal(t, int64(1), cnt)
err = fix.User1.Reload(ctx, s.DB)
require.NoError(t, err)
assert.Equal(t, fixtures.HashedTestUserPassword, fix.User1.Password.String)
})
}
func TestPostForgotPasswordCompleteDeactivatedUser(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
passwordResetToken := models.PasswordResetToken{
UserID: fix.UserDeactivated.ID,
ValidUntil: s.Clock.Now().Add(s.Config.Auth.PasswordResetTokenValidity),
}
err := passwordResetToken.Insert(ctx, s.DB, boil.Infer())
require.NoError(t, err)
payload := test.GenericPayload{
"token": passwordResetToken.Token,
"password": newPassword,
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password/complete", payload, nil)
test.RequireHTTPError(t, res, httperrors.ErrForbiddenUserDeactivated)
err = fix.UserDeactivatedAccessToken1.Reload(ctx, s.DB)
require.NoError(t, err)
err = fix.UserDeactivatedRefreshToken1.Reload(ctx, s.DB)
require.NoError(t, err)
cnt, err := fix.UserDeactivated.AccessTokens().Count(ctx, s.DB)
require.NoError(t, err)
assert.Equal(t, int64(1), cnt)
cnt, err = fix.UserDeactivated.RefreshTokens().Count(ctx, s.DB)
require.NoError(t, err)
assert.Equal(t, int64(1), cnt)
err = fix.UserDeactivated.Reload(ctx, s.DB)
require.NoError(t, err)
assert.Equal(t, fixtures.HashedTestUserPassword, fix.UserDeactivated.Password.String)
})
}
func TestPostForgotPasswordCompleteUserWithoutPassword(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
passwordResetToken := models.PasswordResetToken{
UserID: fix.User2.ID,
ValidUntil: s.Clock.Now().Add(s.Config.Auth.PasswordResetTokenValidity),
}
err := passwordResetToken.Insert(ctx, s.DB, boil.Infer())
require.NoError(t, err)
payload := test.GenericPayload{
"token": passwordResetToken.Token,
"password": newPassword,
}
fix.User2.Password = null.String{}
rowsAff, err := fix.User2.Update(t.Context(), s.DB, boil.Infer())
require.NoError(t, err)
require.Equal(t, int64(1), rowsAff)
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password/complete", payload, nil)
test.RequireHTTPError(t, res, httperrors.ErrForbiddenNotLocalUser)
err = fix.User2AccessToken1.Reload(ctx, s.DB)
require.NoError(t, err)
err = fix.User2RefreshToken1.Reload(ctx, s.DB)
require.NoError(t, err)
cnt, err := fix.User2.AccessTokens().Count(ctx, s.DB)
require.NoError(t, err)
assert.Equal(t, int64(1), cnt)
cnt, err = fix.User2.RefreshTokens().Count(ctx, s.DB)
require.NoError(t, err)
assert.Equal(t, int64(1), cnt)
err = fix.User2.Reload(ctx, s.DB)
require.NoError(t, err)
assert.False(t, fix.User2.Password.Valid)
})
}
func TestPostForgotPasswordCompleteBadRequest(t *testing.T) {
tests := []struct {
name string
payload test.GenericPayload
}{
{
name: "MissingToken",
payload: test.GenericPayload{
"password": "correct horse battery stable",
},
},
{
name: "MissingPassword",
payload: test.GenericPayload{
"token": "7b6e2366-7806-421f-bd56-ffcb39d7b1ee",
},
},
{
name: "InvalidToken",
payload: test.GenericPayload{
"token": "definitelydoesnotexist",
"password": "correct horse battery stable",
},
},
{
name: "EmptyToken",
payload: test.GenericPayload{
"password": "correct horse battery stable",
"token": "",
},
},
{
name: "EmptyPassword",
payload: test.GenericPayload{
"token": "42deb737-fa9c-4e9e-bdce-e33b829c72f7",
"password": "",
},
},
}
test.WithTestServer(t, func(s *api.Server) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password/complete", tt.payload, nil)
assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
var response httperrors.HTTPValidationError
test.ParseResponseAndValidate(t, res, &response)
test.Snapshoter.Save(t, response)
})
}
})
}

View File

@@ -0,0 +1,256 @@
package auth_test
import (
"fmt"
"net/http"
"strings"
"testing"
"time"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
"allaboutapps.dev/aw/go-starter/internal/config"
"allaboutapps.dev/aw/go-starter/internal/models"
"allaboutapps.dev/aw/go-starter/internal/test"
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
"allaboutapps.dev/aw/go-starter/internal/types"
"allaboutapps.dev/aw/go-starter/internal/util/db"
"github.com/aarondl/null/v8"
"github.com/aarondl/sqlboiler/v4/boil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPostForgotPasswordSuccess(t *testing.T) {
config := config.DefaultServiceConfigFromEnv()
config.Auth.PasswordResetTokenReuseDuration = 120 * time.Second
config.Auth.PasswordResetTokenDebounceDuration = 60 * time.Second
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
payload := test.GenericPayload{
"username": fix.User1.Username,
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
require.Equal(t, http.StatusNoContent, res.Result().StatusCode)
passwordResetToken, err := fix.User1.PasswordResetTokens().One(ctx, s.DB)
require.NoError(t, err)
mail := test.GetLastSentMail(t, s.Mailer)
require.NotNil(t, mail)
assert.Contains(t, string(mail.HTML), fmt.Sprintf("http://localhost:3000/set-new-password?token=%s", passwordResetToken.Token))
// retrying should not send a new mail because of the debounce time
{
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
require.Equal(t, http.StatusNoContent, res.Result().StatusCode)
sentMails := test.GetSentMails(t, s.Mailer)
assert.Len(t, sentMails, 1)
}
// CreatedAt of token exceeds debounce time, retrying should send a new mail
// but with the same token as the reuse duration has not passed yet
{
passwordResetToken.CreatedAt = s.Clock.Now().Add(-s.Config.Auth.PasswordResetTokenDebounceDuration)
_, err = passwordResetToken.Update(ctx, s.DB, boil.Whitelist(models.PasswordResetTokenColumns.CreatedAt))
require.NoError(t, err)
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
require.Equal(t, http.StatusNoContent, res.Result().StatusCode)
sentMails := test.GetSentMails(t, s.Mailer)
require.Len(t, sentMails, 2)
passwordResetTokens, err := fix.User1.PasswordResetTokens().All(ctx, s.DB)
require.NoError(t, err)
assert.Len(t, passwordResetTokens, 1)
for _, mail := range sentMails {
assert.Contains(t, string(mail.HTML), fmt.Sprintf("http://localhost:3000/set-new-password?token=%s", passwordResetTokens[0].Token))
}
}
// CreatedAt of token exceeds reuse time, retrying should send a new mail with a new token
{
passwordResetToken.CreatedAt = s.Clock.Now().Add(-s.Config.Auth.PasswordResetTokenReuseDuration)
_, err = passwordResetToken.Update(ctx, s.DB, boil.Whitelist(models.PasswordResetTokenColumns.CreatedAt))
require.NoError(t, err)
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
require.Equal(t, http.StatusNoContent, res.Result().StatusCode)
sentMails := test.GetSentMails(t, s.Mailer)
require.Len(t, sentMails, 3)
passwordResetTokens, err := fix.User1.PasswordResetTokens(
db.OrderBy(types.OrderDirDesc, models.PasswordResetTokenColumns.CreatedAt),
).All(ctx, s.DB)
require.NoError(t, err)
require.Len(t, passwordResetTokens, 2)
assert.Contains(t, string(sentMails[2].HTML), fmt.Sprintf("http://localhost:3000/set-new-password?token=%s", passwordResetTokens[0].Token))
}
// Token validity is expired, retrying should send a new mail with a new token
{
_, err = models.PasswordResetTokens().UpdateAll(ctx, s.DB, models.M{
models.PasswordResetTokenColumns.ValidUntil: s.Clock.Now().Add(-1 * time.Second),
})
require.NoError(t, err)
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
require.Equal(t, http.StatusNoContent, res.Result().StatusCode)
sentMails := test.GetSentMails(t, s.Mailer)
require.Len(t, sentMails, 4)
passwordResetTokens, err := fix.User1.PasswordResetTokens(
db.OrderBy(types.OrderDirDesc, models.PasswordResetTokenColumns.CreatedAt),
).All(ctx, s.DB)
require.NoError(t, err)
require.Len(t, passwordResetTokens, 3)
assert.Contains(t, string(sentMails[3].HTML), fmt.Sprintf("http://localhost:3000/set-new-password?token=%s", passwordResetTokens[0].Token))
}
})
}
func TestPostForgotPasswordSuccessLowercaseTrimWhitespaces(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
payload := test.GenericPayload{
"username": fmt.Sprintf(" %s ", strings.ToUpper(fix.User1.Username.String)),
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
assert.Equal(t, http.StatusNoContent, res.Result().StatusCode)
passwordResetToken, err := fix.User1.PasswordResetTokens().One(ctx, s.DB)
require.NoError(t, err)
mail := test.GetLastSentMail(t, s.Mailer)
require.NotNil(t, mail)
assert.Contains(t, string(mail.HTML), fmt.Sprintf("http://localhost:3000/set-new-password?token=%s", passwordResetToken.Token))
})
}
func TestPostForgotPasswordUnknownUser(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
payload := test.GenericPayload{
"username": "definitelydoesnotexist@example.com",
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
assert.Equal(t, http.StatusNoContent, res.Result().StatusCode)
cnt, err := models.PasswordResetTokens().Count(ctx, s.DB)
require.NoError(t, err)
assert.Equal(t, int64(0), cnt)
mail := test.GetLastSentMail(t, s.Mailer)
assert.Nil(t, mail)
})
}
func TestPostForgotPasswordDeactivatedUser(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
payload := test.GenericPayload{
"username": fix.UserDeactivated.Username,
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
assert.Equal(t, http.StatusNoContent, res.Result().StatusCode)
cnt, err := models.PasswordResetTokens().Count(ctx, s.DB)
require.NoError(t, err)
assert.Equal(t, int64(0), cnt)
mail := test.GetLastSentMail(t, s.Mailer)
assert.Nil(t, mail)
})
}
func TestPostForgotPasswordUserWithoutPassword(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
payload := test.GenericPayload{
"username": fix.User2.Username,
}
fix.User2.Password = null.String{}
rowsAff, err := fix.User2.Update(t.Context(), s.DB, boil.Infer())
require.NoError(t, err)
require.Equal(t, int64(1), rowsAff)
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
assert.Equal(t, http.StatusNoContent, res.Result().StatusCode)
cnt, err := models.PasswordResetTokens().Count(ctx, s.DB)
require.NoError(t, err)
assert.Equal(t, int64(0), cnt)
mail := test.GetLastSentMail(t, s.Mailer)
assert.Nil(t, mail)
})
}
func TestPostForgotPasswordBadRequest(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
tests := []struct {
name string
payload test.GenericPayload
}{
{
name: "MissingUsername",
payload: test.GenericPayload{},
},
{
name: "EmptyUsername",
payload: test.GenericPayload{
"username": "",
},
},
{
name: "InvalidUsername",
payload: test.GenericPayload{
"username": "definitely not an email",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", tt.payload, nil)
assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
var response httperrors.HTTPValidationError
test.ParseResponseAndValidate(t, res, &response)
test.Snapshoter.Save(t, response)
cnt, err := models.PasswordResetTokens().Count(ctx, s.DB)
require.NoError(t, err)
assert.Equal(t, int64(0), cnt)
mail := test.GetLastSentMail(t, s.Mailer)
assert.Nil(t, mail)
})
}
})
}

View File

@@ -0,0 +1,39 @@
package auth
import (
"net/http"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/data/dto"
"allaboutapps.dev/aw/go-starter/internal/types"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/go-openapi/swag"
"github.com/labstack/echo/v4"
)
func PostLoginRoute(s *api.Server) *echo.Route {
return s.Router.APIV1Auth.POST("/login", postLoginHandler(s))
}
func postLoginHandler(s *api.Server) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
log := util.LogFromContext(ctx)
var body types.PostLoginPayload
if err := util.BindAndValidateBody(c, &body); err != nil {
return err
}
result, err := s.Auth.Login(ctx, dto.LoginRequest{
Username: dto.NewUsername(body.Username.String()),
Password: swag.StringValue(body.Password),
})
if err != nil {
log.Debug().Err(err).Msg("Failed to authenticate user")
return err
}
return util.ValidateAndReturn(c, http.StatusOK, result.ToTypes())
}
}

View File

@@ -0,0 +1,179 @@
package auth_test
import (
"fmt"
"net/http"
"strings"
"testing"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
"allaboutapps.dev/aw/go-starter/internal/auth"
"allaboutapps.dev/aw/go-starter/internal/test"
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
"allaboutapps.dev/aw/go-starter/internal/types"
"github.com/aarondl/null/v8"
"github.com/aarondl/sqlboiler/v4/boil"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPostLoginSuccess(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
fix := fixtures.Fixtures()
payload := test.GenericPayload{
"username": fix.User1.Username,
"password": fixtures.PlainTestUserPassword,
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
assert.Equal(t, http.StatusOK, res.Result().StatusCode)
var response types.PostLoginResponse
test.ParseResponseAndValidate(t, res, &response)
assert.NotEmpty(t, response.AccessToken)
assert.NotEqual(t, fix.User1AccessToken1.Token, response.AccessToken)
assert.NotEmpty(t, response.RefreshToken)
assert.NotEqual(t, fix.User1RefreshToken1.Token, response.RefreshToken)
assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response.ExpiresIn)
assert.Equal(t, auth.TokenTypeBearer, *response.TokenType)
})
}
func TestPostLoginSuccessLowercaseTrimWhitespaces(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
fix := fixtures.Fixtures()
payload := test.GenericPayload{
"username": fmt.Sprintf(" %s ", strings.ToUpper(fix.User1.Username.String)),
"password": fixtures.PlainTestUserPassword,
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
assert.Equal(t, http.StatusOK, res.Result().StatusCode)
var response types.PostLoginResponse
test.ParseResponseAndValidate(t, res, &response)
assert.NotEmpty(t, response.AccessToken)
assert.NotEqual(t, fix.User1AccessToken1.Token, response.AccessToken)
assert.NotEmpty(t, response.RefreshToken)
assert.NotEqual(t, fix.User1RefreshToken1.Token, response.RefreshToken)
assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response.ExpiresIn)
assert.Equal(t, auth.TokenTypeBearer, *response.TokenType)
})
}
func TestPostLoginInvalidCredentials(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
fix := fixtures.Fixtures()
payload := test.GenericPayload{
"username": fix.User1.Username,
"password": "not my password",
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
})
}
func TestPostLoginUnknownUser(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
payload := test.GenericPayload{
"username": "definitelydoesnotexist@example.com",
"password": fixtures.PlainTestUserPassword,
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
})
}
func TestPostLoginDeactivatedUser(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
fix := fixtures.Fixtures()
payload := test.GenericPayload{
"username": fix.UserDeactivated.Username,
"password": fixtures.PlainTestUserPassword,
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
test.RequireHTTPError(t, res, httperrors.ErrForbiddenUserDeactivated)
})
}
func TestPostLoginUserWithoutPassword(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
fix := fixtures.Fixtures()
payload := test.GenericPayload{
"username": fix.User2.Username,
"password": fixtures.PlainTestUserPassword,
}
fix.User2.Password = null.String{}
rowsAff, err := fix.User2.Update(t.Context(), s.DB, boil.Infer())
require.NoError(t, err)
require.Equal(t, int64(1), rowsAff)
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
})
}
func TestPostLoginBadRequest(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
fix := fixtures.Fixtures()
tests := []struct {
name string
payload test.GenericPayload
}{
{
name: "InvalidUsername",
payload: test.GenericPayload{
"username": "definitely not an email",
"password": fixtures.PlainTestUserPassword,
},
},
{
name: "MissingUsername",
payload: test.GenericPayload{
"password": fixtures.PlainTestUserPassword,
},
},
{
name: "MissingPassword",
payload: test.GenericPayload{
"username": fix.User1.Username,
},
},
{
name: "EmptyUsername",
payload: test.GenericPayload{
"username": "",
"password": fixtures.PlainTestUserPassword,
},
},
{
name: "EmptyPassword",
payload: test.GenericPayload{
"username": fix.User1.Username,
"password": "",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", tt.payload, nil)
assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
var response httperrors.HTTPValidationError
test.ParseResponseAndValidate(t, res, &response)
test.Snapshoter.Save(t, response)
})
}
})
}

View File

@@ -0,0 +1,45 @@
package auth
import (
"net/http"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/auth"
"allaboutapps.dev/aw/go-starter/internal/data/dto"
"allaboutapps.dev/aw/go-starter/internal/types"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/aarondl/null/v8"
"github.com/labstack/echo/v4"
)
func PostLogoutRoute(s *api.Server) *echo.Route {
return s.Router.APIV1Auth.POST("/logout", postLogoutHandler(s))
}
func postLogoutHandler(s *api.Server) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
log := util.LogFromContext(ctx)
var body types.PostLogoutPayload
if err := util.BindAndValidateBody(c, &body); err != nil {
return err
}
request := dto.LogoutRequest{
AccessToken: *auth.AccessTokenFromEchoContext(c),
}
if len(body.RefreshToken.String()) > 0 {
request.RefreshToken = null.StringFrom(body.RefreshToken.String())
}
err := s.Auth.Logout(ctx, request)
if err != nil {
log.Debug().Err(err).Msg("Failed to logout user")
return err
}
return c.NoContent(http.StatusNoContent)
}
}

View File

@@ -0,0 +1,129 @@
package auth_test
import (
"database/sql"
"net/http"
"testing"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
"allaboutapps.dev/aw/go-starter/internal/api/middleware"
"allaboutapps.dev/aw/go-starter/internal/test"
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPostLogoutSuccess(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/logout", nil, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
assert.Equal(t, http.StatusNoContent, res.Result().StatusCode)
err := fix.User1AccessToken1.Reload(ctx, s.DB)
require.ErrorIs(t, err, sql.ErrNoRows)
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
require.NoError(t, err)
})
}
func TestPostLogoutSuccessWithRefreshToken(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
payload := test.GenericPayload{
"refresh_token": fix.User1RefreshToken1.Token,
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/logout", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
assert.Equal(t, http.StatusNoContent, res.Result().StatusCode)
err := fix.User1AccessToken1.Reload(ctx, s.DB)
require.ErrorIs(t, err, sql.ErrNoRows)
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
require.ErrorIs(t, err, sql.ErrNoRows)
})
}
func TestPostLogoutSuccessWithUnknownRefreshToken(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
payload := test.GenericPayload{
"refresh_token": "93d8ccd0-be30-4661-a428-cbe74e1a3ffe",
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/logout", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
assert.Equal(t, http.StatusNoContent, res.Result().StatusCode)
err := fix.User1AccessToken1.Reload(ctx, s.DB)
require.ErrorIs(t, err, sql.ErrNoRows)
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
require.NoError(t, err)
})
}
func TestPostLogoutInvalidRefreshToken(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
payload := test.GenericPayload{
"refresh_token": "not my refresh token",
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/logout", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
var response httperrors.HTTPValidationError
test.ParseResponseAndValidate(t, res, &response)
test.Snapshoter.Save(t, response)
err := fix.User1AccessToken1.Reload(ctx, s.DB)
require.NoError(t, err)
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
require.NoError(t, err)
})
}
func TestPostLogoutError(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
tests := []struct {
name string
expectedError *httperrors.HTTPError
headers http.Header
}{
{
name: "InvalidAuthToken",
expectedError: middleware.ErrBadRequestMalformedToken,
headers: test.HeadersWithAuth(t, "not my auth token"),
},
{
name: "UnknownAuthToken",
expectedError: httperrors.NewFromEcho(echo.ErrUnauthorized),
headers: test.HeadersWithAuth(t, "25e8630e-9a41-4f38-8339-373f0c203cef"),
},
{
name: "MissingAuthToken",
expectedError: httperrors.NewFromEcho(echo.ErrUnauthorized),
headers: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/logout", nil, tt.headers)
test.RequireHTTPError(t, res, tt.expectedError)
})
}
})
}

View File

@@ -0,0 +1,37 @@
package auth
import (
"net/http"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/data/dto"
"allaboutapps.dev/aw/go-starter/internal/types"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/labstack/echo/v4"
)
func PostRefreshRoute(s *api.Server) *echo.Route {
return s.Router.APIV1Auth.POST("/refresh", postRefreshHandler(s))
}
func postRefreshHandler(s *api.Server) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
log := util.LogFromContext(ctx)
var body types.PostRefreshPayload
if err := util.BindAndValidateBody(c, &body); err != nil {
return err
}
result, err := s.Auth.Refresh(ctx, dto.RefreshRequest{
RefreshToken: body.RefreshToken.String(),
})
if err != nil {
log.Debug().Err(err).Msg("Failed to refresh tokens")
return err
}
return util.ValidateAndReturn(c, http.StatusOK, result.ToTypes())
}
}

View File

@@ -0,0 +1,108 @@
package auth_test
import (
"database/sql"
"net/http"
"testing"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
"allaboutapps.dev/aw/go-starter/internal/auth"
"allaboutapps.dev/aw/go-starter/internal/test"
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
"allaboutapps.dev/aw/go-starter/internal/types"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPostRefreshSuccess(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
payload := test.GenericPayload{
"refresh_token": fix.User1RefreshToken1.Token,
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/refresh", payload, nil)
assert.Equal(t, http.StatusOK, res.Result().StatusCode)
var response types.PostLoginResponse
test.ParseResponseAndValidate(t, res, &response)
assert.NotEmpty(t, response.AccessToken)
assert.NotEqual(t, fix.User1AccessToken1.Token, response.AccessToken)
assert.NotEmpty(t, response.RefreshToken)
assert.NotEqual(t, fix.User1RefreshToken1.Token, response.RefreshToken)
assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response.ExpiresIn)
assert.Equal(t, auth.TokenTypeBearer, *response.TokenType)
err := fix.User1RefreshToken1.Reload(ctx, s.DB)
require.ErrorIs(t, err, sql.ErrNoRows)
})
}
func TestPostRefreshUnknownToken(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
payload := test.GenericPayload{
"refresh_token": "c094e933-e5f0-4ece-9c10-914f3122cdb6",
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/refresh", payload, nil)
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
})
}
func TestPostRefreshDeactivatedUser(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
payload := test.GenericPayload{
"refresh_token": fix.UserDeactivatedRefreshToken1.Token,
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/refresh", payload, nil)
test.RequireHTTPError(t, res, httperrors.ErrForbiddenUserDeactivated)
err := fix.UserDeactivatedRefreshToken1.Reload(ctx, s.DB)
require.NoError(t, err)
})
}
func TestPostRefreshBadRequest(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
tests := []struct {
name string
payload test.GenericPayload
}{
{
name: "MissingRefreshToken",
payload: test.GenericPayload{},
},
{
name: "EmptyRefreshToken",
payload: test.GenericPayload{
"refresh_token": "",
},
},
{
name: "InvalidToken",
payload: test.GenericPayload{
"refresh_token": "not a valid token",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/refresh", tt.payload, nil)
assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
var response httperrors.HTTPValidationError
test.ParseResponseAndValidate(t, res, &response)
test.Snapshoter.Save(t, response)
})
}
})
}

View File

@@ -0,0 +1,72 @@
package auth
import (
"net/http"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/data/dto"
"allaboutapps.dev/aw/go-starter/internal/types"
"allaboutapps.dev/aw/go-starter/internal/util"
"allaboutapps.dev/aw/go-starter/internal/util/url"
"github.com/go-openapi/swag"
"github.com/labstack/echo/v4"
)
func PostRegisterRoute(s *api.Server) *echo.Route {
return s.Router.APIV1Auth.POST("/register", postRegisterHandler(s))
}
func postRegisterHandler(s *api.Server) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
log := util.LogFromContext(ctx)
var body types.PostRegisterPayload
if err := util.BindAndValidateBody(c, &body); err != nil {
return err
}
username := dto.NewUsername(body.Username.String())
result, err := s.Auth.Register(ctx, dto.RegisterRequest{
Username: username,
Password: swag.StringValue(body.Password),
})
if err != nil {
log.Debug().Err(err).Msg("Failed to register user")
return err
}
if !result.RequiresConfirmation {
loginResult, err := s.Auth.Login(ctx, dto.LoginRequest{
Username: username,
Password: swag.StringValue(body.Password),
})
if err != nil {
log.Debug().Err(err).Msg("Failed to authenticate user after registration")
return err
}
return util.ValidateAndReturn(c, http.StatusOK, loginResult.ToTypes())
}
if result.ConfirmationToken.Valid {
confirmationLink, err := url.ConfirmationDeeplinkURL(s.Config, result.ConfirmationToken.String)
if err != nil {
log.Debug().Err(err).Msg("Failed to generate confirmation link")
return err
}
if err := s.Mailer.SendAccountConfirmation(ctx, username.String(), dto.ConfirmatioNotificationPayload{
ConfirmationLink: confirmationLink.String(),
}); err != nil {
log.Debug().Err(err).Msg("Failed to send confirmation email")
return err
}
}
return util.ValidateAndReturn(c, http.StatusAccepted, &types.RegisterResponse{
RequiresConfirmation: swag.Bool(result.RequiresConfirmation),
})
}
}

View File

@@ -0,0 +1,334 @@
package auth_test
import (
"net/http"
"testing"
"time"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
"allaboutapps.dev/aw/go-starter/internal/auth"
"allaboutapps.dev/aw/go-starter/internal/config"
"allaboutapps.dev/aw/go-starter/internal/models"
"allaboutapps.dev/aw/go-starter/internal/test"
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
"allaboutapps.dev/aw/go-starter/internal/types"
"allaboutapps.dev/aw/go-starter/internal/util/db"
"allaboutapps.dev/aw/go-starter/internal/util/url"
"github.com/aarondl/null/v8"
"github.com/aarondl/sqlboiler/v4/queries/qm"
"github.com/go-openapi/strfmt"
"github.com/go-openapi/swag"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPostRegisterSuccess(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
now := time.Date(2025, 2, 5, 11, 42, 30, 0, time.UTC)
test.SetMockClock(t, s, now)
username := "usernew@example.com"
payload := test.GenericPayload{
"username": username,
"password": fixtures.PlainTestUserPassword,
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", payload, nil)
require.Equal(t, http.StatusOK, res.Result().StatusCode)
var response types.PostLoginResponse
test.ParseResponseAndValidate(t, res, &response)
assert.NotEmpty(t, response.AccessToken)
assert.NotEmpty(t, response.RefreshToken)
assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response.ExpiresIn)
assert.Equal(t, auth.TokenTypeBearer, *response.TokenType)
user, err := models.Users(
models.UserWhere.Username.EQ(null.StringFrom(username)),
qm.Load(models.UserRels.AppUserProfile),
qm.Load(models.UserRels.AccessTokens),
qm.Load(models.UserRels.RefreshTokens),
).One(ctx, s.DB)
require.NoError(t, err)
assert.Equal(t, null.StringFrom(username), user.Username)
assert.True(t, user.LastAuthenticatedAt.Valid)
assert.Equal(t, now, user.LastAuthenticatedAt.Time)
assert.EqualValues(t, s.Config.Auth.DefaultUserScopes, user.Scopes)
assert.NotNil(t, user.R.AppUserProfile)
assert.False(t, user.R.AppUserProfile.LegalAcceptedAt.Valid)
assert.Len(t, user.R.AccessTokens, 1)
assert.Equal(t, strfmt.UUID4(user.R.AccessTokens[0].Token), *response.AccessToken)
assert.Len(t, user.R.RefreshTokens, 1)
assert.Equal(t, strfmt.UUID4(user.R.RefreshTokens[0].Token), *response.RefreshToken)
res2 := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
assert.Equal(t, http.StatusOK, res2.Result().StatusCode)
var response2 types.PostLoginResponse
test.ParseResponseAndValidate(t, res2, &response2)
assert.NotEmpty(t, response2.AccessToken)
assert.NotEqual(t, response.AccessToken, *response2.AccessToken)
assert.NotEmpty(t, response2.RefreshToken)
assert.NotEqual(t, response.RefreshToken, *response2.RefreshToken)
assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response2.ExpiresIn)
assert.Equal(t, auth.TokenTypeBearer, *response2.TokenType)
})
}
func TestPostRegisterWithConfirmationSuccess(t *testing.T) {
config := config.DefaultServiceConfigFromEnv()
config.Auth.RegistrationRequiresConfirmation = true
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
ctx := t.Context()
username := "usernew-with-confirmation@example.com"
payload := test.GenericPayload{
"username": username,
"password": fixtures.PlainTestUserPassword,
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", payload, nil)
require.Equal(t, http.StatusAccepted, res.Result().StatusCode)
var response types.RegisterResponse
test.ParseResponseAndValidate(t, res, &response)
assert.True(t, swag.BoolValue(response.RequiresConfirmation))
// expect the user to be normally created
user, err := models.Users(
models.UserWhere.Username.EQ(null.StringFrom(username)),
qm.Load(models.UserRels.AppUserProfile),
qm.Load(models.UserRels.AccessTokens),
qm.Load(models.UserRels.RefreshTokens),
qm.Load(models.UserRels.ConfirmationTokens),
).One(ctx, s.DB)
require.NoError(t, err)
assert.Equal(t, null.StringFrom(username), user.Username)
assert.True(t, user.LastAuthenticatedAt.Valid)
assert.EqualValues(t, s.Config.Auth.DefaultUserScopes, user.Scopes)
assert.False(t, user.IsActive)
assert.True(t, user.RequiresConfirmation)
assert.NotNil(t, user.R.AppUserProfile)
assert.False(t, user.R.AppUserProfile.LegalAcceptedAt.Valid)
// expect the user to have no access or refresh tokens
assert.Empty(t, user.R.AccessTokens)
assert.Empty(t, user.R.RefreshTokens)
require.Len(t, user.R.ConfirmationTokens, 1)
confirmationToken := user.R.ConfirmationTokens[0]
// expect the login to fail
res2 := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
test.RequireHTTPError(t, res2, httperrors.ErrForbiddenUserDeactivated)
// expect the confirmation email to be sent
mails := test.GetSentMails(t, s.Mailer)
require.Len(t, mails, 1)
mail := mails[0]
expectedConfirmationLink, err := url.ConfirmationDeeplinkURL(s.Config, confirmationToken.Token)
require.NoError(t, err)
assert.Equal(t, username, mail.To[0])
assert.Contains(t, string(mail.HTML), expectedConfirmationLink.String())
// directly register again should trigger the debounce
// and not create a new confirmation token
registerAgainRes := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", payload, nil)
require.Equal(t, http.StatusAccepted, registerAgainRes.Result().StatusCode)
var registerAgainResponse types.RegisterResponse
test.ParseResponseAndValidate(t, registerAgainRes, &registerAgainResponse)
// expect the confirmation to be required
assert.True(t, swag.BoolValue(registerAgainResponse.RequiresConfirmation))
confirmationTokenCount, err := user.ConfirmationTokens().Count(ctx, s.DB)
require.NoError(t, err)
assert.EqualValues(t, 1, confirmationTokenCount)
// register later again
test.SetMockClock(t, s, s.Clock.Now().Add(config.Auth.ConfirmationTokenDebounceDuration+time.Second))
registerLaterAgainRes := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", payload, nil)
require.Equal(t, http.StatusAccepted, registerLaterAgainRes.Result().StatusCode)
var registerLaterAgainResponse types.RegisterResponse
test.ParseResponseAndValidate(t, registerLaterAgainRes, &registerLaterAgainResponse)
assert.True(t, swag.BoolValue(registerLaterAgainResponse.RequiresConfirmation))
confirmationTokens, err := models.ConfirmationTokens(
models.ConfirmationTokenWhere.UserID.EQ(user.ID),
db.OrderBy(types.OrderDirDesc, models.ConfirmationTokenColumns.CreatedAt),
).All(ctx, s.DB)
require.NoError(t, err)
require.Len(t, confirmationTokens, 2)
lastSentMail := test.GetLastSentMail(t, s.Mailer)
require.NotNil(t, lastSentMail)
expectedConfirmationLink, err = url.ConfirmationDeeplinkURL(s.Config, confirmationTokens[0].Token)
require.NoError(t, err)
assert.Equal(t, username, lastSentMail.To[0])
assert.Contains(t, string(lastSentMail.HTML), expectedConfirmationLink.String())
})
}
func TestPostRegisterSuccessLowercaseTrimWhitespaces(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
username := " USERNEW@example.com "
usernameLowerTrimmed := "usernew@example.com"
payload := test.GenericPayload{
"username": username,
"password": fixtures.PlainTestUserPassword,
"name": "Trim Whitespaces",
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", payload, nil)
require.Equal(t, http.StatusOK, res.Result().StatusCode)
var response types.PostLoginResponse
test.ParseResponseAndValidate(t, res, &response)
assert.NotEmpty(t, response.AccessToken)
assert.NotEmpty(t, response.RefreshToken)
assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response.ExpiresIn)
assert.Equal(t, auth.TokenTypeBearer, *response.TokenType)
user, err := models.Users(
models.UserWhere.Username.EQ(null.StringFrom(usernameLowerTrimmed)),
qm.Load(models.UserRels.AppUserProfile),
qm.Load(models.UserRels.AccessTokens),
qm.Load(models.UserRels.RefreshTokens),
).One(ctx, s.DB)
require.NoError(t, err)
assert.Equal(t, null.StringFrom(usernameLowerTrimmed), user.Username)
assert.True(t, user.LastAuthenticatedAt.Valid)
assert.WithinDuration(t, s.Clock.Now(), user.LastAuthenticatedAt.Time, time.Second*10)
assert.EqualValues(t, s.Config.Auth.DefaultUserScopes, user.Scopes)
assert.NotNil(t, user.R.AppUserProfile)
assert.False(t, user.R.AppUserProfile.LegalAcceptedAt.Valid)
assert.Len(t, user.R.AccessTokens, 1)
assert.Equal(t, strfmt.UUID4(user.R.AccessTokens[0].Token), *response.AccessToken)
assert.Len(t, user.R.RefreshTokens, 1)
assert.Equal(t, strfmt.UUID4(user.R.RefreshTokens[0].Token), *response.RefreshToken)
res2 := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
assert.Equal(t, http.StatusOK, res2.Result().StatusCode)
var response2 types.PostLoginResponse
test.ParseResponseAndValidate(t, res2, &response2)
assert.NotEmpty(t, response2.AccessToken)
assert.NotEqual(t, response.AccessToken, *response2.AccessToken)
assert.NotEmpty(t, response2.RefreshToken)
assert.NotEqual(t, response.RefreshToken, *response2.RefreshToken)
assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response2.ExpiresIn)
assert.Equal(t, auth.TokenTypeBearer, *response2.TokenType)
})
}
func TestPostRegisterAlreadyExists(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
payload := test.GenericPayload{
"username": fix.User1.Username,
"password": fixtures.PlainTestUserPassword,
}
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", payload, nil)
test.RequireHTTPError(t, res, httperrors.ErrConflictUserAlreadyExists)
user, err := models.Users(
models.UserWhere.Username.EQ(fix.User1.Username),
qm.Load(models.UserRels.AppUserProfile),
qm.Load(models.UserRels.AccessTokens),
qm.Load(models.UserRels.RefreshTokens),
).One(ctx, s.DB)
require.NoError(t, err)
assert.Equal(t, user.ID, fix.User1.ID)
assert.NotNil(t, user.R.AppUserProfile)
assert.Len(t, user.R.AccessTokens, 1)
assert.Len(t, user.R.RefreshTokens, 1)
})
}
func TestPostRegisterBadRequest(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
fix := fixtures.Fixtures()
tests := []struct {
name string
payload test.GenericPayload
}{
{
name: "MissingUsername",
payload: test.GenericPayload{
"password": fixtures.PlainTestUserPassword,
},
},
{
name: "MissingPassword",
payload: test.GenericPayload{
"username": fix.User1.Username,
},
},
{
name: "InvalidUsername",
payload: test.GenericPayload{
"username": "definitely not an email",
"password": fixtures.PlainTestUserPassword,
},
},
{
name: "EmptyUsername",
payload: test.GenericPayload{
"username": "",
"password": fixtures.PlainTestUserPassword,
},
},
{
name: "EmptyPassword",
payload: test.GenericPayload{
"username": fix.User1.Username,
"password": "",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", tt.payload, nil)
assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
var response httperrors.HTTPValidationError
test.ParseResponseAndValidate(t, res, &response)
test.Snapshoter.Save(t, response)
})
}
})
}

View File

@@ -0,0 +1,55 @@
// nolint:revive
package common
import (
"context"
"fmt"
"net/http"
"strings"
"allaboutapps.dev/aw/go-starter/internal/api"
"github.com/labstack/echo/v4"
)
const (
// We use 521 to indicate an error state
// same as Cloudflare: https://support.cloudflare.com/hc/en-us/articles/115003011431#521error
httpStatusDown = 521
)
func GetHealthyRoute(s *api.Server) *echo.Route {
return s.Router.Management.GET("/healthy", getHealthyHandler(s))
}
// Heathly check (= liveness)
// Returns an human readable string about the current service status.
// In addition to readiness probes, it performs actual write probes.
// Note that /-/healthy is private (shielded by the mgmt-secret) as it may expose sensitive information about your service.
// Structured upon https://prometheus.io/docs/prometheus/latest/management_api/
func getHealthyHandler(s *api.Server) echo.HandlerFunc {
return func(c echo.Context) error {
if !s.Ready() {
return c.String(httpStatusDown, "Not ready.")
}
var str strings.Builder
fmt.Fprintln(&str, "Ready.")
// General Timeout and associated context.
ctx, cancel := context.WithTimeout(c.Request().Context(), s.Config.Management.LivenessTimeout)
defer cancel()
healthyStr, errs := ProbeLiveness(ctx, s.DB, s.Config.Management.ProbeWriteablePathsAbs, s.Config.Management.ProbeWriteableTouchfile)
str.WriteString(healthyStr)
// Finally return the health status according to the seen states
if ctx.Err() != nil || len(errs) != 0 {
fmt.Fprintln(&str, "Probes failed.")
return c.String(httpStatusDown, str.String())
}
fmt.Fprintln(&str, "Probes succeeded.")
return c.String(http.StatusOK, str.String())
}
}

View File

@@ -0,0 +1,110 @@
package common_test
import (
"net/http"
"os"
"path"
"testing"
"time"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetHealthySuccess(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
// explicitly set touchfile that no other test has (so we can explicitly remove it beforehand.)
s.Config.Management.ProbeWriteableTouchfile = ".healthy-test"
for _, writeablePath := range s.Config.Management.ProbeWriteablePathsAbs {
os.Remove(path.Join(writeablePath, s.Config.Management.ProbeWriteableTouchfile))
// also remove after test completion.
defer os.Remove(path.Join(writeablePath, s.Config.Management.ProbeWriteableTouchfile))
}
res := test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret="+s.Config.Management.Secret, nil, nil)
require.Equal(t, http.StatusOK, res.Result().StatusCode)
require.Contains(t, res.Body.String(), "seq_health=1")
firstTouchTime := make([]time.Time, len(s.Config.Management.ProbeWriteablePathsAbs))
// expect a new touchfiles were written
for i, writeablePath := range s.Config.Management.ProbeWriteablePathsAbs {
filePath := path.Join(writeablePath, s.Config.Management.ProbeWriteableTouchfile)
stat, err := os.Stat(filePath)
require.NoErrorf(t, err, "Expected to have %v", filePath)
firstTouchTime[i] = stat.ModTime()
}
res = test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret="+s.Config.Management.Secret, nil, nil)
require.Equal(t, http.StatusOK, res.Result().StatusCode)
require.Contains(t, res.Body.String(), "seq_health=2")
// expect touchfiles modTime was updated
for i, writeablePath := range s.Config.Management.ProbeWriteablePathsAbs {
filePath := path.Join(writeablePath, s.Config.Management.ProbeWriteableTouchfile)
stat, err := os.Stat(filePath)
require.NoErrorf(t, err, "Expected to have %v", filePath)
assert.NotEqual(t, firstTouchTime[i], stat.ModTime())
}
})
}
func TestGetHealthyNoAuth(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
res := test.PerformRequest(t, s, "GET", "/-/healthy", nil, nil)
require.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
})
}
func TestGetHealthyWrongAuth(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
res := test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret=i-have-no-idea-about-the-pass", nil, nil)
require.Equal(t, http.StatusUnauthorized, res.Result().StatusCode)
})
}
func TestGetHealthyDBPingError(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
// forcefully close the DB
s.DB.Close()
res := test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret="+s.Config.Management.Secret, nil, nil)
require.Equal(t, 521, res.Result().StatusCode)
})
}
func TestGetHealthyDBSeqError(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
// forcefully remove the sequence
if _, err := s.DB.Exec("DROP SEQUENCE seq_health;"); err != nil {
t.Fatal(err, "was unable to drop sequence seq_health")
}
res := test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret="+s.Config.Management.Secret, nil, nil)
require.Equal(t, 521, res.Result().StatusCode)
})
}
func TestGetHealthyMountError(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
s.Config.Management.ProbeWriteablePathsAbs = []string{"/this/path/does/not/exist"}
res := test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret="+s.Config.Management.Secret, nil, nil)
require.Equal(t, 521, res.Result().StatusCode)
})
}
func TestGetHealthyNotReady(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
// forcefully remove an initialized component to check if ready state works
s.Mailer = nil
res := test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret="+s.Config.Management.Secret, nil, nil)
require.Equal(t, 521, res.Result().StatusCode)
})
}

View File

@@ -0,0 +1,40 @@
// nolint:revive
package common
import (
"context"
"net/http"
"allaboutapps.dev/aw/go-starter/internal/api"
"github.com/labstack/echo/v4"
)
func GetReadyRoute(s *api.Server) *echo.Route {
return s.Router.Management.GET("/ready", getReadyHandler(s))
}
// Readiness check
// This endpoint returns 200 when our Service is ready to serve traffic (i.e. respond to queries).
// Does read-only probes apart from the general server ready state.
// Note that /-/ready is typically public (and not shielded by a mgmt-secret), we thus prevent information leakage here and only return `"Ready."`.
// Structured upon https://prometheus.io/docs/prometheus/latest/management_api/
func getReadyHandler(s *api.Server) echo.HandlerFunc {
return func(c echo.Context) error {
if !s.Ready() {
return c.String(httpStatusDown, "Not ready.")
}
// General Timeout and associated context.
ctx, cancel := context.WithTimeout(c.Request().Context(), s.Config.Management.ReadinessTimeout)
defer cancel()
_, errs := ProbeReadiness(ctx, s.DB, s.Config.Management.ProbeWriteablePathsAbs)
// Finally return the health status according to the seen states
if ctx.Err() != nil || len(errs) != 0 {
return c.String(httpStatusDown, "Not ready.")
}
return c.String(http.StatusOK, "Ready.")
}
}

View File

@@ -0,0 +1,41 @@
package common_test
import (
"net/http"
"testing"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/test"
"github.com/stretchr/testify/require"
)
func TestGetReadyReadiness(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
res := test.PerformRequest(t, s, "GET", "/-/ready", nil, nil)
require.Equal(t, http.StatusOK, res.Result().StatusCode)
require.Equal(t, "Ready.", res.Body.String())
})
}
func TestGetReadyReadinessBroken(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
// forcefully remove an initialized component to check if ready state works
s.Mailer = nil
res := test.PerformRequest(t, s, "GET", "/-/ready", nil, nil)
require.Equal(t, 521, res.Result().StatusCode)
require.Equal(t, "Not ready.", res.Body.String())
})
}
func TestGetReadyDBBrokenNotReady(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
// forcefully remove pg
err := s.DB.Close()
require.NoError(t, err)
res := test.PerformRequest(t, s, "GET", "/-/ready", nil, nil)
require.Equal(t, 521, res.Result().StatusCode)
require.Equal(t, "Not ready.", res.Body.String())
})
}

View File

@@ -0,0 +1,20 @@
// nolint:revive
package common
import (
"path/filepath"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/api/middleware"
"github.com/labstack/echo/v4"
)
func GetSwaggerRoute(s *api.Server) *echo.Route {
// ---
// Serve generated swagger.yml file statically at /swagger.yml
// hack: not attached to group - can go away after echo/group.go .File and .Static actually return the *echo.Route
// see https://github.com/labstack/echo/issues/1595
// return s.Router.Root.File("swagger.yml", filepath.Join(s.Config.Echo.APIBaseDirAbs, "swagger.yml"))
// we explicitly enforce a no-cache directive on any requests to it.
return s.Echo.File("/swagger.yml", filepath.Join(s.Config.Paths.APIBaseDirAbs, "swagger.yml"), middleware.NoCache())
}

View File

@@ -0,0 +1,32 @@
package common_test
import (
"net/http"
"testing"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSwaggerYAMLRetrieval(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
res := test.PerformRequest(t, s, "GET", "/swagger.yml", nil, nil)
require.Equal(t, http.StatusOK, res.Result().StatusCode)
// caching: ensure this call is always uncached for browsers
assert.Equal(t, "no-cache, private, max-age=0", res.Header().Get("Cache-Control"))
assert.Equal(t, "Thu, 01 Jan 1970 00:00:00 UTC", res.Header().Get("Expires"))
assert.Equal(t, "0", res.Header().Get("X-Accel-Expires"))
assert.Equal(t, "no-cache", res.Header().Get("Pragma"))
// caching: unset
assert.Empty(t, res.Header().Get("ETag"))
assert.Empty(t, res.Header().Get("If-Modified-Since"))
assert.Empty(t, res.Header().Get("If-Match"))
assert.Empty(t, res.Header().Get("If-None-Match"))
assert.Empty(t, res.Header().Get("If-Range"))
assert.Empty(t, res.Header().Get("If-Unmodified-Since"))
})
}

View File

@@ -0,0 +1,21 @@
// nolint:revive
package common
import (
"net/http"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/config"
"github.com/labstack/echo/v4"
)
func GetVersionRoute(s *api.Server) *echo.Route {
return s.Router.Management.GET("/version", getVersionHandler(s))
}
// Returns the version and build date baked into the binary.
func getVersionHandler(_ *api.Server) echo.HandlerFunc {
return func(c echo.Context) error {
return c.String(http.StatusOK, config.GetFormattedBuildArgs())
}
}

View File

@@ -0,0 +1,33 @@
package common_test
import (
"net/http"
"testing"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetVersion(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
res := test.PerformRequest(t, s, "GET", "/-/version?mgmt-secret="+s.Config.Management.Secret, nil, nil)
require.Equal(t, http.StatusOK, res.Result().StatusCode)
require.Equal(t, "build.local/misses/ldflags @ < 40 chars git commit hash via ldflags > (1970-01-01T00:00:00+00:00)", res.Body.String()) // build args are not injected during test time.
// caching: ensure this call is always uncached for browsers
assert.Equal(t, "no-cache, private, max-age=0", res.Header().Get("Cache-Control"))
assert.Equal(t, "Thu, 01 Jan 1970 00:00:00 UTC", res.Header().Get("Expires"))
assert.Equal(t, "0", res.Header().Get("X-Accel-Expires"))
assert.Equal(t, "no-cache", res.Header().Get("Pragma"))
// caching: unset
assert.Empty(t, res.Header().Get("ETag"))
assert.Empty(t, res.Header().Get("If-Modified-Since"))
assert.Empty(t, res.Header().Get("If-Match"))
assert.Empty(t, res.Header().Get("If-None-Match"))
assert.Empty(t, res.Header().Get("If-Range"))
assert.Empty(t, res.Header().Get("If-Unmodified-Since"))
})
}

View File

@@ -0,0 +1,226 @@
// nolint:revive
package common
import (
"context"
"database/sql"
"fmt"
"path"
"strings"
"sync"
"time"
"allaboutapps.dev/aw/go-starter/internal/util"
"golang.org/x/sys/unix"
)
func ProbeReadiness(ctx context.Context, database *sql.DB, writeablePaths []string) (string, []error) {
var str strings.Builder
// slice collects all errors from probes
errs := make([]error, 0, 1+len(writeablePaths))
// DB readable?
dbPingStr, dbPingErr := probeDatabasePingable(ctx, database)
str.WriteString(dbPingStr)
if dbPingErr != nil {
errs = append(errs, dbPingErr)
}
// FS (potentially) writeable?
for _, writeablePath := range writeablePaths {
fsPermStr, fsPermErr := probePathWriteablePermission(ctx, writeablePath)
str.WriteString(fsPermStr)
if fsPermErr != nil {
errs = append(errs, fsPermErr)
}
}
// Feel free to add additional probes here...
return str.String(), errs
}
func ProbeLiveness(ctx context.Context, database *sql.DB, writeablePaths []string, touch string) (string, []error) {
// fail immediately if any readiness probes above have already failed.
readinessProbeStr, readinessProbeErrs := ProbeReadiness(ctx, database, writeablePaths)
if len(readinessProbeErrs) != 0 {
return readinessProbeStr, readinessProbeErrs
}
var str strings.Builder
// include previous readiness probe results in final string
str.WriteString(readinessProbeStr)
// slice collects all errors from probes
errs := make([]error, 0, 1+len(writeablePaths))
// DB writeable?
dbHealthStr, dbHealthErr := probeDatabaseNextHealthSequence(ctx, database)
str.WriteString(dbHealthStr)
if dbHealthErr != nil {
errs = append(errs, dbHealthErr)
}
// FS writeable?
for _, writeablePath := range writeablePaths {
fsTouchStr, fsTouchErr := probePathWriteableTouch(ctx, writeablePath, touch)
str.WriteString(fsTouchStr)
if fsTouchErr != nil {
errs = append(errs, fsTouchErr)
}
}
// Feel free to add additional probes here...
return str.String(), errs
}
// FS (especially hard mounted NFS paths) or PostgreSQL calls may be blocking or running for too long and thus need to run detached
// We additionally want them to timeout (e.g. useful for hard mounted NFS paths)
// Typically a any context used here will already have a deadline associated
// If not we will explicitly return a short one here.
func ensureProbeDeadlineFromContext(ctx context.Context) time.Time {
ctxDeadline, hasDeadline := ctx.Deadline()
if !hasDeadline {
ctxDeadline = time.Now().Add(1 * time.Second)
}
return ctxDeadline
}
func probeDatabasePingable(ctx context.Context, database *sql.DB) (string, error) {
var str strings.Builder
ctxDeadline := ensureProbeDeadlineFromContext(ctx)
dbPingStart := time.Now()
var dbPingWg sync.WaitGroup
var dbErr error
dbPingWg.Add(1)
go func() {
dbErr = database.PingContext(ctx)
dbPingWg.Done()
}()
if err := util.WaitTimeout(&dbPingWg, time.Until(ctxDeadline)); err != nil {
fmt.Fprintf(&str, "Probe db: Ping deadline after %s, error=%v.\n", time.Since(dbPingStart), err.Error())
return str.String(), err
}
if dbErr != nil {
fmt.Fprintf(&str, "Probe db: Ping errored after %s, error=%v.\n", time.Since(dbPingStart), dbErr.Error())
return str.String(), dbErr
}
fmt.Fprintf(&str, "Probe db: Ping succeeded in %s.\n", time.Since(dbPingStart))
return str.String(), nil
}
func probeDatabaseNextHealthSequence(ctx context.Context, database *sql.DB) (string, error) {
var str strings.Builder
ctxDeadline := ensureProbeDeadlineFromContext(ctx)
dbWriteStart := time.Now()
var seqVal int
var dbWriteWg sync.WaitGroup
var dbErr error
dbWriteWg.Add(1)
go func() {
dbErr = database.QueryRowContext(ctx, "SELECT nextval('seq_health');").Scan(&seqVal)
dbWriteWg.Done()
}()
if err := util.WaitTimeout(&dbWriteWg, time.Until(ctxDeadline)); err != nil {
fmt.Fprintf(&str, "Probe db: Next health sequence deadline after %s, error=%v.\n", time.Since(dbWriteStart), err.Error())
return str.String(), err
}
if dbErr != nil {
fmt.Fprintf(&str, "Probe db: Next health sequence errored after %s, error=%v.\n", time.Since(dbWriteStart), dbErr.Error())
return str.String(), dbErr
}
fmt.Fprintf(&str, "Probe db: Next health sequence succeeded in %s, seq_health=%v.\n", time.Since(dbWriteStart), seqVal)
return str.String(), nil
}
func probePathWriteablePermission(ctx context.Context, writeablePath string) (string, error) {
var str strings.Builder
ctxDeadline := ensureProbeDeadlineFromContext(ctx)
fsWriteStart := time.Now()
if ctx.Err() != nil {
fmt.Fprintf(&str, "Probe path '%s': W_OK check cancelled after %s, error=%v.\n", writeablePath, time.Since(fsWriteStart), ctx.Err())
return str.String(), ctx.Err()
}
var fsWriteWg sync.WaitGroup
var fsWriteErr error
fsWriteWg.Add(1)
go func(wp string) {
fsWriteErr = unix.Access(wp, unix.W_OK)
fsWriteWg.Done()
}(writeablePath)
if err := util.WaitTimeout(&fsWriteWg, time.Until(ctxDeadline)); err != nil {
fmt.Fprintf(&str, "Probe path '%s': W_OK check deadline after %s, error=%v.\n", writeablePath, time.Since(fsWriteStart), err)
return str.String(), err
}
if fsWriteErr != nil {
fmt.Fprintf(&str, "Probe path '%s': W_OK check errored after %s, error=%v.\n", writeablePath, time.Since(fsWriteStart), fsWriteErr.Error())
return str.String(), fsWriteErr
}
fmt.Fprintf(&str, "Probe path '%s': W_OK check succeeded in %s.\n", writeablePath, time.Since(fsWriteStart))
return str.String(), nil
}
func probePathWriteableTouch(ctx context.Context, writeablePath string, touch string) (string, error) {
var str strings.Builder
ctxDeadline := ensureProbeDeadlineFromContext(ctx)
fsTouchStart := time.Now()
fsTouchNameAbs := path.Join(writeablePath, touch)
if ctx.Err() != nil {
fmt.Fprintf(&str, "Probe path '%s': Touch cancelled after %s, error=%v.\n", fsTouchNameAbs, time.Since(fsTouchStart), ctx.Err())
return str.String(), ctx.Err()
}
var fsTouchWg sync.WaitGroup
var fsTouchErr error
var fsTouchModTime time.Time
fsTouchWg.Add(1)
go func(tn string) {
fsTouchModTime, fsTouchErr = util.TouchFile(tn)
fsTouchWg.Done()
}(fsTouchNameAbs)
if err := util.WaitTimeout(&fsTouchWg, time.Until(ctxDeadline)); err != nil {
fmt.Fprintf(&str, "Probe path '%s': Touch deadline after %s, error=%v.\n", fsTouchNameAbs, time.Since(fsTouchStart), err)
return str.String(), err
}
if fsTouchErr != nil {
fmt.Fprintf(&str, "Probe path '%s': Touch errored after %s, error=%v.\n", fsTouchNameAbs, time.Since(fsTouchStart), fsTouchErr.Error())
return str.String(), fsTouchErr
}
fmt.Fprintf(&str, "Probe path '%s': Touch succeeded in %s, modTime=%v.\n", fsTouchNameAbs, time.Since(fsTouchStart), fsTouchModTime.Unix())
return str.String(), nil
}

View File

@@ -0,0 +1,70 @@
// nolint:revive
package common
import (
"context"
"database/sql"
"errors"
"os"
"testing"
"time"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEnsureDeadline(t *testing.T) {
deadline := time.Now().Add(1 * time.Second)
ctx, cancel := context.WithDeadline(t.Context(), deadline)
defer cancel()
receivedDeadline := ensureProbeDeadlineFromContext(ctx)
assert.Equal(t, deadline, receivedDeadline)
}
func TestDummyDeadlineWithinOneSec(t *testing.T) {
ctx := t.Context()
receivedDeadline := ensureProbeDeadlineFromContext(ctx)
assert.WithinDuration(t, time.Now().Add(1*time.Second), receivedDeadline, 100*time.Millisecond)
}
func TestProbeDatabasePingableDeadline(t *testing.T) {
ctx, cancel := context.WithDeadline(t.Context(), time.Now())
defer cancel()
_, err := probeDatabasePingable(ctx, &sql.DB{})
assert.Truef(t, errors.Is(err, util.ErrWaitTimeout) || errors.Is(err, context.DeadlineExceeded), "err must be util.ErrWaitTimeout or context.DeadlineExceeded but is %v", err)
}
func TestProbeDatabaseNextHealthSequenceDeadline(t *testing.T) {
ctx, cancel := context.WithDeadline(t.Context(), time.Now())
defer cancel()
_, err := probeDatabaseNextHealthSequence(ctx, &sql.DB{})
assert.Truef(t, errors.Is(err, util.ErrWaitTimeout) || errors.Is(err, context.DeadlineExceeded), "err must be util.ErrWaitTimeout or context.DeadlineExceeded but is %v", err)
}
func TestProbePathWriteablePermissionContextDeadline(t *testing.T) {
ctx, cancel := context.WithDeadline(t.Context(), time.Now())
defer cancel()
_, err := probePathWriteablePermission(ctx, "/any/thing")
assert.Truef(t, errors.Is(err, util.ErrWaitTimeout) || errors.Is(err, context.DeadlineExceeded), "err must be util.ErrWaitTimeout or context.DeadlineExceeded but is %v", err)
}
func TestProbePathWriteableTouchContextDeadline(t *testing.T) {
ctx, cancel := context.WithDeadline(t.Context(), time.Now())
defer cancel()
_, err := probePathWriteableTouch(ctx, "/any/thing", ".touch")
assert.Truef(t, errors.Is(err, util.ErrWaitTimeout) || errors.Is(err, context.DeadlineExceeded), "err must be util.ErrWaitTimeout or context.DeadlineExceeded but is %v", err)
}
func TestProbePathWriteableTouchInaccessable(t *testing.T) {
_, err := probePathWriteableTouch(t.Context(), "/this/path/does/not/exist", ".touch")
require.Error(t, err)
assert.ErrorIs(t, err, os.ErrNotExist)
}

View File

@@ -0,0 +1,5 @@
package constants
const (
RegistrationTokenParam = "registrationToken"
)

View File

@@ -0,0 +1,35 @@
// Code generated by go run -tags scripts scripts/handlers/gen_handlers.go; DO NOT EDIT.
package handlers
import (
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/api/handlers/auth"
"allaboutapps.dev/aw/go-starter/internal/api/handlers/common"
"allaboutapps.dev/aw/go-starter/internal/api/handlers/push"
"allaboutapps.dev/aw/go-starter/internal/api/handlers/wellknown"
"github.com/labstack/echo/v4"
)
func AttachAllRoutes(s *api.Server) {
// attach our routes
s.Router.Routes = []*echo.Route{
auth.DeleteUserAccountRoute(s),
auth.GetCompleteRegisterRoute(s),
auth.GetUserInfoRoute(s),
auth.PostChangePasswordRoute(s),
auth.PostCompleteRegisterRoute(s),
auth.PostForgotPasswordCompleteRoute(s),
auth.PostForgotPasswordRoute(s),
auth.PostLoginRoute(s),
auth.PostLogoutRoute(s),
auth.PostRefreshRoute(s),
auth.PostRegisterRoute(s),
common.GetHealthyRoute(s),
common.GetReadyRoute(s),
common.GetSwaggerRoute(s),
common.GetVersionRoute(s),
push.PutUpdatePushTokenRoute(s),
wellknown.GetAndroidDigitalAssetLinksRoute(s),
wellknown.GetAppleAppSiteAssociationRoute(s),
}
}

View File

@@ -0,0 +1,44 @@
package push
import (
"net/http"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/auth"
"allaboutapps.dev/aw/go-starter/internal/data/dto"
"allaboutapps.dev/aw/go-starter/internal/types"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/aarondl/null/v8"
"github.com/go-openapi/swag"
"github.com/labstack/echo/v4"
)
func PutUpdatePushTokenRoute(s *api.Server) *echo.Route {
return s.Router.APIV1Push.PUT("/token", putUpdatePushTokenHandler(s))
}
func putUpdatePushTokenHandler(s *api.Server) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
user := auth.UserFromEchoContext(c)
log := util.LogFromContext(ctx)
var body types.PutUpdatePushTokenPayload
if err := util.BindAndValidateBody(c, &body); err != nil {
return err
}
err := s.Local.UpdatePushToken(ctx, dto.UpdatePushTokenRequest{
User: *user,
Token: swag.StringValue(body.NewToken),
Provider: swag.StringValue(body.Provider),
ExistingToken: null.StringFromPtr(body.OldToken),
})
if err != nil {
log.Debug().Err(err).Msg("Failed to update push token")
return err
}
return c.String(http.StatusOK, "Success")
}
}

View File

@@ -0,0 +1,169 @@
package push_test
import (
"database/sql"
"net/http"
"testing"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
"allaboutapps.dev/aw/go-starter/internal/models"
"allaboutapps.dev/aw/go-starter/internal/test"
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
"github.com/aarondl/sqlboiler/v4/boil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPutUpdatePushTokenSuccess(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
//nolint:gosec
testToken := "869f6deb-73e6-4691-9d40-2a2a794006cf"
testProvider := models.ProviderTypeFCM
payload := test.GenericPayload{
"newToken": testToken,
"provider": testProvider,
}
res := test.PerformRequest(t, s, "PUT", "/api/v1/push/token", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
assert.Equal(t, http.StatusOK, res.Result().StatusCode)
newToken, err := models.PushTokens(models.PushTokenWhere.Token.EQ(testToken)).One(ctx, s.DB)
require.NoError(t, err)
assert.NotEmpty(t, newToken.ID)
assert.Equal(t, testToken, newToken.Token)
assert.Equal(t, testProvider, newToken.Provider)
assert.Equal(t, fix.User1.ID, newToken.UserID)
})
}
func TestPutUpdatePushTokenSuccessWithOldToken(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
//nolint:gosec
oldToken := "6803ccb4-c91d-47b2-960e-291afa5e29cd"
oldPushToken := models.PushToken{
Token: oldToken,
Provider: models.ProviderTypeFCM,
UserID: fix.User1.ID,
}
err := oldPushToken.Insert(ctx, s.DB, boil.Infer())
require.NoError(t, err)
//nolint:gosec
testToken := "af55b6cf-1fb0-4bb7-960c-25268a5ce7c3"
testProvider := models.ProviderTypeFCM
payload := test.GenericPayload{
"newToken": testToken,
"provider": testProvider,
"oldToken": oldToken,
}
res := test.PerformRequest(t, s, "PUT", "/api/v1/push/token", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
assert.Equal(t, http.StatusOK, res.Result().StatusCode)
newToken, err := models.PushTokens(models.PushTokenWhere.Token.EQ(testToken)).One(ctx, s.DB)
require.NoError(t, err)
assert.NotEmpty(t, newToken.ID)
assert.Equal(t, testToken, newToken.Token)
assert.Equal(t, testProvider, newToken.Provider)
assert.Equal(t, fix.User1.ID, newToken.UserID)
err = oldPushToken.Reload(ctx, s.DB)
require.ErrorIs(t, err, sql.ErrNoRows)
})
}
func TestPutUpdatePushTokenWithDuplicateToken(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
//nolint:gosec
oldToken := "6803ccb4-c91d-47b2-960e-291afa5e29cd"
oldPushToken := models.PushToken{
Token: oldToken,
Provider: models.ProviderTypeFCM,
UserID: fix.User1.ID,
}
err := oldPushToken.Insert(ctx, s.DB, boil.Infer())
require.NoError(t, err)
testProvider := models.ProviderTypeFCM
payload := test.GenericPayload{
"newToken": oldToken,
"provider": testProvider,
"oldToken": oldToken,
}
oldCnt, err := fix.User1.PushTokens().Count(ctx, s.DB)
require.NoError(t, err)
res := test.PerformRequest(t, s, "PUT", "/api/v1/push/token", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
test.RequireHTTPError(t, res, httperrors.ErrConflictPushToken)
err = oldPushToken.Reload(ctx, s.DB)
require.NoError(t, err)
cnt, err := fix.User1.PushTokens().Count(ctx, s.DB)
require.NoError(t, err)
assert.Equal(t, oldCnt, cnt)
})
}
func TestPutUpdatePushTokenWithOldTokenNotfound(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
ctx := t.Context()
fix := fixtures.Fixtures()
//nolint:gosec
oldToken := "cc08624a-b40d-4b8e-bbfe-f62aabb47592"
oldPushToken := models.PushToken{
Token: oldToken,
Provider: models.ProviderTypeFCM,
UserID: fix.User1.ID,
}
err := oldPushToken.Insert(ctx, s.DB, boil.Infer())
require.NoError(t, err)
oldCnt, err := fix.User1.PushTokens().Count(ctx, s.DB)
require.NoError(t, err)
//nolint:gosec
testToken := "8e4ad85f-cbb6-4ef3-a455-d9d8bd8917b3"
testProvider := models.ProviderTypeFCM
payload := test.GenericPayload{
"newToken": testToken,
"provider": testProvider,
"oldToken": "3199aa21-eb41-47dd-9287-338e9e88a5ae",
}
res := test.PerformRequest(t, s, "PUT", "/api/v1/push/token", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
test.RequireHTTPError(t, res, httperrors.ErrNotFoundOldPushToken)
newToken, err := models.PushTokens(models.PushTokenWhere.Token.EQ(testToken)).One(ctx, s.DB)
require.NoError(t, err)
assert.NotEmpty(t, newToken.ID)
assert.Equal(t, testToken, newToken.Token)
assert.Equal(t, testProvider, newToken.Provider)
assert.Equal(t, fix.User1.ID, newToken.UserID)
err = oldPushToken.Reload(ctx, s.DB)
require.NoError(t, err)
cnt, err := fix.User1.PushTokens().Count(ctx, s.DB)
require.NoError(t, err)
assert.Equal(t, oldCnt+1, cnt)
})
}

View File

@@ -0,0 +1,23 @@
package wellknown
import (
"allaboutapps.dev/aw/go-starter/internal/api"
"github.com/labstack/echo/v4"
)
func GetAndroidDigitalAssetLinksRoute(s *api.Server) *echo.Route {
return s.Router.WellKnown.GET("/assetlinks.json", getAndroidDigitalAssetLinksHandler(s))
}
func getAndroidDigitalAssetLinksHandler(s *api.Server) echo.HandlerFunc {
return func(c echo.Context) error {
if s.Config.Paths.AndroidAssetlinksFile == "" {
return echo.ErrNotFound
}
c.Response().Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
c.Response().Header().Set("Content-Type", "application/json")
return c.File(s.Config.Paths.AndroidAssetlinksFile)
}
}

View File

@@ -0,0 +1,30 @@
package wellknown_test
import (
"path/filepath"
"testing"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
"allaboutapps.dev/aw/go-starter/internal/config"
"allaboutapps.dev/aw/go-starter/internal/test"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/labstack/echo/v4"
)
func TestGetAndroidWellKnown(t *testing.T) {
config := config.DefaultServiceConfigFromEnv()
config.Paths.AndroidAssetlinksFile = filepath.Join(util.GetProjectRootDir(), "test", "testdata", "android-assetlinks.json")
testGetWellKnown(t, config, "/.well-known/assetlinks.json")
}
func TestGetAndroidWellKnownNotFound(t *testing.T) {
config := config.DefaultServiceConfigFromEnv()
config.Paths.AndroidAssetlinksFile = ""
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
res := test.PerformRequest(t, s, "GET", "/.well-known/assetlinks.json", nil, nil)
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrNotFound))
})
}

View File

@@ -0,0 +1,21 @@
package wellknown
import (
"allaboutapps.dev/aw/go-starter/internal/api"
"github.com/labstack/echo/v4"
)
func GetAppleAppSiteAssociationRoute(s *api.Server) *echo.Route {
return s.Router.WellKnown.GET("/apple-app-site-association", getAppleAppSiteAssociationHandler(s))
}
func getAppleAppSiteAssociationHandler(s *api.Server) echo.HandlerFunc {
return func(c echo.Context) error {
if s.Config.Paths.AppleAppSiteAssociationFile == "" {
return echo.ErrNotFound
}
c.Response().Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
return c.File(s.Config.Paths.AppleAppSiteAssociationFile)
}
}

View File

@@ -0,0 +1,47 @@
package wellknown_test
import (
"io"
"net/http"
"path/filepath"
"testing"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
"allaboutapps.dev/aw/go-starter/internal/config"
"allaboutapps.dev/aw/go-starter/internal/test"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/require"
)
func testGetWellKnown(t *testing.T, config config.Server, path string) {
t.Helper()
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
res := test.PerformRequest(t, s, "GET", path, nil, nil)
require.Equal(t, http.StatusOK, res.Result().StatusCode)
result, err := io.ReadAll(res.Body)
require.NoError(t, err)
test.Snapshoter.SaveString(t, string(result))
})
}
func TestGetAppleWellKnown(t *testing.T) {
config := config.DefaultServiceConfigFromEnv()
config.Paths.AppleAppSiteAssociationFile = filepath.Join(util.GetProjectRootDir(), "test", "testdata", "apple-app-site-association.json")
testGetWellKnown(t, config, "/.well-known/apple-app-site-association")
}
func TestGetAppleWellKnownNotFound(t *testing.T) {
config := config.DefaultServiceConfigFromEnv()
config.Paths.AppleAppSiteAssociationFile = ""
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
res := test.PerformRequest(t, s, "GET", "/.well-known/apple-app-site-association", nil, nil)
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrNotFound))
})
}

View File

@@ -0,0 +1,16 @@
package httperrors
import (
"net/http"
"allaboutapps.dev/aw/go-starter/internal/types"
)
var (
ErrForbiddenUserDeactivated = NewHTTPError(http.StatusForbidden, types.PublicHTTPErrorTypeUSERDEACTIVATED, "User account is deactivated")
ErrBadRequestInvalidPassword = NewHTTPErrorWithDetail(http.StatusBadRequest, types.PublicHTTPErrorTypeINVALIDPASSWORD, "The password provided was invalid", "Password was either too weak or did not match other criteria")
ErrForbiddenNotLocalUser = NewHTTPError(http.StatusForbidden, types.PublicHTTPErrorTypeNOTLOCALUSER, "User account is not valid for local authentication")
ErrNotFoundTokenNotFound = NewHTTPError(http.StatusNotFound, types.PublicHTTPErrorTypeTOKENNOTFOUND, "Provided token was not found")
ErrConflictTokenExpired = NewHTTPError(http.StatusConflict, types.PublicHTTPErrorTypeTOKENEXPIRED, "Provided token has expired and is no longer valid")
ErrConflictUserAlreadyExists = NewHTTPError(http.StatusConflict, types.PublicHTTPErrorTypeUSERALREADYEXISTS, "User with given username already exists")
)

View File

@@ -0,0 +1,11 @@
package httperrors
import (
"net/http"
"allaboutapps.dev/aw/go-starter/internal/types"
)
var (
ErrBadRequestZeroFileSize = NewHTTPError(http.StatusBadRequest, types.PublicHTTPErrorTypeZEROFILESIZE, "File size of 0 is not supported.")
)

View File

@@ -0,0 +1,147 @@
package httperrors
import (
"fmt"
"net/http"
"sort"
"strings"
"allaboutapps.dev/aw/go-starter/internal/types"
"github.com/go-openapi/swag"
"github.com/labstack/echo/v4"
)
// Payload in accordance with RFC 7807 (Problem Details for HTTP APIs) with the exception of the type
// value not being represented by a URI. https://tools.ietf.org/html/rfc7807 @ 2020-04-27T15:44:37Z
type HTTPError struct {
types.PublicHTTPError
Internal error `json:"-"`
AdditionalData map[string]interface{} `json:"-"`
}
type HTTPValidationError struct {
types.PublicHTTPValidationError
Internal error `json:"-"`
AdditionalData map[string]interface{} `json:"-"`
}
func NewHTTPError(code int, errorType types.PublicHTTPErrorType, title string) *HTTPError {
return &HTTPError{
PublicHTTPError: types.PublicHTTPError{
Code: swag.Int64(int64(code)),
Type: errorType.Pointer(),
Title: swag.String(title),
},
}
}
func NewHTTPErrorWithDetail(code int, errorType types.PublicHTTPErrorType, title string, detail string) *HTTPError {
return &HTTPError{
PublicHTTPError: types.PublicHTTPError{
Code: swag.Int64(int64(code)),
Type: errorType.Pointer(),
Title: swag.String(title),
Detail: detail,
},
}
}
func NewFromEcho(e *echo.HTTPError) *HTTPError {
return NewHTTPError(e.Code, types.PublicHTTPErrorTypeGeneric, http.StatusText(e.Code))
}
func (e *HTTPError) Error() string {
var builder strings.Builder
fmt.Fprintf(&builder, "HTTPError %d (%s): %s", *e.Code, *e.Type, *e.Title)
if len(e.Detail) > 0 {
fmt.Fprintf(&builder, " - %s", e.Detail)
}
if e.Internal != nil {
fmt.Fprintf(&builder, ", %v", e.Internal)
}
if len(e.AdditionalData) > 0 {
keys := make([]string, 0, len(e.AdditionalData))
for k := range e.AdditionalData {
keys = append(keys, k)
}
sort.Strings(keys)
builder.WriteString(". Additional: ")
for i, k := range keys {
fmt.Fprintf(&builder, "%s=%v", k, e.AdditionalData[k])
if i < len(keys)-1 {
builder.WriteString(", ")
}
}
}
return builder.String()
}
func NewHTTPValidationError(code int, errorType types.PublicHTTPErrorType, title string, validationErrors []*types.HTTPValidationErrorDetail) *HTTPValidationError {
return &HTTPValidationError{
PublicHTTPValidationError: types.PublicHTTPValidationError{
PublicHTTPError: types.PublicHTTPError{
Code: swag.Int64(int64(code)),
Type: errorType.Pointer(),
Title: swag.String(title),
},
ValidationErrors: validationErrors,
},
}
}
func NewHTTPValidationErrorWithDetail(code int, errorType types.PublicHTTPErrorType, title string, validationErrors []*types.HTTPValidationErrorDetail, detail string) *HTTPValidationError {
return &HTTPValidationError{
PublicHTTPValidationError: types.PublicHTTPValidationError{
PublicHTTPError: types.PublicHTTPError{
Code: swag.Int64(int64(code)),
Type: errorType.Pointer(),
Title: swag.String(title),
Detail: detail,
},
ValidationErrors: validationErrors,
},
}
}
func (e *HTTPValidationError) Error() string {
var builder strings.Builder
fmt.Fprintf(&builder, "HTTPValidationError %d (%s): %s", *e.Code, *e.Type, *e.Title)
if len(e.Detail) > 0 {
fmt.Fprintf(&builder, " - %s", e.Detail)
}
if e.Internal != nil {
fmt.Fprintf(&builder, ", %v", e.Internal)
}
if len(e.AdditionalData) > 0 {
keys := make([]string, 0, len(e.AdditionalData))
for k := range e.AdditionalData {
keys = append(keys, k)
}
sort.Strings(keys)
builder.WriteString(". Additional: ")
for i, k := range keys {
fmt.Fprintf(&builder, "%s=%v", k, e.AdditionalData[k])
if i < len(keys)-1 {
builder.WriteString(", ")
}
}
}
builder.WriteString(" - Validation: ")
for i, ve := range e.ValidationErrors {
fmt.Fprintf(&builder, "%s (in %s): %s", *ve.Key, *ve.In, *ve.Error)
if i < len(e.ValidationErrors)-1 {
builder.WriteString(", ")
}
}
return builder.String()
}

View File

@@ -0,0 +1,80 @@
package httperrors_test
import (
"database/sql"
"net/http"
"testing"
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
"allaboutapps.dev/aw/go-starter/internal/types"
"github.com/go-openapi/swag"
"github.com/stretchr/testify/require"
)
func TestHTTPErrorSimple(t *testing.T) {
err := httperrors.NewHTTPError(http.StatusNotFound, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusNotFound))
require.Equal(t, "HTTPError 404 (generic): Not Found", err.Error())
}
func TestHTTPErrorDetail(t *testing.T) {
err := httperrors.NewHTTPErrorWithDetail(http.StatusNotFound, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusNotFound), "ToS violation")
require.Equal(t, "HTTPError 404 (generic): Not Found - ToS violation", err.Error())
}
func TestHTTPErrorInternalError(t *testing.T) {
err := httperrors.NewHTTPError(http.StatusInternalServerError, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusInternalServerError))
err.Internal = sql.ErrConnDone
require.Equal(t, "HTTPError 500 (generic): Internal Server Error, sql: connection is already closed", err.Error())
}
func TestHTTPErrorAdditionalData(t *testing.T) {
err := httperrors.NewHTTPError(http.StatusInternalServerError, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusInternalServerError))
err.AdditionalData = map[string]interface{}{
"key1": "value1",
"key2": "value2",
}
require.Equal(t, "HTTPError 500 (generic): Internal Server Error. Additional: key1=value1, key2=value2", err.Error())
}
var valErrs = append(make([]*types.HTTPValidationErrorDetail, 0, 2), &types.HTTPValidationErrorDetail{
Key: swag.String("test1"),
In: swag.String("body.test1"),
Error: swag.String("ValidationError"),
}, &types.HTTPValidationErrorDetail{
Key: swag.String("test2"),
In: swag.String("body.test2"),
Error: swag.String("Validation Error"),
})
func TestHTTPValidationErrorSimple(t *testing.T) {
err := httperrors.NewHTTPValidationError(http.StatusBadRequest, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusBadRequest), valErrs)
require.Equal(t, "HTTPValidationError 400 (generic): Bad Request - Validation: test1 (in body.test1): ValidationError, test2 (in body.test2): Validation Error", err.Error())
}
func TestHTTPValidationErrorDetail(t *testing.T) {
err := httperrors.NewHTTPValidationErrorWithDetail(http.StatusBadRequest, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusBadRequest), valErrs, "Did API spec change?")
require.Equal(t, "HTTPValidationError 400 (generic): Bad Request - Did API spec change? - Validation: test1 (in body.test1): ValidationError, test2 (in body.test2): Validation Error", err.Error())
}
func TestHTTPValidationErrorInternalError(t *testing.T) {
err := httperrors.NewHTTPValidationError(http.StatusBadRequest, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusBadRequest), valErrs)
err.Internal = sql.ErrConnDone
require.Equal(t, "HTTPValidationError 400 (generic): Bad Request, sql: connection is already closed - Validation: test1 (in body.test1): ValidationError, test2 (in body.test2): Validation Error", err.Error())
}
func TestHTTPValidationErrorAdditionalData(t *testing.T) {
err := httperrors.NewHTTPValidationError(http.StatusBadRequest, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusBadRequest), valErrs)
err.AdditionalData = map[string]interface{}{
"key1": "value1",
"key2": "value2",
}
require.Equal(t, "HTTPValidationError 400 (generic): Bad Request. Additional: key1=value1, key2=value2 - Validation: test1 (in body.test1): ValidationError, test2 (in body.test2): Validation Error", err.Error())
}

View File

@@ -0,0 +1,12 @@
package httperrors
import (
"net/http"
"allaboutapps.dev/aw/go-starter/internal/types"
)
var (
ErrConflictPushToken = NewHTTPError(http.StatusConflict, types.PublicHTTPErrorTypePUSHTOKENALREADYEXISTS, "The given token already exists.")
ErrNotFoundOldPushToken = NewHTTPError(http.StatusNotFound, types.PublicHTTPErrorTypeOLDPUSHTOKENNOTFOUND, "The old push token does not exists. The new token was saved.")
)

View File

@@ -0,0 +1,390 @@
package middleware
import (
"database/sql"
"errors"
"fmt"
"net/http"
"time"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
"allaboutapps.dev/aw/go-starter/internal/auth"
"allaboutapps.dev/aw/go-starter/internal/data/dto"
"allaboutapps.dev/aw/go-starter/internal/data/mapper"
"allaboutapps.dev/aw/go-starter/internal/models"
"allaboutapps.dev/aw/go-starter/internal/types"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/aarondl/sqlboiler/v4/queries/qm"
"github.com/go-openapi/strfmt"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/rs/zerolog/log"
)
var (
ErrBadRequestMalformedToken = httperrors.NewHTTPError(http.StatusBadRequest, types.PublicHTTPErrorTypeMALFORMEDTOKEN, "Auth token is malformed")
ErrUnauthorizedLastAuthenticatedAtExceeded = httperrors.NewHTTPError(http.StatusUnauthorized, types.PublicHTTPErrorTypeLASTAUTHENTICATEDATEXCEEDED, "LastAuthenticatedAt timestamp exceeds threshold, re-authentication required")
ErrForbiddenMissingScopes = httperrors.NewHTTPError(http.StatusForbidden, types.PublicHTTPErrorTypeMISSINGSCOPES, "User is missing required scopes")
ErrAuthTokenValidationFailed = errors.New("auth token validation failed")
)
// AuthMode controls the type of authentication check performed for a specific route or group
type AuthMode int
const (
// AuthModeRequired requires an auth token to be present and valid in order to access the route or group
AuthModeRequired AuthMode = iota
// AuthModeSecure requires an auth token to be present and for the user to have recently re-confirmed their authentication in order to access the route or group
AuthModeSecure
// AuthModeOptional does not require an auth token to be present, however if it is, it must be valid in order to access the route or group
AuthModeOptional
// AuthModeTry does not require an auth token to be present in order to access the route or group and will process the request even if an invalid one has been provided
AuthModeTry
// AuthModeNone does not require an auth token to be present in order to access the route or group and will not attempt to parse any authentication provided
AuthModeNone
)
func (m AuthMode) String() string {
switch m {
case AuthModeRequired:
return "required"
case AuthModeSecure:
return "secure"
case AuthModeOptional:
return "optional"
case AuthModeTry:
return "try"
case AuthModeNone:
return "none"
default:
return fmt.Sprintf("unknown (%d)", m)
}
}
type AuthFailureMode int
const (
// AuthFailureModeUnauthorized returns a 401 Unauthorized response on missing or invalid authentication
AuthFailureModeUnauthorized AuthFailureMode = iota
// AuthFailureModeNotFound returns a 404 Not Found response on missing or invalid authentication
AuthFailureModeNotFound
)
func (m AuthFailureMode) String() string {
switch m {
case AuthFailureModeUnauthorized:
return "unauthorized"
case AuthFailureModeNotFound:
return "not_found"
default:
return fmt.Sprintf("unknown (%d)", m)
}
}
func (m AuthFailureMode) Error() error {
switch m {
case AuthFailureModeUnauthorized:
return echo.ErrUnauthorized
case AuthFailureModeNotFound:
return echo.ErrNotFound
default:
return echo.ErrInternalServerError
}
}
type AuthTokenSource int
const (
// AuthTokenSourceHeader retrieves the auth token from a header, specified by TokenSourceKey
AuthTokenSourceHeader AuthTokenSource = iota
// AuthTokenSourceQuery retrieves the auth token from a query parameter, specified by TokenSourceKey
AuthTokenSourceQuery
// AuthTokenSourceForm retrieves the auth token from a form parameter, specified by TokenSourceKey
AuthTokenSourceForm
)
func (s AuthTokenSource) String() string {
switch s {
case AuthTokenSourceHeader:
return "header"
case AuthTokenSourceQuery:
return "query"
case AuthTokenSourceForm:
return "form"
default:
return fmt.Sprintf("unknown (%d)", s)
}
}
func (s AuthTokenSource) Extract(c echo.Context, key string, scheme string) (string, bool) {
var token string
switch s {
case AuthTokenSourceHeader:
token = c.Request().Header.Get(key)
case AuthTokenSourceForm:
token = c.FormValue(key)
case AuthTokenSourceQuery:
token = c.QueryParam(key)
default:
return "", false
}
if len(token) == 0 {
return "", false
}
lenScheme := len(scheme)
if lenScheme == 0 {
return token, true
}
if len(token) < lenScheme+1 {
return "", true
}
if token[:lenScheme] != scheme {
return "", true
}
return token[lenScheme+1:], true
}
type AuthTokenFormatValidator func(string) bool
func DefaultAuthTokenFormatValidator(token string) bool {
return strfmt.IsUUID4(token)
}
type AuthTokenValidator func(c echo.Context, config AuthConfig, token string) (auth.Result, error)
func DefaultAuthTokenValidator(c echo.Context, config AuthConfig, token string) (auth.Result, error) {
accessToken, err := models.AccessTokens(
models.AccessTokenWhere.Token.EQ(token),
qm.Load(qm.Rels(models.AccessTokenRels.User, models.UserRels.AppUserProfile)),
).One(c.Request().Context(), config.S.DB)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
log.Trace().Err(err).Msg("Access token not found in database")
return auth.Result{}, ErrAuthTokenValidationFailed
}
log.Error().Err(err).Msg("Failed to query for access token in database, aborting request")
return auth.Result{}, echo.ErrInternalServerError
}
return auth.Result{
Token: accessToken.Token,
User: mapper.LocalUserToDTO(accessToken.R.User).Ptr(),
ValidUntil: accessToken.ValidUntil,
}, nil
}
var (
DefaultAuthConfig = AuthConfig{
Mode: AuthModeRequired,
FailureMode: AuthFailureModeUnauthorized,
TokenSource: AuthTokenSourceHeader,
TokenSourceKey: echo.HeaderAuthorization,
Scheme: "Bearer",
Skipper: middleware.DefaultSkipper,
FormatValidator: DefaultAuthTokenFormatValidator,
TokenValidator: DefaultAuthTokenValidator,
Scopes: []string{auth.ScopeApp.String()},
}
)
type AuthConfig struct {
S *api.Server // API server used for database and service access
Mode AuthMode // Controls type of authentication required (default: AuthModeRequired)
FailureMode AuthFailureMode // Controls response on auth failure (default: AuthFailureModeUnauthorized)
TokenSource AuthTokenSource // Sets source of auth token (default: AuthTokenSourceHeader)
TokenSourceKey string // Sets key for auth token source lookup (default: "Authorization")
Scheme string // Sets required token scheme (default: "Bearer")
Skipper middleware.Skipper // Controls skipping of certain routes (default: no skipped routes)
FormatValidator AuthTokenFormatValidator // Validates the format of the token retrieved
TokenValidator AuthTokenValidator // Validates token retrieved and returns associated user (default: performs lookup in access_tokens table)
Scopes []string // List of scopes required to access endpoint (default: none required)
}
func (c AuthConfig) CheckLastAuthenticatedAt(user *dto.User) bool {
if c.Mode != AuthModeSecure {
return true
}
if !user.LastAuthenticatedAt.Valid {
return false
}
return time.Since(user.LastAuthenticatedAt.Time).Seconds() <= c.S.Config.Auth.LastAuthenticatedAtThreshold.Seconds()
}
func (c AuthConfig) CheckUserScopes(user *dto.User) bool {
if len(c.Scopes) == 0 {
return true
}
if len(user.Scopes) == 0 {
return false
}
for _, scope := range c.Scopes {
for _, userScope := range user.Scopes {
if scope == userScope {
return true
}
}
}
return false
}
func Auth(s *api.Server) echo.MiddlewareFunc {
c := DefaultAuthConfig
c.S = s
return AuthWithConfig(c)
}
func AuthWithConfig(config AuthConfig) echo.MiddlewareFunc {
if config.S == nil {
panic("auth middleware: server is required")
}
if len(config.TokenSourceKey) == 0 {
config.TokenSourceKey = DefaultAuthConfig.TokenSourceKey
}
if len(config.Scheme) == 0 {
config.Scheme = DefaultAuthConfig.Scheme
}
if config.Skipper == nil {
config.Skipper = DefaultAuthConfig.Skipper
}
if config.FormatValidator == nil {
config.FormatValidator = DefaultAuthConfig.FormatValidator
}
if config.TokenValidator == nil {
config.TokenValidator = DefaultAuthConfig.TokenValidator
}
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
log := util.LogFromEchoContext(c).With().Str("middleware", "auth").Str("auth_mode", config.Mode.String()).Logger()
if config.Mode == AuthModeNone {
log.Trace().Msg("No authentication required, allowing request")
return next(c)
}
if config.Skipper(c) {
log.Trace().Msg("Skipping auth middleware, allowing request")
return next(c)
}
user := auth.UserFromEchoContext(c)
if user != nil {
if !config.CheckLastAuthenticatedAt(user) {
log.Trace().
Time("last_authenticated_at", user.LastAuthenticatedAt.Time).
Dur("last_authenticated_at_threshold", config.S.Config.Auth.LastAuthenticatedAtThreshold).
Msg("Authentication already performed, but last authenticated at time exceeds threshold, rejecting request")
return ErrUnauthorizedLastAuthenticatedAtExceeded
}
if !config.CheckUserScopes(user) {
log.Trace().
Strs("scopes", config.Scopes).
Strs("user_scopes", user.Scopes).
Msg("Authentication already performed, but user does not have required scopes, rejecting request")
return ErrForbiddenMissingScopes
}
log.Trace().Msg("Authentication already performed, allowing request")
return next(c)
}
token, exists := config.TokenSource.Extract(c, config.TokenSourceKey, config.Scheme)
if len(token) == 0 {
if config.Mode == AuthModeRequired || config.Mode == AuthModeSecure || (exists && config.Mode == AuthModeOptional) {
log.Trace().Bool("token_exists", exists).Msg("Request has missing or malformed token, rejecting")
return config.FailureMode.Error()
}
log.Trace().Bool("token_exists", exists).Msg("Request does not have valid token, but auth mode permits access, allowing request")
return next(c)
}
if !config.FormatValidator(token) {
if config.Mode == AuthModeRequired || config.Mode == AuthModeSecure || config.Mode == AuthModeOptional {
log.Trace().Msg("Request has malformed token, rejecting")
return ErrBadRequestMalformedToken
}
log.Trace().Msg("Request have malformed token, but auth mode permits access, allowing request")
return next(c)
}
res, err := config.TokenValidator(c, config, token)
if err != nil {
if errors.Is(err, ErrAuthTokenValidationFailed) {
if config.Mode == AuthModeTry {
log.Trace().Msg("Auth token validation failed, but auth mode permits access, allowing request")
return next(c)
}
log.Trace().Msg("Auth token validation failed, rejecting request")
return config.FailureMode.Error()
}
log.Trace().Err(err).Msg("Failed to validate auth token, aborting request")
return echo.ErrInternalServerError
}
user = res.User
if res.ValidUntil.IsZero() {
log.Trace().Str("user_id", user.ID).Msg("Auth token has no expiry, allowing request")
} else if config.S.Clock.Now().After(res.ValidUntil) {
if config.Mode == AuthModeTry {
log.Trace().Time("valid_until", res.ValidUntil).Str("user_id", user.ID).Msg("Auth token is expired, but auth mode permits access, allowing request")
return next(c)
}
log.Trace().Time("valid_until", res.ValidUntil).Str("user_id", user.ID).Msg("Auth token is expired, rejecting request")
return config.FailureMode.Error()
}
// ! User has been explicitly deactivated - we do not allow access here, even with AuthModeTry
if !user.IsActive {
log.Trace().Str("user_id", user.ID).Msg("User is deactivated, rejecting request")
return httperrors.ErrForbiddenUserDeactivated
}
if !config.CheckLastAuthenticatedAt(user) {
log.Trace().
Time("last_authenticated_at", user.LastAuthenticatedAt.Time).
Dur("last_authenticated_at_threshold", config.S.Config.Auth.LastAuthenticatedAtThreshold).
Msg("Authentication already performed, but last authenticated at time exceeds threshold, rejecting request")
return ErrUnauthorizedLastAuthenticatedAtExceeded
}
if !config.CheckUserScopes(user) {
log.Trace().
Strs("scopes", config.Scopes).
Strs("user_scopes", user.Scopes).
Msg("Authentication already performed, but user does not have required scopes, rejecting request")
return ErrForbiddenMissingScopes
}
auth.EnrichEchoContextWithCredentials(c, res)
log.Trace().Str("user_id", user.ID).Msg("Auth token is valid, allowing request")
return next(c)
}
}
}

View File

@@ -0,0 +1,55 @@
package middleware
import (
"context"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
var (
DefaultCacheControlConfig = CacheControlConfig{
Skipper: middleware.DefaultSkipper,
}
)
type CacheControlConfig struct {
Skipper middleware.Skipper
}
func CacheControl() echo.MiddlewareFunc {
return CacheControlWithConfig(DefaultCacheControlConfig)
}
func CacheControlWithConfig(config CacheControlConfig) echo.MiddlewareFunc {
if config.Skipper == nil {
config.Skipper = DefaultCacheControlConfig.Skipper
}
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if config.Skipper(c) {
return next(c)
}
cacheControl := c.Request().Header.Get(util.HTTPHeaderCacheControl)
if len(cacheControl) > 0 {
directive := util.ParseCacheControlHeader(cacheControl)
ctx := c.Request().Context()
l := util.LogFromContext(ctx).With().Str("cacheControl", directive.String()).Logger()
ctx = l.WithContext(ctx)
ctx = context.WithValue(ctx, util.CTXKeyCacheControl, directive)
l.Trace().Msg("Setting cache control directive for request")
c.SetRequest(c.Request().WithContext(ctx))
}
return next(c)
}
}
}

View File

@@ -0,0 +1,334 @@
package middleware
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
// RequestBodyLogSkipper defines a function to skip logging certain request bodies.
// Returning true skips logging the payload of the request.
type RequestBodyLogSkipper func(req *http.Request) bool
// DefaultRequestBodyLogSkipper returns true for all requests with Content-Type
// application/x-www-form-urlencoded or multipart/form-data as those might contain
// binary or URL-encoded file uploads unfit for logging purposes.
func DefaultRequestBodyLogSkipper(req *http.Request) bool {
contentType := req.Header.Get(echo.HeaderContentType)
switch {
case strings.HasPrefix(contentType, echo.MIMEApplicationForm),
strings.HasPrefix(contentType, echo.MIMEMultipartForm):
return true
default:
return false
}
}
// ResponseBodyLogSkipper defines a function to skip logging certain response bodies.
// Returning true skips logging the payload of the response.
type ResponseBodyLogSkipper func(req *http.Request, res *echo.Response) bool
// DefaultResponseBodyLogSkipper returns false for all responses with Content-Type
// application/json, preventing logging for all other types of payloads as those
// might contain binary or URL-encoded data unfit for logging purposes.
func DefaultResponseBodyLogSkipper(_ *http.Request, res *echo.Response) bool {
contentType := res.Header().Get(echo.HeaderContentType)
switch {
case strings.HasPrefix(contentType, echo.MIMEApplicationJSON):
return false
default:
return true
}
}
// BodyLogReplacer defines a function to replace certain parts of a body before logging it,
// mainly used to strip sensitive information from a request or response payload.
// The []byte returned should contain a sanitized payload ready for logging.
type BodyLogReplacer func(body []byte) []byte
// DefaultBodyLogReplacer returns the body received without any modifications.
func DefaultBodyLogReplacer(body []byte) []byte {
return body
}
// HeaderLogReplacer defines a function to replace certain parts of a header before logging it,
// mainly used to strip sensitive information from a request or response header.
// The http.Header returned should be a sanitized copy of the original header as not to modify
// the request or response while logging.
type HeaderLogReplacer func(header http.Header) http.Header
// DefaultHeaderLogReplacer replaces all Authorization, X-CSRF-Token and Proxy-Authorization
// header entries with a redacted string, indicating their presence without revealing actual,
// potentially sensitive values in the logs.
func DefaultHeaderLogReplacer(headers http.Header) http.Header {
sanitizedHeader := http.Header{}
for key, value := range headers {
shouldRedact := strings.EqualFold(key, echo.HeaderAuthorization) ||
strings.EqualFold(key, echo.HeaderXCSRFToken) ||
strings.EqualFold(key, "Proxy-Authorization")
for _, v := range value {
if shouldRedact {
sanitizedHeader.Add(key, "*****REDACTED*****")
} else {
sanitizedHeader.Add(key, v)
}
}
}
return sanitizedHeader
}
// QueryLogReplacer defines a function to replace certain parts of a URL query before logging it,
// mainly used to strip sensitive information from a request query.
// The url.Values returned should be a sanitized copy of the original query as not to modify the
// request while logging.
type QueryLogReplacer func(query url.Values) url.Values
// DefaultQueryLogReplacer returns the query received without any modifications.
func DefaultQueryLogReplacer(query url.Values) url.Values {
return query
}
var (
DefaultLoggerConfig = LoggerConfig{
Skipper: middleware.DefaultSkipper,
Level: zerolog.DebugLevel,
LogRequestBody: false,
LogRequestHeader: false,
LogRequestQuery: false,
RequestBodyLogSkipper: DefaultRequestBodyLogSkipper,
RequestBodyLogReplacer: DefaultBodyLogReplacer,
RequestHeaderLogReplacer: DefaultHeaderLogReplacer,
RequestQueryLogReplacer: DefaultQueryLogReplacer,
LogResponseBody: false,
LogResponseHeader: false,
ResponseBodyLogSkipper: DefaultResponseBodyLogSkipper,
ResponseBodyLogReplacer: DefaultBodyLogReplacer,
}
)
type LoggerConfig struct {
Skipper middleware.Skipper
Level zerolog.Level
LogRequestBody bool
LogRequestHeader bool
LogRequestQuery bool
LogCaller bool
RequestBodyLogSkipper RequestBodyLogSkipper
RequestBodyLogReplacer BodyLogReplacer
RequestHeaderLogReplacer HeaderLogReplacer
RequestQueryLogReplacer QueryLogReplacer
LogResponseBody bool
LogResponseHeader bool
ResponseBodyLogSkipper ResponseBodyLogSkipper
ResponseBodyLogReplacer BodyLogReplacer
ResponseHeaderLogReplacer HeaderLogReplacer
}
// Logger with default logger output and configuration
func Logger() echo.MiddlewareFunc {
return LoggerWithConfig(DefaultLoggerConfig, nil)
}
// LoggerWithConfig returns a new MiddlewareFunc which creates a logger with the desired configuration.
// If output is set to nil, the default output is used. If more output params are provided, the first is being used.
func LoggerWithConfig(config LoggerConfig, output ...io.Writer) echo.MiddlewareFunc {
if config.Skipper == nil {
config.Skipper = DefaultLoggerConfig.Skipper
}
if config.RequestBodyLogSkipper == nil {
config.RequestBodyLogSkipper = DefaultRequestBodyLogSkipper
}
if config.RequestBodyLogReplacer == nil {
config.RequestBodyLogReplacer = DefaultBodyLogReplacer
}
if config.RequestHeaderLogReplacer == nil {
config.RequestHeaderLogReplacer = DefaultHeaderLogReplacer
}
if config.RequestQueryLogReplacer == nil {
config.RequestQueryLogReplacer = DefaultQueryLogReplacer
}
if config.ResponseBodyLogSkipper == nil {
config.ResponseBodyLogSkipper = DefaultResponseBodyLogSkipper
}
if config.ResponseBodyLogReplacer == nil {
config.ResponseBodyLogReplacer = DefaultBodyLogReplacer
}
if config.ResponseHeaderLogReplacer == nil {
config.ResponseHeaderLogReplacer = DefaultHeaderLogReplacer
}
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if config.Skipper(c) {
return next(c)
}
req := c.Request()
res := c.Response()
requestID := req.Header.Get(echo.HeaderXRequestID)
if len(requestID) == 0 {
requestID = res.Header().Get(echo.HeaderXRequestID)
}
contentLength := req.Header.Get(echo.HeaderContentLength)
if len(contentLength) == 0 {
contentLength = "0"
}
logger := log.With().
Dict("req", zerolog.Dict().
Str("id", requestID).
Str("host", req.Host).
Str("method", req.Method).
Str("url", req.URL.String()).
Str("bytes_in", contentLength),
).Logger()
if len(output) > 0 {
logger = logger.Output(output[0])
}
if config.LogCaller {
// Caller uses https://pkg.go.dev/runtime#Caller underneath and might decrease the performance.
logger = logger.With().Caller().Logger()
}
le := logger.WithLevel(config.Level)
req = req.WithContext(logger.WithContext(context.WithValue(req.Context(), util.CTXKeyRequestID, requestID)))
if config.LogRequestBody && !config.RequestBodyLogSkipper(req) {
var reqBody []byte
var err error
if req.Body != nil {
reqBody, err = io.ReadAll(req.Body)
if err != nil {
logger.Error().Err(err).Msg("Failed to read body while logging request")
return fmt.Errorf("failed to read body while logging request: %w", err)
}
req.Body = io.NopCloser(bytes.NewBuffer(reqBody))
}
le = le.Bytes("req_body", config.RequestBodyLogReplacer(reqBody))
}
if config.LogRequestHeader {
header := zerolog.Dict()
for k, v := range config.RequestHeaderLogReplacer(req.Header) {
header.Strs(k, v)
}
le = le.Dict("req_header", header)
}
if config.LogRequestQuery {
query := zerolog.Dict()
for k, v := range req.URL.Query() {
query.Strs(k, v)
}
le = le.Dict("req_query", query)
}
le.Msg("Request received")
c.SetRequest(req)
var resBody bytes.Buffer
if config.LogResponseBody {
mw := io.MultiWriter(res.Writer, &resBody)
writer := &bodyDumpResponseWriter{Writer: mw, ResponseWriter: res.Writer}
res.Writer = writer
}
start := time.Now()
err := next(c)
if err != nil {
c.Error(err)
}
stop := time.Now()
// Retrieve logger from context again since other middlewares might have enhanced it
ll := util.LogFromEchoContext(c)
lle := ll.WithLevel(config.Level).
Dict("res", zerolog.Dict().
Int("status", res.Status).
Int64("bytes_out", res.Size).
TimeDiff("duration_ms", stop, start).
Err(err),
)
if config.LogResponseBody && !config.ResponseBodyLogSkipper(req, res) {
lle = lle.Bytes("res_body", config.ResponseBodyLogReplacer(resBody.Bytes()))
}
if config.LogResponseHeader {
header := zerolog.Dict()
for k, v := range config.ResponseHeaderLogReplacer(res.Header()) {
header.Strs(k, v)
}
lle = lle.Dict("res_header", header)
}
lle.Msg("Response sent")
return nil
}
}
}
type bodyDumpResponseWriter struct {
io.Writer
http.ResponseWriter
}
func (w *bodyDumpResponseWriter) WriteHeader(code int) {
w.ResponseWriter.WriteHeader(code)
}
func (w *bodyDumpResponseWriter) Write(b []byte) (int, error) {
n, err := w.Writer.Write(b)
if err != nil {
return 0, fmt.Errorf("failed to write response body: %w", err)
}
return n, nil
}
func (w *bodyDumpResponseWriter) Flush() {
flusher, ok := w.ResponseWriter.(http.Flusher)
if !ok {
panic(fmt.Sprintf("failed to get flusher as http.Flusher, got %T", w.ResponseWriter))
}
flusher.Flush()
}
func (w *bodyDumpResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hijacker, ok := w.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, fmt.Errorf("failed to get hijacker as http.Hijacker, got %T", w.ResponseWriter)
}
conn, rw, err := hijacker.Hijack()
if err != nil {
return nil, nil, fmt.Errorf("failed to hijack connection: %w", err)
}
return conn, rw, nil
}

View File

@@ -0,0 +1,57 @@
package middleware_test
import (
"net/http"
"net/http/httptest"
"testing"
"allaboutapps.dev/aw/go-starter/internal/api/middleware"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func logTestHandler(c echo.Context) error {
log := util.LogFromEchoContext(c)
log.Info().Msg("I'm here!")
return nil
}
func TestLogWithCaller(t *testing.T) {
cfg := middleware.DefaultLoggerConfig
cfg.LogCaller = false
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
loggerMW := middleware.LoggerWithConfig(cfg, rec)
e := echo.New()
c := e.NewContext(req, rec)
middlewareChain := loggerMW(logTestHandler)
require.NoError(t, middlewareChain(c))
bufSize := 2000
loggedData := make([]byte, bufSize)
n, err := rec.Body.Read(loggedData)
require.NoError(t, err)
assert.Less(t, n, bufSize)
// LogCaller was set to false
assert.NotContains(t, string(loggedData), "caller")
// now log again with LogCaller set to true
cfg.LogCaller = true
loggerMW = middleware.LoggerWithConfig(cfg, rec)
rec.Flush()
middlewareChain = loggerMW(logTestHandler)
require.NoError(t, middlewareChain(c))
n, err = rec.Body.Read(loggedData)
require.NoError(t, err)
assert.Less(t, n, bufSize)
// logger_test.go:X should match the line where log.Info() is placed in the logTestHandler
assert.Contains(t, string(loggedData[:n]), `"caller":"/app/internal/api/middleware/logger_test.go:17"`)
}

View File

@@ -0,0 +1,91 @@
package middleware
// Based on https://github.com/LYY/echo-middleware (MIT License, https://github.com/LYY/echo-middleware/blob/master/LICENSE)
// Ported from Goji's middleware, source:
// https://github.com/zenazn/goji/tree/master/web/middleware (MIT License, https://github.com/zenazn/goji/blob/master/LICENSE)
import (
"time"
"github.com/labstack/echo/v4"
middleware "github.com/labstack/echo/v4/middleware"
)
type (
// NoCacheConfig defines the config for nocache middleware.
NoCacheConfig struct {
// Skipper defines a function to skip middleware.
Skipper middleware.Skipper
}
)
var (
// Unix epoch time
epoch = time.Unix(0, 0).Format(time.RFC1123)
// Taken from https://github.com/mytrile/nocache
noCacheHeaders = map[string]string{
"Expires": epoch,
"Cache-Control": "no-cache, private, max-age=0",
"Pragma": "no-cache",
"X-Accel-Expires": "0",
}
etagHeaders = []string{
"ETag",
"If-Modified-Since",
"If-Match",
"If-None-Match",
"If-Range",
"If-Unmodified-Since",
}
// DefaultNoCacheConfig is the default nocache middleware config.
DefaultNoCacheConfig = NoCacheConfig{
Skipper: middleware.DefaultSkipper,
}
)
// NoCache is a simple piece of middleware that sets a number of HTTP headers to prevent
// a router (or subrouter) from being cached by an upstream proxy and/or client.
//
// As per http://wiki.nginx.org/HttpProxyModule - NoCache sets:
//
// Expires: Thu, 01 Jan 1970 00:00:00 UTC
// Cache-Control: no-cache, private, max-age=0
// X-Accel-Expires: 0
// Pragma: no-cache (for HTTP/1.0 proxies/clients)
func NoCache() echo.MiddlewareFunc {
return NoCacheWithConfig(DefaultNoCacheConfig)
}
// NoCacheWithConfig returns a nocache middleware with config.
func NoCacheWithConfig(config NoCacheConfig) echo.MiddlewareFunc {
// Defaults
if config.Skipper == nil {
config.Skipper = DefaultNoCacheConfig.Skipper
}
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if config.Skipper(c) {
return next(c)
}
req := c.Request()
// Delete any ETag headers that may have been set
for _, v := range etagHeaders {
if req.Header.Get(v) != "" {
req.Header.Del(v)
}
}
// Set our NoCache headers
res := c.Response()
for k, v := range noCacheHeaders {
res.Header().Set(k, v)
}
return next(c)
}
}
}

View File

@@ -0,0 +1,11 @@
package middleware
import "github.com/labstack/echo/v4"
func Noop() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
return next(c)
}
}
}

View File

@@ -0,0 +1,15 @@
package middleware
import (
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/labstack/echo/v4"
)
func LogErrorFuncWithRequestInfo(c echo.Context, err error, stack []byte) error {
log := util.LogFromContext(c.Request().Context())
log.Error().Err(err).Bytes("stack", stack).Msg("PANIC RECOVER")
return err
}

View File

@@ -0,0 +1,41 @@
package middleware_test
import (
"io"
"net/http"
"testing"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/api/middleware"
"allaboutapps.dev/aw/go-starter/internal/test"
"github.com/labstack/echo/v4"
echoMiddleware "github.com/labstack/echo/v4/middleware"
"github.com/stretchr/testify/require"
)
func TestLogErrorFuncWithRequestInfo(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
path := "/testing-e87bc94c-2d1f-4342-9ec2-f158c63ac6da"
s.Echo.Use(echoMiddleware.RecoverWithConfig(echoMiddleware.RecoverConfig{
LogErrorFunc: middleware.LogErrorFuncWithRequestInfo,
}))
s.Echo.POST(path, func(c echo.Context) error {
// trigger the recover middleware by triggering a nil pointer dereference
var val *int
_ = *val
return c.NoContent(http.StatusNoContent)
})
res := test.PerformRequest(t, s, "POST", path, nil, nil)
require.Equal(t, http.StatusInternalServerError, res.Result().StatusCode)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
test.Snapshoter.SaveString(t, string(body))
})
}

81
internal/api/providers.go Normal file
View File

@@ -0,0 +1,81 @@
package api
import (
"database/sql"
"fmt"
"testing"
"time"
"allaboutapps.dev/aw/go-starter/internal/auth"
"allaboutapps.dev/aw/go-starter/internal/config"
"allaboutapps.dev/aw/go-starter/internal/i18n"
"allaboutapps.dev/aw/go-starter/internal/mailer"
"allaboutapps.dev/aw/go-starter/internal/persistence"
"allaboutapps.dev/aw/go-starter/internal/push"
"allaboutapps.dev/aw/go-starter/internal/push/provider"
"github.com/dropbox/godropbox/time2"
"github.com/rs/zerolog/log"
)
// PROVIDERS - define here only providers that for various reasons (e.g. cyclic dependency) can't live in their corresponding packages
// or for wrapping providers that only accept sub-configs to prevent the requirements for defining providers for sub-configs.
// https://github.com/google/wire/blob/main/docs/guide.md#defining-providers
// NewPush creates an instance of the push service and registers the configured push providers.
func NewPush(cfg config.Server, db *sql.DB) (*push.Service, error) {
pusher := push.New(db)
if cfg.Push.UseFCMProvider {
fcmProvider, err := provider.NewFCM(cfg.FCMConfig)
if err != nil {
return nil, fmt.Errorf("failed to create FCM provider: %w", err)
}
pusher.RegisterProvider(fcmProvider)
}
if cfg.Push.UseMockProvider {
log.Warn().Msg("Initializing mock push provider")
mockProvider := provider.NewMock(push.ProviderTypeFCM)
pusher.RegisterProvider(mockProvider)
}
if pusher.GetProviderCount() < 1 {
log.Warn().Msg("No providers registered for push service")
}
return pusher, nil
}
func NewClock(t ...*testing.T) time2.Clock {
var clock time2.Clock
useMock := len(t) > 0 && t[0] != nil
if useMock {
clock = time2.NewMockClock(time.Now())
} else {
clock = time2.DefaultClock
}
return clock
}
func NewAuthService(config config.Server, db *sql.DB, clock time2.Clock) *auth.Service {
return auth.NewService(config, db, clock)
}
func NewMailer(config config.Server) (*mailer.Mailer, error) {
return mailer.NewWithConfig(config.Mailer, config.SMTP)
}
func NewDB(config config.Server) (*sql.DB, error) {
return persistence.NewDB(config.Database)
}
func NewI18N(config config.Server) (*i18n.Service, error) {
return i18n.New(config.I18n)
}
func NoTest() []*testing.T {
return nil
}

View File

@@ -0,0 +1,13 @@
package router
import "github.com/rs/zerolog"
type echoLogger struct {
level zerolog.Level
log zerolog.Logger
}
func (l *echoLogger) Write(p []byte) (int, error) {
l.log.WithLevel(l.level).Msgf("%s", p)
return len(p), nil
}

View File

@@ -0,0 +1,27 @@
package router
import (
"fmt"
"html/template"
"io"
"allaboutapps.dev/aw/go-starter/internal/api/router/templates"
"github.com/labstack/echo/v4"
)
type echoRenderer struct {
templates map[templates.ViewTemplate]*template.Template
}
func (t *echoRenderer) Render(writer io.Writer, name string, data interface{}, _ echo.Context) error {
tmplHTML, ok := t.templates[templates.ViewTemplate(name)]
if !ok {
return fmt.Errorf("template not found: %s", name)
}
if err := tmplHTML.Execute(writer, data); err != nil {
return fmt.Errorf("failed to execute template: %w", err)
}
return nil
}

View File

@@ -0,0 +1,150 @@
package router
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
"allaboutapps.dev/aw/go-starter/internal/config"
"allaboutapps.dev/aw/go-starter/internal/types"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/go-openapi/swag"
"github.com/labstack/echo/v4"
"github.com/timewasted/go-accept-headers"
)
var (
DefaultHTTPErrorHandlerConfig = HTTPErrorHandlerConfig{
HideInternalServerErrorDetails: true,
}
)
type HTTPErrorHandlerConfig struct {
HideInternalServerErrorDetails bool
}
func HTTPErrorHandler() echo.HTTPErrorHandler {
return HTTPErrorHandlerWithConfig(DefaultHTTPErrorHandlerConfig)
}
func HTTPErrorHandlerWithConfig(config HTTPErrorHandlerConfig) echo.HTTPErrorHandler {
return func(err error, c echo.Context) {
var code int64
var resultErr error
var httpError *httperrors.HTTPError
var httpValidationError *httperrors.HTTPValidationError
var echoHTTPError *echo.HTTPError
switch {
case errors.As(err, &httpError):
code = *httpError.Code
resultErr = httpError
if code == http.StatusInternalServerError && config.HideInternalServerErrorDetails {
if httpError.Internal == nil {
//nolint:errorlint
httpError.Internal = fmt.Errorf("internal error: %s", httpError)
}
httpError.Title = swag.String(http.StatusText(http.StatusInternalServerError))
}
case errors.As(err, &httpValidationError):
code = *httpValidationError.Code
resultErr = httpValidationError
if code == http.StatusInternalServerError && config.HideInternalServerErrorDetails {
if httpValidationError.Internal == nil {
//nolint:errorlint
httpValidationError.Internal = fmt.Errorf("internal error: %s", httpValidationError)
}
httpValidationError.Title = swag.String(http.StatusText(http.StatusInternalServerError))
}
case errors.As(err, &echoHTTPError):
code = int64(echoHTTPError.Code)
//nolint:nestif
if code == http.StatusInternalServerError && config.HideInternalServerErrorDetails {
if echoHTTPError.Internal == nil {
//nolint:errorlint
echoHTTPError.Internal = fmt.Errorf("internal error: %s", echoHTTPError)
}
resultErr = &httperrors.HTTPError{
PublicHTTPError: types.PublicHTTPError{
Code: swag.Int64(int64(echoHTTPError.Code)),
Title: swag.String(http.StatusText(http.StatusInternalServerError)),
Type: types.PublicHTTPErrorTypeGeneric.Pointer(),
},
Internal: echoHTTPError.Internal,
}
} else {
msg, ok := echoHTTPError.Message.(string)
if !ok {
if m, errr := json.Marshal(msg); err == nil {
msg = string(m)
} else {
msg = fmt.Sprintf("failed to marshal HTTP error message: %v", errr)
}
}
resultErr = &httperrors.HTTPError{
PublicHTTPError: types.PublicHTTPError{
Code: swag.Int64(int64(echoHTTPError.Code)),
Title: &msg,
Type: types.PublicHTTPErrorTypeGeneric.Pointer(),
},
Internal: echoHTTPError.Internal,
}
}
default:
code = http.StatusInternalServerError
if config.HideInternalServerErrorDetails {
resultErr = &httperrors.HTTPError{
PublicHTTPError: types.PublicHTTPError{
Code: swag.Int64(int64(http.StatusInternalServerError)),
Title: swag.String(http.StatusText(http.StatusInternalServerError)),
Type: types.PublicHTTPErrorTypeGeneric.Pointer(),
},
Internal: err,
}
} else {
resultErr = &httperrors.HTTPError{
PublicHTTPError: types.PublicHTTPError{
Code: swag.Int64(int64(http.StatusInternalServerError)),
Title: swag.String(err.Error()),
Type: types.PublicHTTPErrorTypeGeneric.Pointer(),
},
}
}
}
if !c.Response().Committed {
if c.Request().Method == http.MethodHead {
err = c.NoContent(int(code))
} else {
err = c.JSON(int(code), resultErr)
}
if err != nil {
util.LogFromEchoContext(c).Warn().Err(err).AnErr("http_err", err).Msg("Failed to handle HTTP error")
}
}
}
}
func NotFoundHandler(config config.Server) func(c echo.Context) error {
return func(c echo.Context) error {
accepted := accept.Parse(c.Request().Header.Get(echo.HeaderAccept))
if accepted.Accepts(echo.MIMETextHTML) {
return c.HTML(http.StatusNotFound, fmt.Sprintf(`<html><body><h1>Page Not Found</h1><p>The page you are looking for does not exist. Did you mean to visit <a href="%s">%s</a>?</p></body></html>`, config.Frontend.BaseURL, config.Frontend.BaseURL))
}
return echo.ErrNotFound
}
}

View File

@@ -0,0 +1,252 @@
package router
import (
"context"
"fmt"
"html/template"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/api/handlers"
"allaboutapps.dev/aw/go-starter/internal/api/handlers/constants"
"allaboutapps.dev/aw/go-starter/internal/api/middleware"
"allaboutapps.dev/aw/go-starter/internal/api/router/templates"
"github.com/labstack/echo-contrib/echoprometheus"
"github.com/labstack/echo/v4"
echoMiddleware "github.com/labstack/echo/v4/middleware"
"github.com/rs/zerolog/log"
// #nosec G108 - pprof handlers (conditionally made available via http.DefaultServeMux)
"net/http/pprof"
)
func Init(s *api.Server) error {
s.Echo = echo.New()
viewsRenderer := &echoRenderer{
templates: map[templates.ViewTemplate]*template.Template{},
}
files, err := os.ReadDir(s.Config.Echo.WebTemplatesViewsBaseDirAbs)
if err != nil {
return fmt.Errorf("failed to read views templates dir: %w", err)
}
for _, file := range files {
if file.IsDir() {
continue
}
templateName := file.Name()
t, err := template.New(templateName).ParseGlob(filepath.Join(s.Config.Echo.WebTemplatesViewsBaseDirAbs, templateName))
if err != nil {
return fmt.Errorf("failed to parse template file: %w", err)
}
viewsRenderer.templates[templates.ViewTemplate(templateName)] = t
}
s.Echo.Renderer = viewsRenderer
s.Echo.Debug = s.Config.Echo.Debug
s.Echo.HideBanner = true
s.Echo.Logger.SetOutput(&echoLogger{level: s.Config.Logger.RequestLevel, log: log.With().Str("component", "echo").Logger()})
echo.NotFoundHandler = NotFoundHandler(s.Config)
s.Echo.HTTPErrorHandler = HTTPErrorHandlerWithConfig(HTTPErrorHandlerConfig{
HideInternalServerErrorDetails: s.Config.Echo.HideInternalServerErrorDetails,
})
// ---
// General middleware
if s.Config.Management.EnableMetrics {
s.Echo.Use(echoprometheus.NewMiddleware(""))
err := s.Metrics.RegisterMetrics(context.Background())
if err != nil {
log.Err(err).Msg("Failed to register metrics")
return err
}
} else {
log.Warn().Msg("Disabling metrics middleware due to environment config")
}
if s.Config.Echo.EnableTrailingSlashMiddleware {
s.Echo.Pre(echoMiddleware.RemoveTrailingSlash())
} else {
log.Warn().Msg("Disabling trailing slash middleware due to environment config")
}
if s.Config.Echo.EnableRecoverMiddleware {
s.Echo.Use(echoMiddleware.RecoverWithConfig(echoMiddleware.RecoverConfig{
LogErrorFunc: middleware.LogErrorFuncWithRequestInfo,
}))
} else {
log.Warn().Msg("Disabling recover middleware due to environment config")
}
if s.Config.Echo.EnableSecureMiddleware {
s.Echo.Use(echoMiddleware.SecureWithConfig(echoMiddleware.SecureConfig{
Skipper: echoMiddleware.DefaultSecureConfig.Skipper,
XSSProtection: s.Config.Echo.SecureMiddleware.XSSProtection,
ContentTypeNosniff: s.Config.Echo.SecureMiddleware.ContentTypeNosniff,
XFrameOptions: s.Config.Echo.SecureMiddleware.XFrameOptions,
HSTSMaxAge: s.Config.Echo.SecureMiddleware.HSTSMaxAge,
HSTSExcludeSubdomains: s.Config.Echo.SecureMiddleware.HSTSExcludeSubdomains,
ContentSecurityPolicy: s.Config.Echo.SecureMiddleware.ContentSecurityPolicy,
CSPReportOnly: s.Config.Echo.SecureMiddleware.CSPReportOnly,
HSTSPreloadEnabled: s.Config.Echo.SecureMiddleware.HSTSPreloadEnabled,
ReferrerPolicy: s.Config.Echo.SecureMiddleware.ReferrerPolicy,
}))
} else {
log.Warn().Msg("Disabling secure middleware due to environment config")
}
if s.Config.Echo.EnableRequestIDMiddleware {
s.Echo.Use(echoMiddleware.RequestID())
} else {
log.Warn().Msg("Disabling request ID middleware due to environment config")
}
if s.Config.Echo.EnableLoggerMiddleware {
s.Echo.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
Level: s.Config.Logger.RequestLevel,
LogRequestBody: s.Config.Logger.LogRequestBody,
LogRequestHeader: s.Config.Logger.LogRequestHeader,
LogRequestQuery: s.Config.Logger.LogRequestQuery,
LogResponseBody: s.Config.Logger.LogResponseBody,
LogResponseHeader: s.Config.Logger.LogResponseHeader,
LogCaller: s.Config.Logger.LogCaller,
RequestBodyLogSkipper: func(req *http.Request) bool {
// We skip all body logging for auth endpoints as these might contain credentials
if strings.HasPrefix(req.URL.Path, "/api/v1/auth") {
return true
}
return middleware.DefaultRequestBodyLogSkipper(req)
},
ResponseBodyLogSkipper: func(req *http.Request, res *echo.Response) bool {
// We skip all body logging for auth endpoints as these might contain credentials
if strings.HasPrefix(req.URL.Path, "/api/v1/auth") {
return true
}
return middleware.DefaultResponseBodyLogSkipper(req, res)
},
Skipper: func(c echo.Context) bool {
// We skip logging of readiness and liveness endpoints
switch c.Path() {
case "/-/ready", "/-/healthy":
return true
}
return false
},
}))
} else {
log.Warn().Msg("Disabling logger middleware due to environment config")
}
if s.Config.Echo.EnableCORSMiddleware {
s.Echo.Use(echoMiddleware.CORS())
} else {
log.Warn().Msg("Disabling CORS middleware due to environment config")
}
if s.Config.Echo.EnableCacheControlMiddleware {
s.Echo.Use(middleware.CacheControl())
} else {
log.Warn().Msg("Disabling cache control middleware due to environment config")
}
if s.Config.Pprof.Enable {
pprofAuthMiddleware := middleware.Noop()
if s.Config.Pprof.EnableManagementKeyAuth {
pprofAuthMiddleware = echoMiddleware.KeyAuthWithConfig(echoMiddleware.KeyAuthConfig{
KeyLookup: "query:mgmt-secret",
Validator: func(key string, _ echo.Context) (bool, error) {
return key == s.Config.Management.Secret, nil
},
})
}
s.Echo.GET("/debug/pprof", echo.WrapHandler(http.HandlerFunc(pprof.Index)), pprofAuthMiddleware)
s.Echo.Any("/debug/pprof/*", echo.WrapHandler(http.DefaultServeMux), pprofAuthMiddleware)
log.Warn().Bool("EnableManagementKeyAuth", s.Config.Pprof.EnableManagementKeyAuth).Msg("Pprof http handlers are available at /debug/pprof")
if s.Config.Pprof.RuntimeBlockProfileRate != 0 {
runtime.SetBlockProfileRate(s.Config.Pprof.RuntimeBlockProfileRate)
log.Warn().Int("RuntimeBlockProfileRate", s.Config.Pprof.RuntimeBlockProfileRate).Msg("Pprof runtime.SetBlockProfileRate")
}
if s.Config.Pprof.RuntimeMutexProfileFraction != 0 {
runtime.SetMutexProfileFraction(s.Config.Pprof.RuntimeMutexProfileFraction)
log.Warn().Int("RuntimeMutexProfileFraction", s.Config.Pprof.RuntimeMutexProfileFraction).Msg("Pprof runtime.SetMutexProfileFraction")
}
}
// Add your custom / additional middlewares here.
// see https://echo.labstack.com/middleware
// ---
// Initialize our general groups and set middleware to use above them
s.Router = &api.Router{
Routes: nil, // will be populated by handlers.AttachAllRoutes(s)
// Unsecured base group available at /**
Root: s.Echo.Group(""),
// Management endpoints, uncacheable, secured by key auth (query param), available at /-/**
Management: s.Echo.Group("/-", echoMiddleware.KeyAuthWithConfig(echoMiddleware.KeyAuthConfig{
KeyLookup: "query:mgmt-secret",
Validator: func(key string, _ echo.Context) (bool, error) {
return key == s.Config.Management.Secret, nil
},
Skipper: func(c echo.Context) bool {
//nolint:gocritic
switch c.Path() {
case "/-/ready":
return true
}
return false
},
}), middleware.NoCache()),
// OAuth2, unsecured or secured by bearer auth, available at /api/v1/auth/**
APIV1Auth: s.Echo.Group("/api/v1/auth", middleware.AuthWithConfig(middleware.AuthConfig{
S: s,
Mode: middleware.AuthModeRequired,
Skipper: func(c echo.Context) bool {
switch c.Path() {
case "/api/v1/auth/forgot-password",
"/api/v1/auth/forgot-password/complete",
"/api/v1/auth/login",
"/api/v1/auth/refresh",
"/api/v1/auth/register",
fmt.Sprintf("/api/v1/auth/register/:%s", constants.RegistrationTokenParam):
return true
}
return false
},
})),
WellKnown: s.Echo.Group("/.well-known"),
// Your other endpoints, typically secured by bearer auth, available at /api/v1/**
APIV1Push: s.Echo.Group("/api/v1/push", middleware.Auth(s)),
}
// ---
// Finally attach our handlers
handlers.AttachAllRoutes(s)
if s.Config.Management.EnableMetrics {
log.Info().Msg("Metrics enabled and available under /metrics")
s.Echo.GET("/metrics", echoprometheus.NewHandler())
}
return nil
}

View File

@@ -0,0 +1,128 @@
package router_test
import (
"fmt"
"net/http"
"testing"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/config"
"allaboutapps.dev/aw/go-starter/internal/metrics/users"
"allaboutapps.dev/aw/go-starter/internal/models"
"allaboutapps.dev/aw/go-starter/internal/test"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPprofEnabled(t *testing.T) {
config := config.DefaultServiceConfigFromEnv()
// these are typically our default values, however we force set them here to ensure those are set while test execution.
config.Pprof.Enable = true
config.Pprof.EnableManagementKeyAuth = true
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
// heap (test any)
res := test.PerformRequest(t, s, "GET", "/debug/pprof/heap?mgmt-secret="+s.Config.Management.Secret, nil, nil)
require.Equal(t, 200, res.Result().StatusCode)
// index
res = test.PerformRequest(t, s, "GET", "/debug/pprof?mgmt-secret="+s.Config.Management.Secret, nil, nil)
require.Equal(t, 200, res.Result().StatusCode)
res = test.PerformRequest(t, s, "GET", "/debug/pprof/heap?mgmt-secret=wrongsecret", nil, nil)
require.Equal(t, 401, res.Result().StatusCode)
})
}
func TestPprofEnabledNoAuth(t *testing.T) {
config := config.DefaultServiceConfigFromEnv()
// these are typically our default values, however we force set them here to ensure those are set while test execution.
config.Pprof.Enable = true
config.Pprof.EnableManagementKeyAuth = false
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
res := test.PerformRequest(t, s, "GET", "/debug/pprof/heap?", nil, nil)
require.Equal(t, 200, res.Result().StatusCode)
})
}
func TestPprofDisabled(t *testing.T) {
config := config.DefaultServiceConfigFromEnv()
config.Pprof.Enable = false
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
res := test.PerformRequest(t, s, "GET", "/debug/pprof/heap?mgmt-secret="+s.Config.Management.Secret, nil, nil)
require.Equal(t, 404, res.Result().StatusCode)
})
}
func TestMiddlewaresDisabled(t *testing.T) {
// disable all
config := config.DefaultServiceConfigFromEnv()
config.Echo.EnableCORSMiddleware = false
config.Echo.EnableLoggerMiddleware = false
config.Echo.EnableRecoverMiddleware = false
config.Echo.EnableRequestIDMiddleware = false
config.Echo.EnableSecureMiddleware = false
config.Echo.EnableTrailingSlashMiddleware = false
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
res := test.PerformRequest(t, s, "GET", "/-/ready", nil, nil)
require.Equal(t, 200, res.Result().StatusCode)
})
}
func TestMetricsEnabled(t *testing.T) {
config := config.DefaultServiceConfigFromEnv()
config.Management.EnableMetrics = true
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
res := test.PerformRequest(t, s, "GET", "/metrics", nil, nil)
require.Equal(t, http.StatusOK, res.Result().StatusCode)
result := res.Body.String()
// expect custom metric for the total user count
expectedTotalUserCount, err := models.Users().Count(t.Context(), s.DB)
require.NoError(t, err)
assert.Contains(t, result, fmt.Sprintf("%s %d", users.MetricNameTotalUsers, expectedTotalUserCount))
// expect sqlstats metrics
assert.Contains(t, result, "go_sql_stats_connections")
})
}
func TestMetricsDisabled(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
res := test.PerformRequest(t, s, "GET", "/metrics", nil, nil)
require.Equal(t, http.StatusNotFound, res.Result().StatusCode)
})
}
func TestNotFound(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
t.Run("AcceptApplicationJSON", func(t *testing.T) {
headers := http.Header{}
headers.Set(echo.HeaderAccept, echo.MIMEApplicationJSON)
res := test.PerformRequest(t, s, "GET", "/api/v1/unknown-path", nil, headers)
require.Equal(t, http.StatusNotFound, res.Result().StatusCode)
test.Snapshoter.Save(t, res.Body.String())
})
t.Run("AcceptTextHTML", func(t *testing.T) {
headers := http.Header{}
headers.Set(echo.HeaderAccept, "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
res := test.PerformRequest(t, s, "GET", "/api/v1/unknown-path", nil, headers)
require.Equal(t, http.StatusNotFound, res.Result().StatusCode)
test.Snapshoter.Save(t, res.Body.String())
})
})
}

View File

@@ -0,0 +1,11 @@
package templates
type ViewTemplate string
const (
ViewTemplateAccountConfirmation ViewTemplate = "account_confirmation.html.tmpl"
)
func (vt ViewTemplate) String() string {
return string(vt)
}

154
internal/api/server.go Normal file
View File

@@ -0,0 +1,154 @@
package api
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"allaboutapps.dev/aw/go-starter/internal/config"
"allaboutapps.dev/aw/go-starter/internal/data/dto"
"allaboutapps.dev/aw/go-starter/internal/data/local"
"allaboutapps.dev/aw/go-starter/internal/i18n"
"allaboutapps.dev/aw/go-starter/internal/mailer"
"allaboutapps.dev/aw/go-starter/internal/metrics"
"allaboutapps.dev/aw/go-starter/internal/push"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/dropbox/godropbox/time2"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
// Import postgres driver for database/sql package
_ "github.com/lib/pq"
)
type Router struct {
Routes []*echo.Route
Root *echo.Group
Management *echo.Group
APIV1Auth *echo.Group
APIV1Push *echo.Group
WellKnown *echo.Group
}
// Server is a central struct keeping all the dependencies.
// It is initialized with wire, which handles making the new instances of the components
// in the right order. To add a new component, 3 steps are required:
// - declaring it in this struct
// - adding a provider function in providers.go
// - adding the provider's function name to the arguments of wire.Build() in wire.go
//
// Components labeled as `wire:"-"` will be skipped and have to be initialized after the InitNewServer* call.
// For more information about wire refer to https://pkg.go.dev/github.com/google/wire
type Server struct {
// skip wire:
// -> initialized with router.Init(s) function
Echo *echo.Echo `wire:"-"`
Router *Router `wire:"-"`
Config config.Server
DB *sql.DB
Mailer *mailer.Mailer
Push *push.Service
I18n *i18n.Service
Clock time2.Clock
Auth AuthService
Local *local.Service
Metrics *metrics.Service
}
// newServerWithComponents is used by wire to initialize the server components.
// Components not listed here won't be handled by wire and should be initialized separately.
// Components which shouldn't be handled must be labeled `wire:"-"` in Server struct.
func newServerWithComponents(
cfg config.Server,
db *sql.DB,
mail *mailer.Mailer,
pusher *push.Service,
i18n *i18n.Service,
clock time2.Clock,
auth AuthService,
local *local.Service,
metrics *metrics.Service,
) *Server {
return &Server{
Config: cfg,
DB: db,
Mailer: mail,
Push: pusher,
I18n: i18n,
Clock: clock,
Auth: auth,
Local: local,
Metrics: metrics,
}
}
type AuthService interface {
GetAppUserProfile(ctx context.Context, id string) (*dto.AppUserProfile, error)
InitPasswordReset(ctx context.Context, request dto.InitPasswordResetRequest) (dto.InitPasswordResetResult, error)
Login(ctx context.Context, request dto.LoginRequest) (dto.LoginResult, error)
Logout(ctx context.Context, request dto.LogoutRequest) error
Refresh(ctx context.Context, request dto.RefreshRequest) (dto.LoginResult, error)
Register(ctx context.Context, request dto.RegisterRequest) (dto.RegisterResult, error)
CompleteRegister(ctx context.Context, request dto.CompleteRegisterRequest) (dto.LoginResult, error)
DeleteUserAccount(ctx context.Context, request dto.DeleteUserAccountRequest) error
ResetPassword(ctx context.Context, request dto.ResetPasswordRequest) (dto.LoginResult, error)
UpdatePassword(ctx context.Context, request dto.UpdatePasswordRequest) (dto.LoginResult, error)
}
func NewServer(config config.Server) *Server {
s := &Server{
Config: config,
}
return s
}
func (s *Server) Ready() bool {
if err := util.IsStructInitialized(s); err != nil {
log.Debug().Err(err).Msg("Server is not fully initialized")
return false
}
return true
}
func (s *Server) Start() error {
if !s.Ready() {
return errors.New("server is not ready")
}
if err := s.Echo.Start(s.Config.Echo.ListenAddress); err != nil {
return fmt.Errorf("failed to start echo server: %w", err)
}
return nil
}
func (s *Server) Shutdown(ctx context.Context) []error {
log.Warn().Msg("Shutting down server")
var errs []error
if s.DB != nil {
log.Debug().Msg("Closing database connection")
if err := s.DB.Close(); err != nil && !errors.Is(err, sql.ErrConnDone) {
log.Error().Err(err).Msg("Failed to close database connection")
errs = append(errs, err)
}
}
if s.Echo != nil {
log.Debug().Msg("Shutting down echo server")
if err := s.Echo.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Error().Err(err).Msg("Failed to shutdown echo server")
errs = append(errs, err)
}
}
return errs
}

52
internal/api/wire.go Normal file
View File

@@ -0,0 +1,52 @@
//go:build wireinject
package api
import (
"database/sql"
"testing"
"allaboutapps.dev/aw/go-starter/internal/auth"
"allaboutapps.dev/aw/go-starter/internal/config"
"allaboutapps.dev/aw/go-starter/internal/data/local"
"allaboutapps.dev/aw/go-starter/internal/metrics"
"github.com/google/wire"
)
// INJECTORS - https://github.com/google/wire/blob/main/docs/guide.md#injectors
// serviceSet groups the default set of providers that are required for initing a server
var serviceSet = wire.NewSet(
newServerWithComponents,
NewPush,
NewMailer,
NewI18N,
authServiceSet,
local.NewService,
metrics.New,
NewClock,
)
var authServiceSet = wire.NewSet(
NewAuthService,
wire.Bind(new(AuthService), new(*auth.Service)),
)
// InitNewServer returns a new Server instance.
func InitNewServer(
_ config.Server,
) (*Server, error) {
wire.Build(serviceSet, NewDB, NoTest)
return new(Server), nil
}
// InitNewServerWithDB returns a new Server instance with the given DB instance.
// All the other components are initialized via go wire according to the configuration.
func InitNewServerWithDB(
_ config.Server,
_ *sql.DB,
t ...*testing.T,
) (*Server, error) {
wire.Build(serviceSet)
return new(Server), nil
}

94
internal/api/wire_gen.go Normal file
View File

@@ -0,0 +1,94 @@
// Code generated by Wire. DO NOT EDIT.
//go:generate go run -mod=mod github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject
package api
import (
"allaboutapps.dev/aw/go-starter/internal/auth"
"allaboutapps.dev/aw/go-starter/internal/config"
"allaboutapps.dev/aw/go-starter/internal/data/local"
"allaboutapps.dev/aw/go-starter/internal/metrics"
"database/sql"
"github.com/google/wire"
"testing"
)
import (
_ "github.com/lib/pq"
)
// Injectors from wire.go:
// InitNewServer returns a new Server instance.
func InitNewServer(server config.Server) (*Server, error) {
db, err := NewDB(server)
if err != nil {
return nil, err
}
mailer, err := NewMailer(server)
if err != nil {
return nil, err
}
service, err := NewPush(server, db)
if err != nil {
return nil, err
}
i18nService, err := NewI18N(server)
if err != nil {
return nil, err
}
v := NoTest()
clock := NewClock(v...)
authService := NewAuthService(server, db, clock)
localService := local.NewService(server, db, clock)
metricsService, err := metrics.New(server, db)
if err != nil {
return nil, err
}
apiServer := newServerWithComponents(server, db, mailer, service, i18nService, clock, authService, localService, metricsService)
return apiServer, nil
}
// InitNewServerWithDB returns a new Server instance with the given DB instance.
// All the other components are initialized via go wire according to the configuration.
func InitNewServerWithDB(server config.Server, db *sql.DB, t ...*testing.T) (*Server, error) {
mailer, err := NewMailer(server)
if err != nil {
return nil, err
}
service, err := NewPush(server, db)
if err != nil {
return nil, err
}
i18nService, err := NewI18N(server)
if err != nil {
return nil, err
}
clock := NewClock(t...)
authService := NewAuthService(server, db, clock)
localService := local.NewService(server, db, clock)
metricsService, err := metrics.New(server, db)
if err != nil {
return nil, err
}
apiServer := newServerWithComponents(server, db, mailer, service, i18nService, clock, authService, localService, metricsService)
return apiServer, nil
}
// wire.go:
// serviceSet groups the default set of providers that are required for initing a server
var serviceSet = wire.NewSet(
newServerWithComponents,
NewPush,
NewMailer,
NewI18N,
authServiceSet, local.NewService, metrics.New, NewClock,
)
var authServiceSet = wire.NewSet(
NewAuthService, wire.Bind(new(AuthService), new(*auth.Service)),
)