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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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