(Feat): Initial Commit
This commit is contained in:
5
internal/auth/constants.go
Normal file
5
internal/auth/constants.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package auth
|
||||
|
||||
const (
|
||||
TokenTypeBearer = "bearer"
|
||||
)
|
||||
82
internal/auth/context.go
Normal file
82
internal/auth/context.go
Normal 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
7
internal/auth/errors.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package auth
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
)
|
||||
11
internal/auth/scopes.go
Normal file
11
internal/auth/scopes.go
Normal 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
620
internal/auth/service.go
Normal 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
14
internal/auth/types.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user