(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

55
internal/README.md Normal file
View File

@@ -0,0 +1,55 @@
# `/internal`
Private application and library code. This is the code you don't want others importing in their applications or libraries. Note that this layout pattern is enforced by the Go compiler itself. See the Go 1.4 [`release notes`](https://golang.org/doc/go1.4#internalpackages) for more details. Note that you are not limited to the top level `internal` directory. You can have more than one `internal` directory at any level of your project tree.
You can optionally add a bit of extra structure to your internal packages to separate your shared and non-shared internal code. It's not required (especially for smaller projects), but it's nice to have visual clues showing the intended package use. Your actual application code can go in the `/internal/app` directory (e.g., `/internal/app/myapp`) and the code shared by those apps in the `/internal/pkg` directory (e.g., `/internal/pkg/myprivlib`).
https://github.com/golang-standards/project-layout/tree/master/internal
### `/internal/api`
Holds API implementations (`/internal/api/handlers`) and general server, router and middleware setup.
### `/internal/config`
Holds configuration of this project (translation of `ENV` vars into something useable).
### `/internal/data`
Anything data related (e.g. mappers, DAOs, DTOs, ...), may be especially relevant if you do 3rd party data integrations. Use this package to write **reuseable** functions for your `/internal/api/handlers/*`.
May hold live db fixture data (for `app db seed`).
### `/internal/i18n`
Our implementation for i18n/l10n, as available via `api.Server.I18n`. Your own localized i18n translation bundles should live within **`/web/i18n`**.
### `/internal/mailer`
Email handling sub-service.
### `/internal/models`
> **Autogenerated** [SQLBoiler](https://github.com/volatiletech/sqlboiler#getting-started) models. Do *not* put your own files in here.
These are based on your current database in `../migrations/*.sql` and updated while running `make`.
### `/internal/push`
Push notifications sub-service.
### `/internal/test`
Test-Setup related code and general testing utility functions.
Holds test db fixture data (`/internal/test/fixtures.go`).
### `/internal/types`
> **Autogenerated** [go-swagger](https://github.com/go-swagger/go-swagger) types and validations. Do *not* put your own files in here.
These are based on your Swagger OpenAPI specification in `../api/**/*.yml` and updated while running `make`.
### `/internal/util`
Utility functions.

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)),
)

View File

@@ -0,0 +1,5 @@
package auth
const (
TokenTypeBearer = "bearer"
)

82
internal/auth/context.go Normal file
View File

@@ -0,0 +1,82 @@
package auth
import (
"context"
"allaboutapps.dev/aw/go-starter/internal/data/dto"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/go-openapi/swag"
"github.com/labstack/echo/v4"
)
// EnrichContextWithCredentials stores the provided credentials in the form of user and access token used for authentication
// in the give context and updates the logger associated with ctx to include the user's ID.
func EnrichContextWithCredentials(ctx context.Context, result Result) context.Context {
// Retrieve current logger associated with context and extend it ID of authenticated user
l := util.LogFromContext(ctx).With().Str("userID", result.User.ID).Logger()
ctx = l.WithContext(ctx)
// Store authenticated user's instance in context
ctx = context.WithValue(ctx, util.CTXKeyUser, result.User)
// Store access token used for authentication in context
ctx = context.WithValue(ctx, util.CTXKeyAccessToken, result.Token)
return ctx
}
// EnrichEchoContextWithCredentials stores the provided credentials in the form of user and access token user for authentication
// in the given echo context's request and updates the logger associated with c to include the user's ID.
func EnrichEchoContextWithCredentials(c echo.Context, result Result) echo.Context {
// Get current context and enrich it with credentials
req := c.Request()
ctx := EnrichContextWithCredentials(req.Context(), result)
// Set updated request with enriched context in echo context
c.SetRequest(req.WithContext(ctx))
return c
}
// UserFromContext returns the user model of the currently authenticated user from a context. If no authentication was provided
// or the current context does not carry any user information, nil will be returned instead.
func UserFromContext(ctx context.Context) *dto.User {
u := ctx.Value(util.CTXKeyUser)
if u == nil {
return nil
}
user, ok := u.(*dto.User)
if !ok {
return nil
}
return user
}
// UserFromEchoContext returns the user model of the currently authenticated user from an echo context. If no authentication was
// provided or the current echo context does not carry any user information, nil will be returned instead.
func UserFromEchoContext(c echo.Context) *dto.User {
return UserFromContext(c.Request().Context())
}
// AccessTokenFromContext returns the access token model of the token used to authentication from a context. If no authentication was
// provided or the current context does not carry any access token information, nil will be returned instead.
func AccessTokenFromContext(ctx context.Context) *string {
t := ctx.Value(util.CTXKeyAccessToken)
if t == nil {
return nil
}
token, ok := t.(string)
if !ok {
return nil
}
return swag.String(token)
}
// AccessTokenFromEchoContext returns the access token model of the token used to authentication from an echo context. If no authentication
// was provided or the current context does not carry any access token information, nil will be returned instead.
func AccessTokenFromEchoContext(c echo.Context) *string {
return AccessTokenFromContext(c.Request().Context())
}

7
internal/auth/errors.go Normal file
View File

@@ -0,0 +1,7 @@
package auth
import "errors"
var (
ErrNotFound = errors.New("not found")
)

11
internal/auth/scopes.go Normal file
View File

@@ -0,0 +1,11 @@
package auth
type Scope string
const (
ScopeApp Scope = "app"
)
func (s Scope) String() string {
return string(s)
}

620
internal/auth/service.go Normal file
View File

@@ -0,0 +1,620 @@
package auth
import (
"context"
"database/sql"
"errors"
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
"allaboutapps.dev/aw/go-starter/internal/config"
"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/util"
"allaboutapps.dev/aw/go-starter/internal/util/db"
"allaboutapps.dev/aw/go-starter/internal/util/hashing"
"github.com/aarondl/null/v8"
"github.com/aarondl/sqlboiler/v4/boil"
"github.com/aarondl/sqlboiler/v4/queries/qm"
"github.com/dropbox/godropbox/time2"
"github.com/labstack/echo/v4"
)
type Service struct {
config config.Server
db *sql.DB
clock time2.Clock
}
func NewService(config config.Server, db *sql.DB, clock time2.Clock) *Service {
return &Service{
config: config,
db: db,
clock: clock,
}
}
func (s *Service) GetAppUserProfile(ctx context.Context, userID string) (*dto.AppUserProfile, error) {
log := util.LogFromContext(ctx).With().Str("userID", userID).Logger()
aup, err := models.AppUserProfiles(
models.AppUserProfileWhere.UserID.EQ(userID),
).One(ctx, s.db)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
log.Debug().Err(err).Msg("AppUserProfile not found")
return nil, ErrNotFound
}
log.Err(err).Msg("Failed to get AppUserProfile")
return nil, err
}
return mapper.LocalAppUserProfileToDTO(aup).Ptr(), nil
}
func (s *Service) UpdatePassword(ctx context.Context, request dto.UpdatePasswordRequest) (dto.LoginResult, error) {
log := util.LogFromContext(ctx).With().Str("userID", request.User.ID).Logger()
if !request.User.IsActive {
log.Debug().Msg("User is deactivated, rejecting password change")
return dto.LoginResult{}, httperrors.ErrForbiddenUserDeactivated
}
if !request.User.PasswordHash.Valid {
log.Debug().Msg("Failed to update user password, user is missing password")
return dto.LoginResult{}, httperrors.ErrForbiddenNotLocalUser
}
if !request.SkipCurrentPasswordVerification {
match, err := hashing.ComparePasswordAndHash(request.CurrentPassword, request.User.PasswordHash.String)
if err != nil {
log.Err(err).Msg("Failed to compare password with stored hash")
return dto.LoginResult{}, err
}
if !match {
log.Debug().Msg("Failed to update user password, provided password does not match stored hash")
return dto.LoginResult{}, echo.ErrUnauthorized
}
}
hash, err := hashing.HashPassword(request.NewPassword, hashing.DefaultArgon2Params)
if err != nil {
log.Err(err).Msg("Failed to hash new password")
return dto.LoginResult{}, httperrors.ErrBadRequestInvalidPassword
}
var result dto.LoginResult
if err := db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error {
request.User.PasswordHash = null.StringFrom(hash)
user := request.User.ToModels()
if _, err := user.Update(ctx, exec, boil.Whitelist(models.UserColumns.Password, models.UserColumns.UpdatedAt)); err != nil {
log.Err(err).Msg("Failed to update user")
return err
}
result, err = s.authenticateUser(ctx, exec, dto.AuthenticateUserRequest{
User: request.User,
InvalidateExistingTokens: true,
})
if err != nil {
log.Err(err).Msg("Failed to authenticate user after password change")
return err
}
return nil
}); err != nil {
log.Debug().Err(err).Msg("Failed to change password")
return dto.LoginResult{}, err
}
return result, nil
}
func (s *Service) ResetPassword(ctx context.Context, request dto.ResetPasswordRequest) (dto.LoginResult, error) {
log := util.LogFromContext(ctx).With().Logger()
passwordResetToken, err := models.PasswordResetTokens(
models.PasswordResetTokenWhere.Token.EQ(request.ResetToken),
qm.Load(models.PasswordResetTokenRels.User),
).One(ctx, s.db)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
log.Debug().Err(err).Msg("Password reset token not found")
return dto.LoginResult{}, httperrors.ErrNotFoundTokenNotFound
}
log.Err(err).Msg("Failed to load password reset token")
return dto.LoginResult{}, err
}
if s.clock.Now().After(passwordResetToken.ValidUntil) {
log.Debug().Time("validUntil", passwordResetToken.ValidUntil).Msg("Password reset token is no longer valid, rejecting password reset")
return dto.LoginResult{}, httperrors.ErrConflictTokenExpired
}
return s.UpdatePassword(ctx, dto.UpdatePasswordRequest{
User: mapper.LocalUserToDTO(passwordResetToken.R.User),
NewPassword: request.NewPassword,
SkipCurrentPasswordVerification: true,
})
}
func (s *Service) InitPasswordReset(ctx context.Context, request dto.InitPasswordResetRequest) (dto.InitPasswordResetResult, error) {
log := util.LogFromContext(ctx).With().Str("username", request.Username.String()).Logger()
user, err := models.Users(
models.UserWhere.Username.EQ(null.StringFrom(request.Username.String())),
).One(ctx, s.db)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
log.Debug().Err(err).Msg("User not found")
return dto.InitPasswordResetResult{}, nil
}
log.Err(err).Err(err).Msg("Failed to load user")
return dto.InitPasswordResetResult{}, err
}
if !user.IsActive {
log.Debug().Msg("User is deactivated, skipping password reset")
return dto.InitPasswordResetResult{}, nil
}
if !user.Password.Valid {
log.Debug().Msg("User is missing password, skipping password reset")
return dto.InitPasswordResetResult{}, nil
}
if s.config.Auth.PasswordResetTokenDebounceDuration > 0 {
resetTokenInDebounceTimeExists, err := user.PasswordResetTokens(
models.PasswordResetTokenWhere.CreatedAt.GT(s.clock.Now().Add(-s.config.Auth.PasswordResetTokenDebounceDuration)),
models.PasswordResetTokenWhere.ValidUntil.GT(s.clock.Now()),
).Exists(ctx, s.db)
if err != nil {
log.Err(err).Msg("Failed to check for existing password reset token")
return dto.InitPasswordResetResult{}, err
}
if resetTokenInDebounceTimeExists {
log.Debug().Msg("Password reset token exists within debounce time, not sending new one")
return dto.InitPasswordResetResult{}, nil
}
}
var result dto.InitPasswordResetResult
if err := db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error {
passwordResetToken, err := user.PasswordResetTokens(
models.PasswordResetTokenWhere.CreatedAt.GT(s.clock.Now().Add(-s.config.Auth.PasswordResetTokenReuseDuration)),
models.PasswordResetTokenWhere.ValidUntil.GT(s.clock.Now()),
).One(ctx, exec)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
log.Debug().Err(err).Msg("No valid password reset token exists, creating new one")
passwordResetToken = &models.PasswordResetToken{
UserID: user.ID,
ValidUntil: s.clock.Now().Add(s.config.Auth.PasswordResetTokenValidity),
}
if err := passwordResetToken.Insert(ctx, exec, boil.Infer()); err != nil {
log.Err(err).Msg("Failed to insert password reset token")
return err
}
} else {
log.Err(err).Msg("Failed to check for existing valid password reset token")
return err
}
}
result.ResetToken = null.StringFrom(passwordResetToken.Token)
return nil
}); err != nil {
log.Debug().Err(err).Msg("Failed to initiate password reset")
return dto.InitPasswordResetResult{}, err
}
return result, nil
}
func (s *Service) Logout(ctx context.Context, request dto.LogoutRequest) error {
log := util.LogFromContext(ctx)
if err := db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error {
if _, err := models.AccessTokens(models.AccessTokenWhere.Token.EQ(request.AccessToken)).DeleteAll(ctx, exec); err != nil {
log.Err(err).Msg("Failed to delete access token")
return err
}
if request.RefreshToken.IsZero() {
return nil
}
if _, err := models.RefreshTokens(models.RefreshTokenWhere.Token.EQ(request.RefreshToken.String)).DeleteAll(ctx, exec); err != nil {
log.Err(err).Msg("Failed to delete refresh token")
return err
}
return nil
}); err != nil {
log.Debug().Err(err).Msg("Failed to process logout")
return err
}
return nil
}
func (s *Service) Login(ctx context.Context, request dto.LoginRequest) (dto.LoginResult, error) {
log := util.LogFromContext(ctx)
user, err := models.Users(
models.UserWhere.Username.EQ(null.StringFrom(request.Username.String())),
).One(ctx, s.db)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
log.Debug().Err(err).Msg("User not found")
}
log.Err(err).Msg("Failed to load user")
return dto.LoginResult{}, echo.ErrUnauthorized
}
if !user.IsActive {
log.Debug().Msg("User is deactivated, rejecting authentication")
return dto.LoginResult{}, httperrors.ErrForbiddenUserDeactivated
}
if !user.Password.Valid {
log.Debug().Msg("User is missing password, forbidding authentication")
return dto.LoginResult{}, echo.ErrUnauthorized
}
match, err := hashing.ComparePasswordAndHash(request.Password, user.Password.String)
if err != nil {
log.Debug().Err(err).Msg("Failed to compare password with stored hash")
return dto.LoginResult{}, echo.ErrUnauthorized
}
if !match {
log.Debug().Msg("Provided password does not match stored hash")
return dto.LoginResult{}, echo.ErrUnauthorized
}
var result dto.LoginResult
err = db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error {
var err error
result, err = s.authenticateUser(ctx, exec, dto.AuthenticateUserRequest{
User: mapper.LocalUserToDTO(user),
})
if err != nil {
log.Err(err).Msg("Failed to authenticate user")
return err
}
return nil
})
if err != nil {
log.Debug().Err(err).Msg("Failed to authenticate user")
return dto.LoginResult{}, err
}
return result, nil
}
func (s *Service) Refresh(ctx context.Context, request dto.RefreshRequest) (dto.LoginResult, error) {
log := util.LogFromContext(ctx)
oldRefreshToken, err := models.RefreshTokens(
models.RefreshTokenWhere.Token.EQ(request.RefreshToken),
qm.Load(models.RefreshTokenRels.User),
).One(ctx, s.db)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
log.Debug().Err(err).Msg("Refresh token not found")
return dto.LoginResult{}, echo.ErrUnauthorized
}
log.Err(err).Msg("Failed to load refresh token")
return dto.LoginResult{}, err
}
user := oldRefreshToken.R.User
if !user.IsActive {
log.Debug().Msg("User is deactivated, rejecting token refresh")
return dto.LoginResult{}, httperrors.ErrForbiddenUserDeactivated
}
var result dto.LoginResult
err = db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error {
_, err = oldRefreshToken.Delete(ctx, exec)
if err != nil {
log.Err(err).Msg("Failed to delete old refresh token")
return err
}
result, err = s.authenticateUser(ctx, exec, dto.AuthenticateUserRequest{
User: mapper.LocalUserToDTO(user),
InvalidateExistingTokens: false,
})
if err != nil {
log.Err(err).Msg("Failed to authenticate user")
return err
}
return nil
})
if err != nil {
log.Debug().Err(err).Msg("Failed to refresh token")
return dto.LoginResult{}, err
}
return result, nil
}
func (s *Service) Register(ctx context.Context, request dto.RegisterRequest) (dto.RegisterResult, error) {
log := util.LogFromContext(ctx).With().Str("username", request.Username.String()).Logger()
user, err := models.Users(
models.UserWhere.Username.EQ(null.StringFrom(request.Username.String())),
).One(ctx, s.db)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
log.Err(err).Msg("Failed to check whether user exists")
return dto.RegisterResult{}, err
}
if user != nil {
if !user.RequiresConfirmation {
log.Debug().Msg("User with given username already exists")
return dto.RegisterResult{}, httperrors.ErrConflictUserAlreadyExists
}
confirmationTokenInDebounceTimeExists, err := user.ConfirmationTokens(
models.ConfirmationTokenWhere.CreatedAt.GT(s.clock.Now().Add(-s.config.Auth.ConfirmationTokenDebounceDuration)),
models.ConfirmationTokenWhere.ValidUntil.GT(s.clock.Now()),
).Exists(ctx, s.db)
if err != nil {
log.Err(err).Msg("Failed to check for existing confirmation token")
return dto.RegisterResult{}, err
}
if confirmationTokenInDebounceTimeExists {
return dto.RegisterResult{
RequiresConfirmation: user.RequiresConfirmation,
}, nil
}
confirmationToken := models.ConfirmationToken{
UserID: user.ID,
ValidUntil: s.clock.Now().Add(s.config.Auth.ConfirmationTokenValidity),
}
if err := confirmationToken.Insert(ctx, s.db, boil.Infer()); err != nil {
log.Err(err).Msg("Failed to insert confirmation token")
return dto.RegisterResult{}, err
}
return dto.RegisterResult{
RequiresConfirmation: user.RequiresConfirmation,
ConfirmationToken: null.StringFrom(confirmationToken.Token),
}, nil
}
hash, err := hashing.HashPassword(request.Password, hashing.DefaultArgon2Params)
if err != nil {
log.Err(err).Msg("Failed to hash user password")
return dto.RegisterResult{}, httperrors.ErrBadRequestInvalidPassword
}
result := dto.RegisterResult{
RequiresConfirmation: s.config.Auth.RegistrationRequiresConfirmation,
}
if err := db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error {
user := &models.User{
Username: null.StringFrom(request.Username.String()),
Password: null.StringFrom(hash),
LastAuthenticatedAt: null.TimeFrom(s.clock.Now()),
IsActive: !result.RequiresConfirmation,
RequiresConfirmation: result.RequiresConfirmation,
Scopes: s.config.Auth.DefaultUserScopes,
}
if err := user.Insert(ctx, exec, boil.Infer()); err != nil {
log.Err(err).Msg("Failed to insert user")
return err
}
appUserProfile := models.AppUserProfile{
UserID: user.ID,
}
if err := appUserProfile.Insert(ctx, exec, boil.Infer()); err != nil {
log.Err(err).Msg("Failed to insert app user profile")
return err
}
if result.RequiresConfirmation {
confirmationToken := models.ConfirmationToken{
UserID: user.ID,
ValidUntil: s.clock.Now().Add(s.config.Auth.ConfirmationTokenValidity),
}
if err := confirmationToken.Insert(ctx, exec, boil.Infer()); err != nil {
log.Err(err).Msg("Failed to insert confirmation token")
return err
}
result.ConfirmationToken = null.StringFrom(confirmationToken.Token)
}
return nil
}); err != nil {
log.Debug().Err(err).Msg("Failed to register user")
return dto.RegisterResult{}, err
}
return result, nil
}
func (s *Service) DeleteUserAccount(ctx context.Context, request dto.DeleteUserAccountRequest) error {
log := util.LogFromContext(ctx)
if !request.User.IsActive {
log.Debug().Msg("User is deactivated, rejecting deletion")
return httperrors.ErrForbiddenUserDeactivated
}
if !request.User.PasswordHash.Valid {
log.Debug().Msg("Failed to delete user account, user is missing password")
return httperrors.ErrForbiddenNotLocalUser
}
match, err := hashing.ComparePasswordAndHash(request.CurrentPassword, request.User.PasswordHash.String)
if err != nil {
log.Debug().Err(err).Msg("Failed to compare password with stored hash")
return echo.ErrUnauthorized
}
if !match {
log.Debug().Msg("Provided password does not match stored hash")
return echo.ErrUnauthorized
}
// delete the user and all related data
err = db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error {
_, err = models.Users(
models.UserWhere.ID.EQ(request.User.ID),
).DeleteAll(ctx, exec)
if err != nil {
log.Err(err).Msg("Failed to delete user")
return err
}
return nil
})
if err != nil {
log.Debug().Err(err).Msg("Failed to delete user account")
return err
}
return nil
}
func (s *Service) CompleteRegister(ctx context.Context, request dto.CompleteRegisterRequest) (dto.LoginResult, error) {
log := util.LogFromContext(ctx)
var result dto.LoginResult
err := db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error {
confirmationToken, err := models.ConfirmationTokens(
models.ConfirmationTokenWhere.Token.EQ(request.ConfirmationToken),
models.ConfirmationTokenWhere.ValidUntil.GT(s.clock.Now()),
qm.Load(models.ConfirmationTokenRels.User),
).One(ctx, s.db)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
log.Debug().Err(err).Msg("Confirmation token not found")
return httperrors.ErrNotFoundTokenNotFound
}
log.Err(err).Msg("Failed to load confirmation token")
return err
}
user := confirmationToken.R.User
if user.IsActive || !user.RequiresConfirmation {
log.Debug().Msg("User already active, skipping confirmation")
return nil
}
user.IsActive = true
user.RequiresConfirmation = false
if _, err := user.Update(ctx, exec, boil.Whitelist(models.UserColumns.IsActive, models.UserColumns.RequiresConfirmation, models.UserColumns.UpdatedAt)); err != nil {
log.Err(err).Msg("Failed to update user")
return err
}
if _, err := confirmationToken.Delete(ctx, exec); err != nil {
log.Err(err).Msg("Failed to delete confirmation token")
return err
}
result, err = s.authenticateUser(ctx, exec, dto.AuthenticateUserRequest{
User: mapper.LocalUserToDTO(confirmationToken.R.User),
})
if err != nil {
log.Err(err).Msg("Failed to authenticate user")
return err
}
return nil
})
if err != nil {
log.Debug().Err(err).Msg("Failed to complete registration")
return dto.LoginResult{}, err
}
return result, nil
}
func (s *Service) authenticateUser(ctx context.Context, exec boil.ContextExecutor, request dto.AuthenticateUserRequest) (dto.LoginResult, error) {
log := util.LogFromContext(ctx)
result := dto.LoginResult{
TokenType: TokenTypeBearer,
ExpiresIn: int64(s.config.Auth.AccessTokenValidity.Seconds()),
}
if request.InvalidateExistingTokens {
if _, err := models.AccessTokens(
models.AccessTokenWhere.UserID.EQ(request.User.ID),
).DeleteAll(ctx, exec); err != nil {
log.Err(err).Msg("Failed to delete existing access tokens")
return dto.LoginResult{}, err
}
if _, err := models.RefreshTokens(
models.RefreshTokenWhere.UserID.EQ(request.User.ID),
).DeleteAll(ctx, exec); err != nil {
log.Err(err).Msg("Failed to delete existing refresh tokens")
return dto.LoginResult{}, err
}
}
accessToken := models.AccessToken{
ValidUntil: s.clock.Now().Add(s.config.Auth.AccessTokenValidity),
UserID: request.User.ID,
}
if err := accessToken.Insert(ctx, exec, boil.Infer()); err != nil {
log.Err(err).Msg("Failed to insert access token")
return dto.LoginResult{}, err
}
refreshToken := models.RefreshToken{
UserID: request.User.ID,
}
if err := refreshToken.Insert(ctx, exec, boil.Infer()); err != nil {
log.Err(err).Msg("Failed to insert refresh token")
return dto.LoginResult{}, err
}
u := request.User.ToModels()
u.LastAuthenticatedAt = null.TimeFrom(s.clock.Now())
if _, err := u.Update(ctx, exec, boil.Whitelist(models.UserColumns.LastAuthenticatedAt, models.UserColumns.UpdatedAt)); err != nil {
log.Err(err).Msg("Failed to update user last authenticated time")
return dto.LoginResult{}, err
}
result.AccessToken = accessToken.Token
result.RefreshToken = refreshToken.Token
return result, nil
}

14
internal/auth/types.go Normal file
View File

@@ -0,0 +1,14 @@
package auth
import (
"time"
"allaboutapps.dev/aw/go-starter/internal/data/dto"
)
type Result struct {
Token string
User *dto.User
ValidUntil time.Time
Scopes []string
}

View File

@@ -0,0 +1,18 @@
package config
import "fmt"
// The following vars are automatically injected via -ldflags.
// See Makefile target "make go-build" and make var $(LDFLAGS).
// No need to change them here.
// https://www.digitalocean.com/community/tutorials/using-ldflags-to-set-version-information-for-go-applications
var (
ModuleName = "build.local/misses/ldflags" // e.g. "allaboutapps.dev/aw/go-starter"
Commit = "< 40 chars git commit hash via ldflags >" // e.g. "59cb7684dd0b0f38d68cd7db657cb614feba8f7e"
BuildDate = "1970-01-01T00:00:00+00:00" // e.g. "1970-01-01T00:00:00+00:00"
)
// GetFormattedBuildArgs returns string representation of buildsargs set via ldflags "<ModuleName> @ <Commit> (<BuildDate>)"
func GetFormattedBuildArgs() string {
return fmt.Sprintf("%v @ %v (%v)", ModuleName, Commit, BuildDate)
}

View File

@@ -0,0 +1,57 @@
package config
import (
"fmt"
"path/filepath"
"sort"
"strings"
"time"
"allaboutapps.dev/aw/go-starter/internal/util"
)
// The DatabaseMigrationTable name is baked into the binary
// This setting should always be in sync with dbconfig.yml, sqlboiler.toml and the live database (e.g. to be able to test producation dumps locally)
const DatabaseMigrationTable = "migrations"
// The DatabaseMigrationFolder (folder with all *.sql migrations).
// This settings should always be in sync with dbconfig.yaml and Dockerfile (the final app stage).
// It's expected that the migrations folder lives at the root of this project or right next to the app binary.
var DatabaseMigrationFolder = filepath.Join(util.GetProjectRootDir(), "/migrations")
type Database struct {
Host string
Port int
Username string
Password string `json:"-"` // sensitive
Database string
AdditionalParams map[string]string `json:"additionalParams,omitempty"` // Optional additional connection parameters mapped into the connection string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
}
// ConnectionString generates a connection string to be passed to sql.Open or equivalents, assuming Postgres syntax
func (c Database) ConnectionString() string {
var builder strings.Builder
builder.WriteString(fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s", c.Host, c.Port, c.Username, c.Password, c.Database))
if _, ok := c.AdditionalParams["sslmode"]; !ok {
builder.WriteString(" sslmode=disable")
}
if len(c.AdditionalParams) > 0 {
params := make([]string, 0, len(c.AdditionalParams))
for param := range c.AdditionalParams {
params = append(params, param)
}
sort.Strings(params)
for _, param := range params {
fmt.Fprintf(&builder, " %s=%s", param, c.AdditionalParams[param])
}
}
return builder.String()
}

View File

@@ -0,0 +1,68 @@
package config_test
import (
"testing"
"allaboutapps.dev/aw/go-starter/internal/config"
)
// via https://github.com/allaboutapps/integresql/blob/master/pkg/manager/database_config_test.go
func TestDatabaseConnectionString(t *testing.T) {
tests := []struct {
name string
config config.Database
want string
}{
{
name: "Simple",
config: config.Database{
Host: "localhost",
Port: 5432,
Username: "simple",
Password: "database_config",
Database: "simple_database_config",
},
want: "host=localhost port=5432 user=simple password=database_config dbname=simple_database_config sslmode=disable",
},
{
name: "SSLMode",
config: config.Database{
Host: "localhost",
Port: 5432,
Username: "simple",
Password: "database_config",
Database: "simple_database_config",
AdditionalParams: map[string]string{
"sslmode": "prefer",
},
},
want: "host=localhost port=5432 user=simple password=database_config dbname=simple_database_config sslmode=prefer",
},
{
name: "Complex",
config: config.Database{
Host: "localhost",
Port: 5432,
Username: "simple",
Password: "database_config",
Database: "simple_database_config",
AdditionalParams: map[string]string{
"connect_timeout": "10",
"sslmode": "verify-full",
"sslcert": "/app/certs/pg.pem",
"sslkey": "/app/certs/pg.key",
"sslrootcert": "/app/certs/pg_root.pem",
},
},
want: "host=localhost port=5432 user=simple password=database_config dbname=simple_database_config connect_timeout=10 sslcert=/app/certs/pg.pem sslkey=/app/certs/pg.key sslmode=verify-full sslrootcert=/app/certs/pg_root.pem",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.config.ConnectionString(); got != tt.want {
t.Errorf("invalid connection string, got %q, want %q", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,71 @@
package config
import (
"errors"
"fmt"
"os"
"github.com/rs/zerolog/log"
"github.com/subosito/gotenv"
)
// os.SetEnv func signature
type envSetter = func(key string, value string) error
// DotEnvTryLoad forcefully overrides ENV variables through **a maybe available** .env file.
//
// This function will always remain silent if a .env file does not exist!
// If we successfully apply an ENV file, we will log a warning.
// If there are any other errors, we will panic!
//
// This mechanism should only be used **locally** to easily inject (gitignored)
// secrets into your ENV. Non-existing .env files are actually the **best case**.
//
// When running normally (not within tests):
// DotEnvTryLoad("/path/tp/my.env.local", os.SetEnv)
//
// For tests (and autoreset) use t.Setenv:
// DotEnvTryLoad("/path/to/my.env.test.local", func(k string, v string) error { t.Setenv(k, v); return nil })
func DotEnvTryLoad(absolutePathToEnvFile string, setEnvFn envSetter) {
err := DotEnvLoad(absolutePathToEnvFile, setEnvFn)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
log.Panic().Err(err).Str("envFile", absolutePathToEnvFile).Msg(".env parse error!")
}
} else {
log.Warn().Str("envFile", absolutePathToEnvFile).Msg(".env overrides ENV variables!")
}
}
// DotEnvLoad forcefully overrides ENV variables through the supplied .env file.
//
// This mechanism should only be used **locally** to easily inject (gitignored)
// secrets into your ENV.
//
// When running normally (not within tests):
// DotEnvLoad("/path/to/my.env.local", os.SetEnv)
//
// For tests (and ENV var autoreset) use t.Setenv:
// DotEnvLoad("/path/to/my.env.test.local", func(k string, v string) error { t.Setenv(k, v); return nil })
func DotEnvLoad(absolutePathToEnvFile string, setEnvFn envSetter) error {
file, err := os.Open(absolutePathToEnvFile)
if err != nil {
return fmt.Errorf("failed to open .env file: %w", err)
}
defer file.Close()
envs, err := gotenv.StrictParse(file)
if err != nil {
return fmt.Errorf("failed to parse .env file: %w", err)
}
for key, value := range envs {
if err := setEnvFn(key, value); err != nil {
return fmt.Errorf("failed to set environment variable: %w", err)
}
}
return nil
}

View File

@@ -0,0 +1,63 @@
package config_test
import (
"os"
"path/filepath"
"testing"
"allaboutapps.dev/aw/go-starter/internal/config"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/stretchr/testify/assert"
)
func TestDotEnvOverride(t *testing.T) {
assert.Empty(t, os.Getenv("IS_THIS_A_TEST_ENV"))
orgPsqlUser := os.Getenv("PSQL_USER")
config.DotEnvTryLoad(
filepath.Join(util.GetProjectRootDir(), "/internal/config/testdata/.env1.local"),
func(k string, v string) error { t.Setenv(k, v); return nil })
assert.Equal(t, "yes", os.Getenv("IS_THIS_A_TEST_ENV"))
assert.Equal(t, "dotenv_override_psql_user", os.Getenv("PSQL_USER"))
assert.Equal(t, orgPsqlUser, os.Getenv("ORIGINAL_PSQL_USER"))
// override works as expected?
config.DotEnvTryLoad(
filepath.Join(util.GetProjectRootDir(), "/internal/config/testdata/.env2.local"),
func(k string, v string) error { t.Setenv(k, v); return nil })
assert.Equal(t, "yes still", os.Getenv("IS_THIS_A_TEST_ENV"))
assert.NotEqual(t, "dotenv_override_psql_user", os.Getenv("PSQL_USER"))
assert.Equal(t, orgPsqlUser, os.Getenv("PSQL_USER"), "Reset to original does not work!")
}
func TestNoopEnvNotFound(t *testing.T) {
assert.NotPanics(t, assert.PanicTestFunc(func() {
config.DotEnvTryLoad(
filepath.Join(util.GetProjectRootDir(), "/internal/config/testdata/.env.does.not.exist"),
func(k string, v string) error { t.Setenv(k, v); return nil },
)
}), "does not panic on file inexistance")
}
func TestEmptyEnv(t *testing.T) {
assert.NotPanics(t, assert.PanicTestFunc(func() {
config.DotEnvTryLoad(
filepath.Join(util.GetProjectRootDir(), "/internal/config/testdata/.env.local.sample"),
func(k string, v string) error { t.Setenv(k, v); return nil },
)
}), "does not panic on file inexistance")
assert.Empty(t, os.Getenv("EMPTY_VARIABLE_INIT"), "should be empty")
}
func TestPanicsOnEnvMalform(t *testing.T) {
assert.Panics(t, assert.PanicTestFunc(func() {
config.DotEnvTryLoad(
filepath.Join(util.GetProjectRootDir(), "/internal/config/testdata/.env.local.malformed"),
func(k string, v string) error { t.Setenv(k, v); return nil },
)
}), "does panic on file malform")
}

View File

@@ -0,0 +1,19 @@
package config
type MailerTransporter string
var (
MailerTransporterMock MailerTransporter = "mock"
MailerTransporterSMTP MailerTransporter = "SMTP"
)
func (m MailerTransporter) String() string {
return string(m)
}
type Mailer struct {
DefaultSender string
Send bool
WebTemplatesEmailBaseDirAbs string
Transporter string
}

View File

@@ -0,0 +1,258 @@
package config
import (
"os"
"path/filepath"
"runtime"
"testing"
"time"
"allaboutapps.dev/aw/go-starter/internal/mailer/transport"
"allaboutapps.dev/aw/go-starter/internal/push/provider"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/rs/zerolog"
"golang.org/x/text/language"
)
type EchoServer struct {
Debug bool
ListenAddress string
HideInternalServerErrorDetails bool
BaseURL string
EnableCORSMiddleware bool
EnableLoggerMiddleware bool
EnableRecoverMiddleware bool
EnableRequestIDMiddleware bool
EnableTrailingSlashMiddleware bool
EnableSecureMiddleware bool
EnableCacheControlMiddleware bool
SecureMiddleware EchoServerSecureMiddleware
WebTemplatesViewsBaseDirAbs string
}
type PprofServer struct {
Enable bool
EnableManagementKeyAuth bool
RuntimeBlockProfileRate int
RuntimeMutexProfileFraction int
}
// EchoServerSecureMiddleware represents a subset of echo's secure middleware config relevant to the app server.
// https://github.com/labstack/echo/blob/master/middleware/secure.go
type EchoServerSecureMiddleware struct {
XSSProtection string
ContentTypeNosniff string
XFrameOptions string
HSTSMaxAge int
HSTSExcludeSubdomains bool
ContentSecurityPolicy string
CSPReportOnly bool
HSTSPreloadEnabled bool
ReferrerPolicy string
}
type AuthServer struct {
AccessTokenValidity time.Duration
PasswordResetTokenValidity time.Duration
PasswordResetTokenDebounceDuration time.Duration
PasswordResetTokenReuseDuration time.Duration
DefaultUserScopes []string
LastAuthenticatedAtThreshold time.Duration
RegistrationRequiresConfirmation bool
ConfirmationTokenValidity time.Duration
ConfirmationTokenDebounceDuration time.Duration
}
type PathsServer struct {
APIBaseDirAbs string
MntBaseDirAbs string
AppleAppSiteAssociationFile string
AndroidAssetlinksFile string
}
type ManagementServer struct {
Secret string `json:"-"` // sensitive
ReadinessTimeout time.Duration
LivenessTimeout time.Duration
ProbeWriteablePathsAbs []string
ProbeWriteableTouchfile string
EnableMetrics bool
}
type FrontendServer struct {
BaseURL string
PasswordResetEndpoint string
}
type LoggerServer struct {
Level zerolog.Level
RequestLevel zerolog.Level
LogRequestBody bool
LogRequestHeader bool
LogRequestQuery bool
LogResponseBody bool
LogResponseHeader bool
LogCaller bool
PrettyPrintConsole bool
}
type I18n struct {
DefaultLanguage language.Tag
BundleDirAbs string
}
type Server struct {
Database Database
Echo EchoServer
Pprof PprofServer
Paths PathsServer
Auth AuthServer
Management ManagementServer
Mailer Mailer
SMTP transport.SMTPMailTransportConfig
Frontend FrontendServer
Logger LoggerServer
Push PushService
FCMConfig provider.FCMConfig
I18n I18n
}
// DefaultServiceConfigFromEnv returns the server config as parsed from environment variables
// and their respective defaults defined below.
// We don't expect that ENV_VARs change while we are running our application or our tests
// (and it would be a bad thing to do anyways with parallel testing).
// Do NOT use os.Setenv / os.Unsetenv in tests utilizing DefaultServiceConfigFromEnv()!
func DefaultServiceConfigFromEnv() Server {
// An `.env.local` file in your project root can override the currently set ENV variables.
//
// We never automatically apply `.env.local` when running "go test" as these ENV variables
// may be sensitive (e.g. secrets to external APIs) and applying them modifies the process
// global "os.Env" state (it should be applied via t.SetEnv instead).
//
// If you need dotenv ENV variables available in a test, do that explicitly within that
// test before executing DefaultServiceConfigFromEnv (or test.WithTestServer).
// See /internal/test/helper_dot_env.go: test.DotEnvLoadLocalOrSkipTest(t)
if !testing.Testing() {
DotEnvTryLoad(filepath.Join(util.GetProjectRootDir(), ".env.local"), os.Setenv)
}
return Server{
Database: Database{
Host: util.GetEnv("PGHOST", "postgres"),
Port: util.GetEnvAsInt("PGPORT", 5432),
Database: util.GetEnv("PGDATABASE", "development"),
Username: util.GetEnv("PGUSER", "dbuser"),
Password: util.GetEnv("PGPASSWORD", ""),
AdditionalParams: map[string]string{
"sslmode": util.GetEnv("PGSSLMODE", "disable"),
},
MaxOpenConns: util.GetEnvAsInt("DB_MAX_OPEN_CONNS", runtime.NumCPU()*2),
MaxIdleConns: util.GetEnvAsInt("DB_MAX_IDLE_CONNS", 1),
ConnMaxLifetime: time.Second * time.Duration(util.GetEnvAsInt("DB_CONN_MAX_LIFETIME_SEC", 60)),
},
Echo: EchoServer{
Debug: util.GetEnvAsBool("SERVER_ECHO_DEBUG", false),
ListenAddress: util.GetEnv("SERVER_ECHO_LISTEN_ADDRESS", ":8080"),
HideInternalServerErrorDetails: util.GetEnvAsBool("SERVER_ECHO_HIDE_INTERNAL_SERVER_ERROR_DETAILS", true),
BaseURL: util.GetEnv("SERVER_ECHO_BASE_URL", "http://localhost:8080"),
EnableCORSMiddleware: util.GetEnvAsBool("SERVER_ECHO_ENABLE_CORS_MIDDLEWARE", true),
EnableLoggerMiddleware: util.GetEnvAsBool("SERVER_ECHO_ENABLE_LOGGER_MIDDLEWARE", true),
EnableRecoverMiddleware: util.GetEnvAsBool("SERVER_ECHO_ENABLE_RECOVER_MIDDLEWARE", true),
EnableRequestIDMiddleware: util.GetEnvAsBool("SERVER_ECHO_ENABLE_REQUEST_ID_MIDDLEWARE", true),
EnableTrailingSlashMiddleware: util.GetEnvAsBool("SERVER_ECHO_ENABLE_TRAILING_SLASH_MIDDLEWARE", true),
EnableSecureMiddleware: util.GetEnvAsBool("SERVER_ECHO_ENABLE_SECURE_MIDDLEWARE", true),
EnableCacheControlMiddleware: util.GetEnvAsBool("SERVER_ECHO_ENABLE_CACHE_CONTROL_MIDDLEWARE", true),
// see https://echo.labstack.com/middleware/secure
// see https://github.com/labstack/echo/blob/master/middleware/secure.go
SecureMiddleware: EchoServerSecureMiddleware{
XSSProtection: util.GetEnv("SERVER_ECHO_SECURE_MIDDLEWARE_XSS_PROTECTION", "1; mode=block"),
ContentTypeNosniff: util.GetEnv("SERVER_ECHO_SECURE_MIDDLEWARE_CONTENT_TYPE_NOSNIFF", "nosniff"),
XFrameOptions: util.GetEnv("SERVER_ECHO_SECURE_MIDDLEWARE_X_FRAME_OPTIONS", "SAMEORIGIN"),
HSTSMaxAge: util.GetEnvAsInt("SERVER_ECHO_SECURE_MIDDLEWARE_HSTS_MAX_AGE", 0),
HSTSExcludeSubdomains: util.GetEnvAsBool("SERVER_ECHO_SECURE_MIDDLEWARE_HSTS_EXCLUDE_SUBDOMAINS", false),
ContentSecurityPolicy: util.GetEnv("SERVER_ECHO_SECURE_MIDDLEWARE_CONTENT_SECURITY_POLICY", ""),
CSPReportOnly: util.GetEnvAsBool("SERVER_ECHO_SECURE_MIDDLEWARE_CSP_REPORT_ONLY", false),
HSTSPreloadEnabled: util.GetEnvAsBool("SERVER_ECHO_SECURE_MIDDLEWARE_HSTS_PRELOAD_ENABLED", false),
ReferrerPolicy: util.GetEnv("SERVER_ECHO_SECURE_MIDDLEWARE_REFERRER_POLICY", ""),
},
WebTemplatesViewsBaseDirAbs: util.GetEnv("SERVER_ECHO_WEB_TEMPLATES_VIEWS_BASE_DIR_ABS", filepath.Join(util.GetProjectRootDir(), "/web/templates/views")),
},
Pprof: PprofServer{
// https://golang.org/pkg/net/http/pprof/
Enable: util.GetEnvAsBool("SERVER_PPROF_ENABLE", false),
EnableManagementKeyAuth: util.GetEnvAsBool("SERVER_PPROF_ENABLE_MANAGEMENT_KEY_AUTH", true),
RuntimeBlockProfileRate: util.GetEnvAsInt("SERVER_PPROF_RUNTIME_BLOCK_PROFILE_RATE", 0),
RuntimeMutexProfileFraction: util.GetEnvAsInt("SERVER_PPROF_RUNTIME_MUTEX_PROFILE_FRACTION", 0),
},
Paths: PathsServer{
// Please ALWAYS work with ABSOLUTE (ABS) paths from ENV_VARS (however you may resolve a project-relative to absolute for the default value)
APIBaseDirAbs: util.GetEnv("SERVER_PATHS_API_BASE_DIR_ABS", filepath.Join(util.GetProjectRootDir(), "/api")), // /app/api (swagger.yml)
MntBaseDirAbs: util.GetEnv("SERVER_PATHS_MNT_BASE_DIR_ABS", filepath.Join(util.GetProjectRootDir(), "/assets/mnt")), // /app/assets/mnt (user-generated content)
AppleAppSiteAssociationFile: util.GetEnv("SERVER_PATHS_APPLE_APP_SITE_ASSOCIATION_FILE", ""),
AndroidAssetlinksFile: util.GetEnv("SERVER_PATHS_ANDROID_ASSETLINKS_FILE", ""),
},
Auth: AuthServer{
AccessTokenValidity: time.Second * time.Duration(util.GetEnvAsInt("SERVER_AUTH_ACCESS_TOKEN_VALIDITY", 86400)),
PasswordResetTokenValidity: time.Second * time.Duration(util.GetEnvAsInt("SERVER_AUTH_PASSWORD_RESET_TOKEN_VALIDITY", 900)),
PasswordResetTokenDebounceDuration: time.Second * time.Duration(util.GetEnvAsInt("SERVER_AUTH_PASSWORD_RESET_TOKEN_DEBOUNCE_DURATION_SECONDS", 60)),
PasswordResetTokenReuseDuration: time.Second * time.Duration(util.GetEnvAsInt("SERVER_AUTH_PASSWORD_RESET_TOKEN_REUSE_DURATION_SECONDS", 0)),
DefaultUserScopes: util.GetEnvAsStringArr("SERVER_AUTH_DEFAULT_USER_SCOPES", []string{"app"}),
LastAuthenticatedAtThreshold: time.Second * time.Duration(util.GetEnvAsInt("SERVER_AUTH_LAST_AUTHENTICATED_AT_THRESHOLD", 900)),
RegistrationRequiresConfirmation: util.GetEnvAsBool("SERVER_AUTH_REGISTRATION_REQUIRES_CONFIRMATION", false),
ConfirmationTokenValidity: time.Second * time.Duration(util.GetEnvAsInt("SERVER_AUTH_CONFIRMATION_TOKEN_VALIDITY_SECONDS", 86400)),
ConfirmationTokenDebounceDuration: time.Second * time.Duration(util.GetEnvAsInt("SERVER_AUTH_CONFIRMATION_TOKEN_DEBOUNCE_DURATION_SECONDS", 60)),
},
Management: ManagementServer{
Secret: util.GetMgmtSecret("SERVER_MANAGEMENT_SECRET"),
ReadinessTimeout: time.Second * time.Duration(util.GetEnvAsInt("SERVER_MANAGEMENT_READINESS_TIMEOUT_SEC", 4)),
LivenessTimeout: time.Second * time.Duration(util.GetEnvAsInt("SERVER_MANAGEMENT_LIVENESS_TIMEOUT_SEC", 9)),
ProbeWriteablePathsAbs: util.GetEnvAsStringArr("SERVER_MANAGEMENT_PROBE_WRITEABLE_PATHS_ABS", []string{
filepath.Join(util.GetProjectRootDir(), "/assets/mnt")}, ","),
ProbeWriteableTouchfile: util.GetEnv("SERVER_MANAGEMENT_PROBE_WRITEABLE_TOUCHFILE", ".healthy"),
EnableMetrics: util.GetEnvAsBool("SERVER_MANAGEMENT_ENABLE_METRICS", false),
},
Mailer: Mailer{
DefaultSender: util.GetEnv("SERVER_MAILER_DEFAULT_SENDER", "go-starter@example.com"),
Send: util.GetEnvAsBool("SERVER_MAILER_SEND", true),
WebTemplatesEmailBaseDirAbs: util.GetEnv("SERVER_MAILER_WEB_TEMPLATES_EMAIL_BASE_DIR_ABS", filepath.Join(util.GetProjectRootDir(), "/web/templates/email")), // /app/web/templates/email
Transporter: util.GetEnvEnum("SERVER_MAILER_TRANSPORTER", MailerTransporterMock.String(), []string{MailerTransporterSMTP.String(), MailerTransporterMock.String()}),
},
SMTP: transport.SMTPMailTransportConfig{
Host: util.GetEnv("SERVER_SMTP_HOST", "mailhog"),
Port: util.GetEnvAsInt("SERVER_SMTP_PORT", 1025),
Username: util.GetEnv("SERVER_SMTP_USERNAME", ""),
Password: util.GetEnv("SERVER_SMTP_PASSWORD", ""),
AuthType: transport.SMTPAuthTypeFromString(util.GetEnv("SERVER_SMTP_AUTH_TYPE", transport.SMTPAuthTypeNone.String())),
Encryption: transport.SMTPEncryption(util.GetEnvEnum("SERVER_SMTP_ENCRYPTION", transport.SMTPEncryptionNone.String(), []string{transport.SMTPEncryptionNone.String(), transport.SMTPEncryptionTLS.String(), transport.SMTPEncryptionStartTLS.String()})),
TLSConfig: nil,
},
Frontend: FrontendServer{
BaseURL: util.GetEnv("SERVER_FRONTEND_BASE_URL", "http://localhost:3000"),
PasswordResetEndpoint: util.GetEnv("SERVER_FRONTEND_PASSWORD_RESET_ENDPOINT", "/set-new-password"),
},
Logger: LoggerServer{
Level: util.LogLevelFromString(util.GetEnv("SERVER_LOGGER_LEVEL", zerolog.DebugLevel.String())),
RequestLevel: util.LogLevelFromString(util.GetEnv("SERVER_LOGGER_REQUEST_LEVEL", zerolog.DebugLevel.String())),
LogRequestBody: util.GetEnvAsBool("SERVER_LOGGER_LOG_REQUEST_BODY", false),
LogRequestHeader: util.GetEnvAsBool("SERVER_LOGGER_LOG_REQUEST_HEADER", false),
LogRequestQuery: util.GetEnvAsBool("SERVER_LOGGER_LOG_REQUEST_QUERY", false),
LogResponseBody: util.GetEnvAsBool("SERVER_LOGGER_LOG_RESPONSE_BODY", false),
LogResponseHeader: util.GetEnvAsBool("SERVER_LOGGER_LOG_RESPONSE_HEADER", false),
LogCaller: util.GetEnvAsBool("SERVER_LOGGER_LOG_CALLER", false),
PrettyPrintConsole: util.GetEnvAsBool("SERVER_LOGGER_PRETTY_PRINT_CONSOLE", false),
},
Push: PushService{
UseFCMProvider: util.GetEnvAsBool("SERVER_PUSH_USE_FCM", false),
UseMockProvider: util.GetEnvAsBool("SERVER_PUSH_USE_MOCK", true),
},
FCMConfig: provider.FCMConfig{
GoogleApplicationCredentials: util.GetEnv("GOOGLE_APPLICATION_CREDENTIALS", ""),
ProjectID: util.GetEnv("SERVER_FCM_PROJECT_ID", "no-fcm-project-id-set"),
ValidateOnly: util.GetEnvAsBool("SERVER_FCM_VALIDATE_ONLY", true),
},
I18n: I18n{
DefaultLanguage: util.GetEnvAsLanguageTag("SERVER_I18N_DEFAULT_LANGUAGE", language.English),
BundleDirAbs: util.GetEnv("SERVER_I18N_BUNDLE_DIR_ABS", filepath.Join(util.GetProjectRootDir(), "/web/i18n")), // /app/web/i18n
},
}
}

View File

@@ -0,0 +1,17 @@
package config_test
import (
"encoding/json"
"testing"
"allaboutapps.dev/aw/go-starter/internal/config"
)
func TestPrintServiceEnv(t *testing.T) {
config := config.DefaultServiceConfigFromEnv()
_, err := json.MarshalIndent(config, "", " ")
if err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,6 @@
package config
type PushService struct {
UseFCMProvider bool
UseMockProvider bool
}

View File

@@ -0,0 +1 @@
bla bla

View File

@@ -0,0 +1,2 @@
# empty value should be ok
EMPTY_VARIABLE_INIT=

3
internal/config/testdata/.env1.local vendored Normal file
View File

@@ -0,0 +1,3 @@
IS_THIS_A_TEST_ENV="yes"
ORIGINAL_PSQL_USER="${PSQL_USER}"
PSQL_USER="dotenv_override_psql_user"

2
internal/config/testdata/.env2.local vendored Normal file
View File

@@ -0,0 +1,2 @@
IS_THIS_A_TEST_ENV="yes still"
PSQL_USER="${ORIGINAL_PSQL_USER}" # reset back to proper env

10
internal/data/dto/push.go Normal file
View File

@@ -0,0 +1,10 @@
package dto
import "github.com/aarondl/null/v8"
type UpdatePushTokenRequest struct {
User User
Token string
Provider string
ExistingToken null.String
}

157
internal/data/dto/users.go Normal file
View File

@@ -0,0 +1,157 @@
package dto
import (
"time"
"allaboutapps.dev/aw/go-starter/internal/models"
"allaboutapps.dev/aw/go-starter/internal/types"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/aarondl/null/v8"
"github.com/go-openapi/strfmt"
"github.com/go-openapi/strfmt/conv"
"github.com/go-openapi/swag"
)
type User struct {
ID string
Username null.String
PasswordHash null.String
IsActive bool
Scopes []string
LastAuthenticatedAt null.Time
UpdatedAt time.Time
Profile *AppUserProfile
}
func (u User) LastUpdatedAt() time.Time {
if u.Profile != nil && u.Profile.UpdatedAt.After(u.UpdatedAt) {
return u.Profile.UpdatedAt
}
return u.UpdatedAt
}
func (u User) Ptr() *User {
return &u
}
func (u User) ToTypes() *types.GetUserInfoResponse {
return &types.GetUserInfoResponse{
Sub: swag.String(u.ID),
UpdatedAt: swag.Int64(u.LastUpdatedAt().Unix()),
Email: strfmt.Email(u.Username.String),
Scopes: u.Scopes,
}
}
func (u User) ToModels() *models.User {
return &models.User{
ID: u.ID,
Username: u.Username,
Password: u.PasswordHash,
IsActive: u.IsActive,
Scopes: u.Scopes,
LastAuthenticatedAt: u.LastAuthenticatedAt,
}
}
type AppUserProfile struct {
UserID string
LegalAcceptedAt null.Time
UpdatedAt time.Time
}
func (aup AppUserProfile) Ptr() *AppUserProfile {
return &aup
}
type UpdatePasswordRequest struct {
User User
CurrentPassword string
SkipCurrentPasswordVerification bool
NewPassword string
}
type RegisterResult struct {
RequiresConfirmation bool
ConfirmationToken null.String
}
type LoginResult struct {
UserID string
AccessToken string
ExpiresIn int64
RefreshToken string
TokenType string
}
func (l LoginResult) ToTypes() *types.PostLoginResponse {
return &types.PostLoginResponse{
AccessToken: conv.UUID4(strfmt.UUID4(l.AccessToken)),
RefreshToken: conv.UUID4(strfmt.UUID4(l.RefreshToken)),
ExpiresIn: swag.Int64(l.ExpiresIn),
TokenType: swag.String(l.TokenType),
}
}
type ResetPasswordRequest struct {
ResetToken string
NewPassword string
}
type Username struct {
val string
}
func NewUsername(val string) Username {
return Username{val: val}
}
func (u Username) String() string {
return util.ToUsernameFormat(u.val)
}
type InitPasswordResetRequest struct {
Username Username
}
type InitPasswordResetResult struct {
ResetToken null.String
}
type LoginRequest struct {
Username Username
Password string
}
type LogoutRequest struct {
AccessToken string
RefreshToken null.String
}
type AuthenticateUserRequest struct {
User User
InvalidateExistingTokens bool
}
type RefreshRequest struct {
RefreshToken string
}
type RegisterRequest struct {
Username Username
Password string
}
type CompleteRegisterRequest struct {
ConfirmationToken string
}
type DeleteUserAccountRequest struct {
User User
CurrentPassword string
}
type ConfirmatioNotificationPayload struct {
ConfirmationLink string
}

View File

@@ -0,0 +1,36 @@
package fixtures
import (
"context"
"fmt"
"allaboutapps.dev/aw/go-starter/internal/models"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/aarondl/sqlboiler/v4/boil"
)
// Live Service fixtures to be applied by manually running the CLI "app db seed"
// Note that these fixtures are not available while testing
// see the separate internal/test/fixtures.go file
type Upsertable interface {
Upsert(ctx context.Context, exec boil.ContextExecutor, updateOnConflict bool, conflictColumns []string, updateColumns, insertColumns boil.Columns, opts ...models.UpsertOptionFunc) error
}
// Mind the declaration order! The fields get upserted exactly in the order they are declared.
type FixtureMap struct{}
func Fixtures() FixtureMap {
return FixtureMap{}
}
func Upserts() []Upsertable {
fix := Fixtures()
upsertableIfc := (*Upsertable)(nil)
upserts, err := util.GetFieldsImplementing(&fix, upsertableIfc)
if err != nil {
panic(fmt.Errorf("failed to get upsertable fixture fields: %w", err))
}
return upserts
}

View File

@@ -0,0 +1,18 @@
package fixtures_test
import (
"testing"
data "allaboutapps.dev/aw/go-starter/internal/data/fixtures"
"allaboutapps.dev/aw/go-starter/internal/models"
"github.com/stretchr/testify/assert"
)
func TestUpsertableInterface(t *testing.T) {
var user any = &models.AppUserProfile{
UserID: "62b13d29-5c4e-420e-b991-a631d3938776",
}
_, ok := user.(data.Upsertable)
assert.True(t, ok, "AppUserProfile should implement the Upsertable interface")
}

View File

@@ -0,0 +1,22 @@
package local
import (
"database/sql"
"allaboutapps.dev/aw/go-starter/internal/config"
"github.com/dropbox/godropbox/time2"
)
type Service struct {
config config.Server
db *sql.DB
clock time2.Clock
}
func NewService(config config.Server, db *sql.DB, clock time2.Clock) *Service {
return &Service{
config: config,
db: db,
clock: clock,
}
}

View File

@@ -0,0 +1,75 @@
package local
import (
"context"
"database/sql"
"errors"
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
"allaboutapps.dev/aw/go-starter/internal/data/dto"
"allaboutapps.dev/aw/go-starter/internal/models"
"allaboutapps.dev/aw/go-starter/internal/util"
"allaboutapps.dev/aw/go-starter/internal/util/db"
"github.com/aarondl/sqlboiler/v4/boil"
)
func (s *Service) UpdatePushToken(ctx context.Context, request dto.UpdatePushTokenRequest) error {
log := util.LogFromContext(ctx).With().Str("userID", request.User.ID).Logger()
err := db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error {
tokenExists, err := models.PushTokens(
models.PushTokenWhere.Token.EQ(request.Token),
).Exists(ctx, exec)
if err != nil {
log.Err(err).Msg("Failed to check if token exists")
return err
}
if tokenExists {
log.Debug().Msg("Token already exists")
return httperrors.ErrConflictPushToken
}
newToken := models.PushToken{
UserID: request.User.ID,
Token: request.Token,
Provider: request.Provider,
}
if err := newToken.Insert(ctx, s.db, boil.Infer()); err != nil {
log.Err(err).Msg("Failed to insert new token")
return err
}
if request.ExistingToken.IsZero() {
return nil
}
existingToken, err := models.PushTokens(
models.PushTokenWhere.Token.EQ(request.ExistingToken.String),
models.PushTokenWhere.UserID.EQ(request.User.ID),
).One(ctx, exec)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
log.Debug().Msg("Existing token not found")
return httperrors.ErrNotFoundOldPushToken
}
log.Err(err).Msg("Failed to find existing token")
return err
}
if _, err := existingToken.Delete(ctx, exec); err != nil {
log.Err(err).Msg("Failed to delete existing token")
return err
}
return nil
})
if err != nil {
log.Debug().Err(err).Msg("Failed to update push token")
return err
}
return nil
}

View File

@@ -0,0 +1,32 @@
package mapper
import (
"allaboutapps.dev/aw/go-starter/internal/data/dto"
"allaboutapps.dev/aw/go-starter/internal/models"
)
func LocalAppUserProfileToDTO(appUserProfile *models.AppUserProfile) dto.AppUserProfile {
return dto.AppUserProfile{
UserID: appUserProfile.UserID,
LegalAcceptedAt: appUserProfile.LegalAcceptedAt,
UpdatedAt: appUserProfile.UpdatedAt,
}
}
func LocalUserToDTO(user *models.User) dto.User {
result := dto.User{
ID: user.ID,
Username: user.Username,
IsActive: user.IsActive,
Scopes: user.Scopes,
LastAuthenticatedAt: user.LastAuthenticatedAt,
UpdatedAt: user.UpdatedAt,
PasswordHash: user.Password,
}
if user.R != nil && user.R.AppUserProfile != nil {
result.Profile = LocalAppUserProfileToDTO(user.R.AppUserProfile).Ptr()
}
return result
}

211
internal/i18n/i18n.go Normal file
View File

@@ -0,0 +1,211 @@
package i18n
import (
"fmt"
"os"
"path/filepath"
"strings"
"allaboutapps.dev/aw/go-starter/internal/config"
"github.com/BurntSushi/toml"
"github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/rs/zerolog/log"
"golang.org/x/text/language"
)
// i18n, we expect the following:
// Your translation toml files should live within /web/i18n and are named according to their supported locale e.g. en.toml, de.toml or en-uk.toml, en-us.toml.
//
// All translation files should hold the same keys (there are no unique keys on a single bundle locale, apart from pluralization).
//
// The Service object is created and owned by the api.Server (s.I18n), you typically don't want to create your own object.
//
// Pluralization obeys to CLDR rules (https://cldr.unicode.org/index/cldr-spec/plural-rules).
// Some keywords are reserved for CLDR behaviour, templating and documentation: id, description, hash, leftdelim, rightdelim, zero, one, two, few, many, other
// See
// Service is your convenience object to call Translate/TranslatePlural and match languages according to your loaded translation bundle and its supported languages/locales.
type Service struct {
bundle *i18n.Bundle
matcher language.Matcher
}
// Data should be used to pass your template data
type Data map[string]string
// New returns a new Service struct holding bundle and matcher with the settings of the given config
//
// Note that Service is typically created and owned by the api.Server (use it via s.I18n)
func New(config config.I18n) (*Service, error) {
bundle := i18n.NewBundle(config.DefaultLanguage)
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
// Load all translation files in each language...
files, err := os.ReadDir(config.BundleDirAbs)
if err != nil {
log.Err(err).Str("dir", config.BundleDirAbs).Msg("Failed to read i18n bundle directory")
return nil, fmt.Errorf("failed to read i18n bundle directory: %w", err)
}
for _, file := range files {
if file.IsDir() || !strings.HasSuffix(file.Name(), ".toml") {
continue
}
// bundle.LoadMessageFile automatically guesses the language.Tag based on the filenames it encounters
_, err := bundle.LoadMessageFile(filepath.Join(config.BundleDirAbs, file.Name()))
if err != nil {
log.Err(err).Str("file", file.Name()).Msg("Failed to load i18n message file")
return nil, fmt.Errorf("failed to load i18n message file: %w", err)
}
}
tags := bundle.LanguageTags()
for tagIndex, tag := range tags {
// Undetermined languages are disallowed in our bundle.
if tag == language.Und {
err := fmt.Errorf("undetermined language at index %v in i18n message bundle: %v", tagIndex, tags)
log.Err(err).Int("index", tagIndex).Str("tags", fmt.Sprintf("%v", tags)).Msg("Invalid i18n message bundle or default language.")
return nil, fmt.Errorf("invalid i18n message bundle or default language: %w", err)
}
}
return &Service{
bundle: bundle,
matcher: language.NewMatcher(tags),
}, nil
}
// Translate your key into a localized string.
//
// Translate makes a lookup for the key in the current bundle with the specified language.
// If a language translation is not available the default language will be used.
// Additional data for templated strings can be passed as key value pairs with by passing an optional data map.
//
// Translate will not fail if a template value is missing "<no value>" will be inserted instead.
// Translate will also not fail if the key is not present. "{{key}}" will be returned instead.
func (m *Service) Translate(key string, lang language.Tag, data ...Data) string {
msg, err := m.TranslateMaybe(key, lang, data...)
if err != nil {
log.Debug().Err(err).Str("key", key).Str("lang", lang.String()).Msg("Failed to translate")
return key
}
return msg
}
// TranslateMaybe has the same sematics as Translate with the following exceptions:
// It exposes encountered errors (does not automatically log this error) and encountered errors may result in an empty "" string!
//
// This method may be useful for conditional translation rendering (if key is available, use that, else...).
func (m *Service) TranslateMaybe(key string, lang language.Tag, data ...Data) (string, error) {
localizeConfig := &i18n.LocalizeConfig{
MessageID: key,
}
if len(data) > 0 {
localizeConfig.TemplateData = data[0]
}
return m.translateConfigurable(lang, localizeConfig)
}
// TranslatePlural translates a pluralized cldrKey into a localized string.
//
// TranslatePlural makes a lookup for the cldrKey (a base key holding CLDR keys like "one" and "other") in the current bundle with the specified language.
// This function should be used to conditionally show the pluralized form, controlled by the count param and according to the CLDR rules.
//
// Note that English and German only support .one and .other CLDR plural rules.
// See https://cldr.unicode.org/index/cldr-spec/plural-rules and https://www.unicode.org/cldr/cldr-aux/charts/28/supplemental/language_plural_rules.html
//
// If a language translation is not available the default language will be used.
// Additional data for templated strings can be passed as key value pairs with by passing an optional data map.
// The count param is automatically injected into this data map as stringified {{.Count}} and may be overwritten.
//
// TranslatePlural will not fail if a template value is missing "<no value>" will be inserted instead.
// TranslatePlural will also not fail if the cldrKey is not present. "{{cldrKey}} (count={{count}})" will be returned instead.
func (m *Service) TranslatePlural(cldrKey string, count interface{}, lang language.Tag, data ...Data) string {
msg, err := m.TranslatePluralMaybe(cldrKey, count, lang, data...)
if err != nil {
log.Debug().Err(err).Str("count", fmt.Sprintf("%v", count)).Str("cldrKey", cldrKey).Str("lang", lang.String()).Msg("Failed to translate plural")
return fmt.Sprintf("%s (count=%v)", cldrKey, count)
}
return msg
}
// TranslatePluralMaybe uses the same sematics as TranslatePlural with the following exceptions:
// It exposes encountered errors (does not automatically log this error) and encountered errors may result in an empty "" string!
//
// This method may be useful for conditional plural translation rendering (if key is available, use that, else...).
func (m *Service) TranslatePluralMaybe(cldrKey string, count interface{}, lang language.Tag, data ...Data) (string, error) {
localizeConfig := &i18n.LocalizeConfig{
MessageID: cldrKey,
PluralCount: count,
}
// We inject Count by default into our template data (for rare usecases you may overwrite it)
templateData := make(Data)
templateData["Count"] = fmt.Sprintf("%v", count)
// If optional data was provided, merge them into the templateData map
if len(data) > 0 {
for k, v := range data[0] {
templateData[k] = v
}
}
localizeConfig.TemplateData = templateData
return m.translateConfigurable(lang, localizeConfig)
}
// ParseAcceptLanguage takes the value of the Accept-Language header and returns
// the best matched language using the matcher.
func (m *Service) ParseAcceptLanguage(lang string) language.Tag {
// we deliberately ignore the error returned here, as it will be nil and the matcher will simply pick the default language
// this allows us to skip any malformed Accept-Language headers without returning 500 errors to the client
// additionally, we don't really care about the q-factor weighting or confidence, the first match will be picked (with a fallback to config.DefaultLanguage)
tags, _, err := language.ParseAcceptLanguage(lang)
if err != nil {
log.Err(err).Str("lang", lang).Msg("Failed to parse accept language")
}
matchedTag, _, _ := m.matcher.Match(tags...)
return matchedTag
}
// ParseLang parses the string as language tag and returns
// the best matched language using the matcher.
func (m *Service) ParseLang(lang string) language.Tag {
t, err := language.Parse(lang)
if err != nil {
log.Err(err).Str("lang", lang).Msg("Failed to parse language")
}
matchedTag, _, _ := m.matcher.Match(t)
return matchedTag
}
// Tags returns the parsed and priority ordered []language.Tag (your config.DefaultLanguage will be on position 0)
func (m *Service) Tags() []language.Tag {
return m.bundle.LanguageTags()
}
// translateConfigurable is used internally for fully configurable translations according to our configured language precedence semantics (new Localizer per call).
func (m *Service) translateConfigurable(lang language.Tag, localizeConfig *i18n.LocalizeConfig) (string, error) {
// We benchmarked precaching all known []i18n.NewLocalizer during initialization,
// but it doesn't make a significant difference even with 10000 concurrent * 8 .Translate calls.
// Thus we take the easy route and initialize a new localizer with each .Translate or .TranslatePlural call.
localizer := i18n.NewLocalizer(m.bundle, lang.String())
msg, err := localizer.Localize(localizeConfig)
if err != nil {
return msg, fmt.Errorf("failed to localize: %w", err)
}
return msg, nil
}

589
internal/i18n/i18n_test.go Normal file
View File

@@ -0,0 +1,589 @@
package i18n_test
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/config"
"allaboutapps.dev/aw/go-starter/internal/i18n"
"allaboutapps.dev/aw/go-starter/internal/test"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/text/language"
)
func TestServerProvidedI18n(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
// uses /app/web/i18n by default
// expect all i18n files were loaded and the defaultLanguage matches the FIRST priority Tag.
assert.Equal(t, s.Config.I18n.DefaultLanguage, s.I18n.Tags()[0])
// expect all i18ns were loaded...
files, err := os.ReadDir(s.Config.I18n.BundleDirAbs)
require.NoError(t, err)
msgFilesCount := 0
for _, file := range files {
if file.IsDir() || !strings.HasSuffix(file.Name(), ".toml") {
continue
}
msgFilesCount++
}
if msgFilesCount == 0 {
// no i18n bundles were available, as the defaultLanguage is a tag itself, check for len 1
assert.Len(t, s.I18n.Tags(), 1)
} else {
assert.Len(t, s.I18n.Tags(), msgFilesCount)
}
msg := s.I18n.Translate("this.key.will.never.exist", s.Config.I18n.DefaultLanguage)
assert.Equal(t, "this.key.will.never.exist", msg)
})
}
// Note that all following tests use a special message directory within /internal/i18n/testdata.
// We do this to ensure we don't depend on your project specific i18n bundle/configuration,
// that you would typically store within /web/i18n.
func TestI18n(t *testing.T) {
srv, err := i18n.New(config.I18n{
DefaultLanguage: language.English,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
})
require.NoError(t, err)
assert.Equal(t, language.English, srv.Tags()[0])
assert.Equal(t, language.German, srv.Tags()[1])
assert.Len(t, srv.Tags(), 2)
msg := srv.Translate("Test.Welcome", language.German, i18n.Data{"Name": "Hans"})
assert.Equal(t, "Guten Tag Hans", msg)
msg = srv.Translate("Test.Welcome", language.English, i18n.Data{"Name": "Hans"})
assert.Equal(t, "Welcome Hans", msg)
msg = srv.Translate("Test.Welcome", language.Spanish, i18n.Data{"Name": "Hans"})
assert.Equal(t, "Welcome Hans", msg)
msg = srv.Translate("Test.Welcome", language.English, i18n.Data{"Name": "Franz"})
assert.Equal(t, "Welcome Franz", msg)
msg = srv.Translate("Test.Welcome", language.English)
assert.Equal(t, "Welcome <no value>", msg)
msg = srv.Translate("Test.Body", language.German)
assert.Equal(t, "Das ist ein Test", msg)
msg = srv.Translate("Test.Body", language.English)
assert.Equal(t, "This is a test", msg)
msg = srv.Translate("Test.Body", language.Spanish)
assert.Equal(t, "This is a test", msg)
msg = srv.Translate("Test.Invalid.Key.Does.Not.Exist", language.English)
assert.Equal(t, "Test.Invalid.Key.Does.Not.Exist", msg)
msg = srv.Translate("Test.Invalid.Key.Does.Not.Exist", language.German)
assert.Equal(t, "Test.Invalid.Key.Does.Not.Exist", msg)
msg = srv.Translate("Test.String.DE.only", language.English)
assert.Equal(t, "Test.String.DE.only", msg)
msg = srv.Translate("Test.String.DE.only", language.German)
assert.Equal(t, "This key only exists in DE", msg)
msg, err = srv.TranslateMaybe("Test.String.DE.only", language.English)
require.Error(t, err)
assert.Empty(t, msg) // no fallback!
msg = srv.Translate("Test.String.EN.only", language.English)
assert.Equal(t, "This key only exists in EN", msg)
msg, err = srv.TranslateMaybe("Test.String.EN.only", language.German)
require.Error(t, err)
assert.Equal(t, "This key only exists in EN", msg) // fallback (but error)
msg = srv.Translate("Test.String.EN.only", language.German)
assert.Equal(t, "Test.String.EN.only", msg)
msg = srv.Translate("", language.German) // empty key
assert.Empty(t, msg)
msg, err = srv.TranslateMaybe("", language.German) // empty key
require.Error(t, err)
assert.Empty(t, msg)
// ensure language subvariants are supported
deAt := srv.ParseLang("de-AT")
assert.NotEqual(t, language.German, deAt)
msg = srv.Translate("Test.Body", deAt)
assert.Equal(t, "Das ist ein Test", msg)
}
func TestI18nConcurrentUsage(t *testing.T) {
srv, err := i18n.New(config.I18n{
DefaultLanguage: language.English,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
})
require.NoError(t, err)
wg := sync.WaitGroup{}
wg.Add(100)
for i := 0; i < 100; i++ {
go func(index int) {
msg := srv.Translate("Test.Welcome", language.German, i18n.Data{"Name": strconv.Itoa(index)})
assert.Equal(t, fmt.Sprintf("Guten Tag %v", index), msg)
msg = srv.Translate("Test.Welcome", language.English, i18n.Data{"Name": strconv.Itoa(index)})
assert.Equal(t, fmt.Sprintf("Welcome %v", index), msg)
msg = srv.Translate("Test.Welcome", language.Spanish, i18n.Data{"Name": strconv.Itoa(index)})
assert.Equal(t, fmt.Sprintf("Welcome %v", index), msg)
msg = srv.Translate("Test.Welcome", language.English, i18n.Data{"Name": "Franz"})
assert.Equal(t, "Welcome Franz", msg)
msg = srv.Translate("Test.Welcome", language.English)
assert.Equal(t, "Welcome <no value>", msg)
msg = srv.Translate("Test.Body", language.German)
assert.Equal(t, "Das ist ein Test", msg)
msg = srv.Translate("Test.Body", language.English)
assert.Equal(t, "This is a test", msg)
msg = srv.Translate("Test.Body", language.Spanish)
assert.Equal(t, "This is a test", msg)
wg.Done()
}(i)
}
wg.Wait()
}
func TestI18nOtherDefault(t *testing.T) {
srv, err := i18n.New(config.I18n{
DefaultLanguage: language.German,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
})
require.NoError(t, err)
assert.Equal(t, language.German, srv.Tags()[0])
assert.Equal(t, language.English, srv.Tags()[1])
assert.Len(t, srv.Tags(), 2)
}
func TestI18nInexistantDefault(t *testing.T) {
srv, err := i18n.New(config.I18n{
DefaultLanguage: language.Italian,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
})
require.NoError(t, err)
assert.Equal(t, language.Italian, srv.Tags()[0])
assert.Equal(t, language.German, srv.Tags()[1])
assert.Equal(t, language.English, srv.Tags()[2])
assert.Len(t, srv.Tags(), 3)
}
func TestI18nEmpty(t *testing.T) {
srv, err := i18n.New(config.I18n{
DefaultLanguage: language.Italian,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-empty"),
})
require.NoError(t, err)
assert.Len(t, srv.Tags(), 1) // the DefaultLanguage should still be set!
assert.Equal(t, language.Italian, srv.Tags()[0])
tag := srv.ParseAcceptLanguage("de,en-US;q=0.7,en;q=0.3")
assert.Equal(t, language.Italian, tag)
msg := srv.Translate("no.test.key.exists", tag)
assert.Equal(t, "no.test.key.exists", msg)
msg, err = srv.TranslateMaybe("no.test.key.exists", tag)
require.Error(t, err)
assert.Empty(t, msg)
msg = srv.Translate("no.test.key.exists", language.Ukrainian)
assert.Equal(t, "no.test.key.exists", msg)
msg, err = srv.TranslateMaybe("no.test.key.exists", language.Ukrainian)
require.Error(t, err)
assert.Empty(t, msg)
}
func TestI18nSpecialized(t *testing.T) {
srv, err := i18n.New(config.I18n{
DefaultLanguage: language.AmericanEnglish,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-specialized"),
})
require.NoError(t, err)
assert.Len(t, srv.Tags(), 4) // specialized subvariant is default
assert.Equal(t, language.AmericanEnglish, srv.Tags()[0])
msg := srv.Translate("test.punchline", language.AmericanEnglish)
assert.Equal(t, "I can has HUMOR?", msg)
msg = srv.Translate("test.punchline", language.BritishEnglish)
assert.Equal(t, "I can has HUMOUR?", msg)
msg = srv.Translate("test.punchline", language.English)
assert.Equal(t, "I can has HUMOR?", msg) // fall back to default
msg = srv.Translate("test.punchline", language.German)
assert.Equal(t, "Habe ich Humor?", msg) // jump to parsed Austrian German
tag := srv.ParseAcceptLanguage("de-at,en-US;q=0.7,en;q=0.3") // explicit Austrian tag
msg = srv.Translate("test.punchline", tag)
assert.Equal(t, "Koan i Humor?", msg) // jump to parsed Austrian German
}
func TestReservedKeywordsResolve(t *testing.T) {
// "reserved" keys:
// "id", "description", "hash", "leftdelim", "rightdelim", "zero", "one", "two", "few", "many", "other"
// see https://github.com/nicksnyder/go-i18n/blob/2180cd9f35b3e125cfe3773a6bf3ea483347f060/v2/i18n/message.go#L181
srv, err := i18n.New(config.I18n{
DefaultLanguage: language.English,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-reserved"),
})
require.NoError(t, err)
assert.Len(t, srv.Tags(), 2)
// single reserved word (not yet mapped, requires 2 keywords at least)
msg := srv.Translate("reserved1.zero", language.English)
assert.Equal(t, "Zero", msg)
msg = srv.Translate("reserved2.one", language.English)
assert.Equal(t, "One", msg)
msg = srv.Translate("reserved3.two", language.English)
assert.Equal(t, "Two", msg)
msg = srv.Translate("reserved4.few", language.English)
assert.Equal(t, "Few", msg)
msg = srv.Translate("reserved5.many", language.English)
assert.Equal(t, "Many", msg)
msg = srv.Translate("reserved6.other", language.English)
assert.Equal(t, "Other", msg)
msg = srv.Translate("reserved7.id", language.English)
assert.Equal(t, "id", msg)
msg = srv.Translate("reserved8.description", language.English)
assert.Equal(t, "Description", msg)
// single parent is not directly resolveable
msg = srv.Translate("reserved2", language.English)
assert.Equal(t, "reserved2", msg)
// german: single reserved word
msg = srv.Translate("reserved1.zero", language.German)
assert.Equal(t, "Null", msg)
msg = srv.Translate("reserved2.one", language.German)
assert.Equal(t, "Eins", msg)
msg = srv.Translate("reserved3.two", language.German)
assert.Equal(t, "Zwei", msg)
msg = srv.Translate("reserved4.few", language.German)
assert.Equal(t, "Wenig", msg)
msg = srv.Translate("reserved5.many", language.German)
assert.Equal(t, "Mehr", msg)
msg = srv.Translate("reserved6.other", language.German)
assert.Equal(t, "Andere", msg)
msg = srv.Translate("reserved7.id", language.German)
assert.Equal(t, "ID", msg)
msg = srv.Translate("reserved8.description", language.German)
assert.Equal(t, "Beschreibung", msg)
// Combined toml map: all reserved words
// This does not work as it's parsed as map and CLDR plural rules now apply!
// The last key is interpreted as https://cldr.unicode.org/index/cldr-spec/plural-rules
msg = srv.Translate("reservedMap.zero", language.English)
assert.Equal(t, "reservedMap.zero", msg)
msg = srv.Translate("reservedMap.one", language.English)
assert.Equal(t, "reservedMap.one", msg)
msg = srv.Translate("reservedMap.two", language.English)
assert.Equal(t, "reservedMap.two", msg)
msg = srv.Translate("reservedMap.few", language.English)
assert.Equal(t, "reservedMap.few", msg)
msg = srv.Translate("reservedMap.many", language.English)
assert.Equal(t, "reservedMap.many", msg)
msg = srv.Translate("reservedMap.other", language.English)
assert.Equal(t, "reservedMap.other", msg)
msg = srv.TranslatePlural("reservedMap", 0, language.English)
assert.Equal(t, "Other", msg)
msg = srv.TranslatePlural("reservedMap", 1, language.English)
assert.Equal(t, "One", msg)
msg = srv.TranslatePlural("reservedMap", 2, language.English)
assert.Equal(t, "Other", msg)
msg = srv.TranslatePlural("reservedMap", "asdfasdf", language.English)
assert.Equal(t, "reservedMap (count=asdfasdf)", msg)
// plain toml: all reserved words
// This does not work as it's parsed as map and CLDR plural rules now apply!
// The last key is interpreted as https://cldr.unicode.org/index/cldr-spec/plural-rules
msg = srv.Translate("reserved.plain.zero", language.English)
assert.Equal(t, "reserved.plain.zero", msg)
msg = srv.Translate("reserved.plain.one", language.English)
assert.Equal(t, "reserved.plain.one", msg)
msg = srv.Translate("reserved.plain.two", language.English)
assert.Equal(t, "reserved.plain.two", msg)
msg = srv.Translate("reserved.plain.few", language.English)
assert.Equal(t, "reserved.plain.few", msg)
msg = srv.Translate("reserved.plain.many", language.English)
assert.Equal(t, "reserved.plain.many", msg)
msg = srv.Translate("reserved.plain.other", language.English)
assert.Equal(t, "reserved.plain.other", msg)
msg = srv.Translate("reserved.plain2.id", language.English)
assert.Equal(t, "reserved.plain2.id", msg)
msg = srv.Translate("reserved.plain2.description", language.English)
assert.Equal(t, "reserved.plain2.description", msg)
msg = srv.Translate("reserved.plain3.id", language.English)
assert.Equal(t, "reserved.plain3.id", msg)
msg = srv.Translate("reserved.plain3.description", language.English)
assert.Equal(t, "reserved.plain3.description", msg)
msg = srv.Translate("reserved.plain3.test", language.English)
assert.Equal(t, "reserved.plain3.test", msg)
}
func TestI18nPlural(t *testing.T) {
srv, err := i18n.New(config.I18n{
DefaultLanguage: language.English,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-plural"),
})
require.NoError(t, err)
assert.Len(t, srv.Tags(), 2)
msg := srv.TranslatePlural("cats", 0, language.AmericanEnglish)
assert.Equal(t, "I've 0 cats.", msg) // zero is not supported for CLDR English, "I don't have a cat." is not possible!
msg = srv.TranslatePlural("cats", 1, language.BritishEnglish)
assert.Equal(t, "I've one cat.", msg)
msg = srv.TranslatePlural("cats", 2, language.English)
assert.Equal(t, "I've 2 cats.", msg)
msg = srv.TranslatePlural("cats", 8, language.English)
assert.Equal(t, "I've 8 cats.", msg)
msg = srv.TranslatePlural("cats", -1, language.English) // negative and positive scales behave the same!
assert.Equal(t, "I've one cat.", msg)
msg = srv.TranslatePlural("cats", -2, language.English) // negative and positive scales behave the same!
assert.Equal(t, "I've -2 cats.", msg)
msg, err = srv.TranslatePluralMaybe("cats", -2, language.English)
require.NoError(t, err)
assert.Equal(t, "I've -2 cats.", msg)
msg = srv.TranslatePlural("cats", nil, language.English)
assert.Equal(t, "I've <nil> cats.", msg) // invalid count
msg, err = srv.TranslatePluralMaybe("cats", nil, language.English)
require.NoError(t, err)
assert.Equal(t, "I've <nil> cats.", msg) // invalid count
msg = srv.TranslatePlural("cats", "many", language.English)
assert.Equal(t, "cats (count=many)", msg) // internal failed to translate plural!
// overwrite Count
msg = srv.TranslatePlural("cats", 8, language.English, i18n.Data{"Count": "too many"})
assert.Equal(t, "I've too many cats.", msg)
msg = srv.TranslatePlural("cats", 0, language.German)
assert.Equal(t, "Ich habe 0 Katzen.", msg) // zero is not supported for CLDR German, "Ich habe keine Katze." is not possible!
msg = srv.TranslatePlural("cats", 1, language.German)
assert.Equal(t, "Ich habe eine Katze.", msg)
msg = srv.TranslatePlural("cats", 2, language.German)
assert.Equal(t, "Ich habe 2 Katzen.", msg)
msg = srv.TranslatePlural("cats", 8, language.German)
assert.Equal(t, "Ich habe 8 Katzen.", msg)
msg = srv.TranslatePlural("cats", "viele", language.German)
assert.Equal(t, "cats (count=viele)", msg) // internal failed to translate plural!
msg, err = srv.TranslatePluralMaybe("cats", "viele", language.German)
require.Error(t, err)
assert.Empty(t, msg) // empty string for errors!
// overwrite Count
msg = srv.TranslatePlural("cats", 8, language.German, i18n.Data{"Count": "zu viele"})
assert.Equal(t, "Ich habe zu viele Katzen.", msg)
// unknown language string
tag := srv.ParseLang("xx")
assert.Equal(t, language.English, tag)
msg = srv.TranslatePlural("cats", 8, tag)
assert.Equal(t, "I've 8 cats.", msg) // fall back to English
// invalid specialized language string
tag = srv.ParseLang("de-xx")
msg = srv.TranslatePlural("cats", 8, tag)
assert.Equal(t, "Ich habe 8 Katzen.", msg) // fall back to German
// invalid language string
tag = srv.ParseLang("§$%/%&/(/&%/)(")
assert.Equal(t, language.English, tag)
msg = srv.TranslatePlural("cats", 8, tag)
assert.Equal(t, "I've 8 cats.", msg) // fall back to English
// unknown
msg = srv.TranslatePlural("this.key.will.never.exist", nil, language.English)
assert.Equal(t, "this.key.will.never.exist (count=<nil>)", msg)
msg, err = srv.TranslatePluralMaybe("this.key.will.never.exist", nil, language.English)
require.Error(t, err)
assert.Empty(t, msg) // empty string for errors!
}
func TestI18nUndetermined(t *testing.T) {
_, err := i18n.New(config.I18n{
DefaultLanguage: language.English,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-undetermined"),
})
require.Error(t, err)
unwrappedErr := errors.Unwrap(err)
require.Error(t, unwrappedErr)
assert.Equal(t, unwrappedErr, errors.New("undetermined language at index 1 in i18n message bundle: [en und]"))
}
func TestI18nUndeterminedDefaultLanguage(t *testing.T) {
_, err := i18n.New(config.I18n{
DefaultLanguage: language.Und, // Undetermined is disallowed
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
})
require.Error(t, err)
unwrappedErr := errors.Unwrap(err)
require.Error(t, unwrappedErr)
assert.Equal(t, unwrappedErr, errors.New("undetermined language at index 0 in i18n message bundle: [und de en]"))
}
func TestI18nInvalidToml(t *testing.T) {
_, err := i18n.New(config.I18n{
DefaultLanguage: language.English,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-invalid"),
})
require.Error(t, err)
}
func TestI18nInexistantFolder(t *testing.T) {
_, err := i18n.New(config.I18n{
DefaultLanguage: language.AmericanEnglish,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n/this/folder/does/not/exist"),
})
require.Error(t, err)
}
func TestParseAcceptLanguage(t *testing.T) {
srv, err := i18n.New(config.I18n{
DefaultLanguage: language.English,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
})
require.NoError(t, err)
tag := srv.ParseAcceptLanguage("de,en-US;q=0.7,en;q=0.3")
assert.Equal(t, language.German, tag)
tag = srv.ParseAcceptLanguage("de-AT,en-US;q=0.7,en;q=0.3")
assert.NotEqual(t, language.German, tag) // actual: de-u-rg-atzzzz
// unknown language header
tag = srv.ParseAcceptLanguage("xx,en-US;q=0.7,en;q=0.3")
assert.Equal(t, language.English, tag)
msg := srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"})
assert.Equal(t, "Welcome Hans", msg)
// invalid specialized language string
tag = srv.ParseAcceptLanguage("de-xx,en-US;q=0.7,en;q=0.3")
msg = srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"})
assert.Equal(t, "Guten Tag Hans", msg)
// invalid language header
tag = srv.ParseAcceptLanguage("§$%/%&/(/&%/)(")
assert.Equal(t, language.English, tag)
msg = srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"})
assert.Equal(t, "Welcome Hans", msg)
}
func TestParseLanguage(t *testing.T) {
srv, err := i18n.New(config.I18n{
DefaultLanguage: language.English,
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
})
require.NoError(t, err)
tag := srv.ParseLang("de")
assert.Equal(t, language.German, tag)
// unknown language string
tag = srv.ParseLang("xx")
assert.Equal(t, language.English, tag)
msg := srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"})
assert.Equal(t, "Welcome Hans", msg)
// invalid specialized language string
tag = srv.ParseLang("de-xx")
msg = srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"})
assert.Equal(t, "Guten Tag Hans", msg) // fall back to German
// invalid language string
tag = srv.ParseLang("§$%/%&/(/&%/)(")
assert.Equal(t, language.English, tag)
msg = srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"})
assert.Equal(t, "Welcome Hans", msg)
}

3
internal/i18n/testdata/README.md vendored Normal file
View File

@@ -0,0 +1,3 @@
# `internal/i18n/testdata`
These translation toml files are not meant to be touched while doing application development, rather they are here to test the baseline functionality of the i18n package.

View File

View File

@@ -0,0 +1,2 @@
# This file in invalid on purpose.
This is not toml.

View File

@@ -0,0 +1,13 @@
# {{.Count}} is injected by default while using service.TranslatePlural
# pluralized structured format
[cats]
zero="Ich habe keine Katze."
one="Ich habe eine Katze."
other="Ich habe {{.Count}} Katzen."
# pluralized single line format
"dogs.zero"="Ich habe keinen Hund."
"dogs.one"="Ich habe einen Hund."
"dogs.other"="Ich habe {{.Count}} Hunde."

View File

@@ -0,0 +1,12 @@
# {{.Count}} is injected by default while using service.TranslatePlural
# pluralized structured format
[cats]
zero="I don't have a cat."
one="I've one cat."
other="I've {{.Count}} cats."
# pluralized single line format
"dogs.zero"="I don't have a dog."
"dogs.one"="I've one dog."
"dogs.other"="I've {{.Count}} dogs."

View File

@@ -0,0 +1,34 @@
# "reserved" keys:
# "id", "description", "hash", "leftdelim", "rightdelim", "zero", "one", "two", "few", "many", "other"
# see https://github.com/nicksnyder/go-i18n/blob/2180cd9f35b3e125cfe3773a6bf3ea483347f060/v2/i18n/message.go#L181
"reserved1.zero"="Null"
"reserved2.one"="Eins"
"reserved3.two"="Zwei"
"reserved4.few"="Wenig"
"reserved5.many"="Mehr"
"reserved6.other"="Andere"
"reserved7.id"="ID"
"reserved8.description"="Beschreibung"
[reservedMap]
zero="Null"
one="Eins"
two="Zwei"
few="Wenig"
many="Mehr"
other="Andere"
"reserved.plain.zero"="Null"
"reserved.plain.one"="Eins"
"reserved.plain.two"="Zwei"
"reserved.plain.few"="Wenig"
"reserved.plain.many"="Mehr"
"reserved.plain.other"="Andere"
"reserved.plain2.id"="ID"
"reserved.plain2.description"="Beschreibung"
"reserved.plain3.id"="ID"
"reserved.plain3.description"="Beschreibung"
"reserved.plain3.test"="Test" # can i see this non-reserved one by FQDN?

View File

@@ -0,0 +1,34 @@
# "reserved" keys:
# "id", "description", "hash", "leftdelim", "rightdelim", "zero", "one", "two", "few", "many", "other"
# see https://github.com/nicksnyder/go-i18n/blob/2180cd9f35b3e125cfe3773a6bf3ea483347f060/v2/i18n/message.go#L181
"reserved1.zero"="Zero"
"reserved2.one"="One"
"reserved3.two"="Two"
"reserved4.few"="Few"
"reserved5.many"="Many"
"reserved6.other"="Other"
"reserved7.id"="id"
"reserved8.description"="Description"
[reservedMap]
zero="Zero"
one="One"
two="Two"
few="Few"
many="Many"
other="Other"
"reserved.plain.zero"="Zero"
"reserved.plain.one"="One"
"reserved.plain.two"="Two"
"reserved.plain.few"="Few"
"reserved.plain.many"="Many"
"reserved.plain.other"="Other"
"reserved.plain2.id"="id"
"reserved.plain2.description"="Description"
"reserved.plain3.id"="id"
"reserved.plain3.description"="Description"
"reserved.plain3.test"="Test" # can i see this non-reserved one by FQDN?

View File

@@ -0,0 +1,2 @@
[test]
punchline="Koan i Humor?"

Some files were not shown because too many files have changed in this diff Show More