// Copyright (c) 2025 Nikolai Papin // // This file is part of Easywish // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See // the GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . package services import ( "context" "easywish/config" "easywish/internal/database" errs "easywish/internal/errors" "easywish/internal/models" "easywish/internal/utils" "easywish/internal/utils/enums" "errors" "fmt" "time" "github.com/go-redis/redis/v8" "github.com/google/uuid" "github.com/jackc/pgerrcode" "github.com/jackc/pgx/v5" "go.uber.org/zap" ) type AuthService interface { RegistrationBegin(request models.RegistrationBeginRequest) (bool, error) RegistrationComplete(request models.RegistrationCompleteRequest) (*models.RegistrationCompleteResponse, error) Login(request models.LoginRequest) (*models.LoginResponse, error) Refresh(request models.RefreshRequest) (*models.RefreshResponse, error) PasswordResetBegin(request models.PasswordResetBeginRequest) (bool, error) PasswordResetComplete(request models.PasswordResetCompleteRequest) (*models.PasswordResetCompleteResponse, error) } type authServiceImpl struct { log *zap.Logger dbctx database.DbContext redis *redis.Client smtp SmtpService } func NewAuthService(_log *zap.Logger, _dbctx database.DbContext, _redis *redis.Client, _smtp SmtpService) AuthService { return &authServiceImpl{log: _log, dbctx: _dbctx, redis: _redis, smtp: _smtp} } func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequest) (bool, error) { var occupationStatus database.CheckUserRegistrationAvailabilityRow var user database.User var generatedCode string var generatedCodeHash string var passwordHash string var err error helper, db, err := database.NewDbHelperTransaction(a.dbctx) if err != nil { a.log.Error( "Failed to open a transaction", zap.Error(err)) return false, errs.ErrServerError } defer helper.RollbackOnError(err) // TODO: check occupation with redis if occupationStatus, err = db.TXQueries.CheckUserRegistrationAvailability(db.CTX, database.CheckUserRegistrationAvailabilityParams{ Email: request.Email, Username: request.Username, }); err != nil { a.log.Error( "Failed to check credentials availability for registration", zap.String("username", request.Username), zap.String("email", request.Email), zap.Error(err)) return false, errs.ErrServerError } if occupationStatus.UsernameBusy { a.log.Warn( "Attempted registration for a taken username", zap.String("email", request.Email), zap.String("username", request.Username)) return false, errs.ErrUsernameTaken } else if occupationStatus.EmailBusy { // Falsely confirm in order to avoid disclosing registered email addresses // TODO: save this email into redis a.log.Warn( "Attempted registration for a taken email", zap.String("email", request.Email), zap.String("username", request.Username)) return true, nil } else { if _, err := db.TXQueries.DeleteUnverifiedAccountsHavingUsernameOrEmail(db.CTX, database.DeleteUnverifiedAccountsHavingUsernameOrEmailParams{ Username: request.Username, Email: request.Email, }); err != nil { a.log.Error( "Failed to purge unverified accounts as part of registration", zap.String("email", request.Email), zap.String("username", request.Username), zap.Error(err)) return false, errs.ErrServerError } } if user, err = db.TXQueries.CreateUser(db.CTX, request.Username); err != nil { a.log.Error("Failed to add user to database", zap.Error(err)) return false, errs.ErrServerError } if passwordHash, err = utils.HashPassword(request.Password); err != nil { a.log.Error("Error on hashing password", zap.Error(err)) return false, errs.ErrServerError } if _, err = db.TXQueries.CreateLoginInformation(db.CTX, database.CreateLoginInformationParams{ UserID: user.ID, Email: utils.NewPointer(request.Email), PasswordHash: passwordHash, // Hashed in database }); err != nil { if errs.MatchPgError(err, pgerrcode.UniqueViolation) { // Since we've already checked for username previously, only email is left return false, errs.ErrEmailTaken } a.log.Error("Failed to add login information for user to database", zap.Error(err)) return false, errs.ErrServerError } if generatedCode, err = utils.GenerateSecure6DigitNumber(); err != nil { a.log.Error("Failed to generate a registration code", zap.Error(err)) return false, errs.ErrServerError } if generatedCodeHash, err = utils.HashPassword(generatedCode); err != nil { a.log.Error("Failed to hash generated verification code", zap.Error(err)) return false, errs.ErrServerError } if _, err = db.TXQueries.CreateConfirmationCode(db.CTX, database.CreateConfirmationCodeParams{ UserID: user.ID, CodeType: int32(enums.RegistrationCodeType), CodeHash: generatedCodeHash, // Hashed in database }); err != nil { a.log.Error("Failed to add registration code to database", zap.Error(err)) return false, errs.ErrServerError } a.log.Info( "Registered a new user", zap.String("username", user.Username), zap.Int64("id", user.ID)) if config.GetConfig().SmtpEnabled { if err := a.smtp.SendEmail( request.Email, "Easywish", fmt.Sprintf("Your registration code is %s", generatedCode), ); err != nil { a.log.Error( "Failed to send registration email", zap.String("email", request.Email), zap.String("username", request.Username), zap.Error(err)) return false, errs.ErrServerError } } else { a.log.Debug( "Declared registration code for a new user. Enable SMTP in the config to disable this message", zap.String("username", user.Username), zap.String("code", generatedCode)) } if err = helper.Commit(); err != nil { a.log.Error( "Failed to commit transaction", zap.Error(err)) return false, errs.ErrServerError } return true, nil } func (a *authServiceImpl) RegistrationComplete(request models.RegistrationCompleteRequest) (*models.RegistrationCompleteResponse, error) { var user database.User var profile database.Profile var session database.Session var confirmationCode database.ConfirmationCode var accessToken, refreshToken string var err error helper, db, err := database.NewDbHelperTransaction(a.dbctx) if err != nil { a.log.Error( "Failed to open a transaction", zap.Error(err)) return nil, errs.ErrServerError } defer helper.RollbackOnError(err) user, err = db.TXQueries.GetUserByUsername(db.CTX, request.Username) if err != nil { if errors.Is(err, pgx.ErrNoRows) { a.log.Warn( "Could not find user attempting to complete registration with given username", zap.String("username", request.Username), zap.Error(err)) return nil, errs.ErrUserNotFound } a.log.Error( "Failed to get user", zap.String("username", request.Username), zap.Error(err)) return nil, errs.ErrServerError } confirmationCode, err = db.TXQueries.GetValidConfirmationCodeByCode(db.CTX, database.GetValidConfirmationCodeByCodeParams{ UserID: user.ID, CodeType: int32(enums.RegistrationCodeType), Code: request.VerificationCode, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { a.log.Warn( "User supplied unexistent confirmation code for completing registration", zap.String("username", user.Username), zap.String("code", request.VerificationCode), zap.Error(err)) return nil, errs.ErrForbidden } a.log.Error( "Failed to acquire specified registration code", zap.String("username", user.Username), zap.Error(err)) return nil, errs.ErrServerError } err = db.TXQueries.UpdateConfirmationCode(db.CTX, database.UpdateConfirmationCodeParams{ ID: confirmationCode.ID, Used: utils.NewPointer(true), }) if err != nil { a.log.Error( "Failed to update the user's registration code used state", zap.String("username", user.Username), zap.Int64("confirmation_code_id", confirmationCode.ID), zap.Error(err)) return nil, errs.ErrServerError } err = db.TXQueries.UpdateUser(db.CTX, database.UpdateUserParams{ ID: user.ID, Verified: utils.NewPointer(true), }) if err != nil { a.log.Error("Failed to update verified status for user", zap.String("username", user.Username), zap.Error(err)) return nil, errs.ErrServerError } profile, err = db.TXQueries.CreateProfile(db.CTX, database.CreateProfileParams{ UserID: user.ID, Name: request.Name, }) if err != nil { a.log.Error("Failed to create profile for user", zap.String("username", user.Username), ) return nil, errs.ErrServerError } _, err = db.TXQueries.CreateProfileSettings(db.CTX, profile.ID) if err != nil { a.log.Error("Failed to create profile settings for user", zap.String("username", user.Username), zap.Error(err)) return nil, errs.ErrServerError } // TODO: session info session, err = db.TXQueries.CreateSession(db.CTX, database.CreateSessionParams{ UserID: user.ID, Name: utils.NewPointer("First device"), Platform: utils.NewPointer("Unknown"), LatestIp: utils.NewPointer("Unknown"), }) if err != nil { a.log.Error( "Failed to create a new session during registration, rolling back registration", zap.String("username", user.Username), zap.Error(err)) return nil, errs.ErrServerError } accessToken, refreshToken, err = utils.GenerateTokens(user.Username, session.Guid.String()) if err != nil { a.log.Error( "Failed to create tokens for newly registered user, rolling back registration", zap.String("username", user.Username), zap.Error(err)) return nil, errs.ErrServerError } if err = helper.Commit(); err != nil { a.log.Error( "Failed to commit transaction", zap.Error(err)) return nil, errs.ErrServerError } a.log.Info( "User verified registration", zap.String("username", request.Username)) response := models.RegistrationCompleteResponse{Tokens: models.Tokens{ AccessToken: accessToken, RefreshToken: refreshToken, }} return &response, nil } // TODO: totp func (a *authServiceImpl) Login(request models.LoginRequest) (*models.LoginResponse, error) { var userRow database.GetValidUserByLoginCredentialsRow var session database.Session var err error helper, db, err := database.NewDbHelperTransaction(a.dbctx) if err != nil { a.log.Error( "Failed to open a transaction", zap.Error(err)) return nil, errs.ErrServerError } defer helper.RollbackOnError(err) userRow, err = db.TXQueries.GetValidUserByLoginCredentials(db.CTX, database.GetValidUserByLoginCredentialsParams{ Username: request.Username, Password: request.Password, }) if err != nil { a.log.Warn( "Failed login attempt", zap.Error(err)) var returnedError error switch err { case pgx.ErrNoRows: returnedError = errs.ErrForbidden default: returnedError = errs.ErrServerError } return nil, returnedError } // Until release 4, only 1 session at a time is supported if err = db.TXQueries.TerminateAllSessionsForUserByUsername(db.CTX, request.Username); err != nil { a.log.Error( "Failed to terminate older sessions for user trying to log in", zap.String("username", request.Username), zap.Error(err)) return nil, errs.ErrServerError } session, err = db.TXQueries.CreateSession(db.CTX, database.CreateSessionParams{ // TODO: use actual values for session metadata UserID: userRow.ID, Name: utils.NewPointer("New device"), Platform: utils.NewPointer("Unknown"), LatestIp: utils.NewPointer("Unknown"), }) if err != nil { a.log.Error( "Failed to create session for a new login", zap.String("username", userRow.Username), zap.Error(err)) return nil, errs.ErrServerError } accessToken, refreshToken, err := utils.GenerateTokens(userRow.Username, session.Guid.String()) if err != nil { a.log.Error( "Failed to generate tokens for a new login", zap.String("username", userRow.Username), zap.Error(err)) return nil, errs.ErrServerError } if err = helper.Commit(); err != nil { a.log.Error( "Failed to commit transaction", zap.Error(err)) return nil, errs.ErrServerError } response := models.LoginResponse{Tokens: models.Tokens{ AccessToken: accessToken, RefreshToken: refreshToken, }} return &response, nil } func (a *authServiceImpl) Refresh(request models.RefreshRequest) (*models.RefreshResponse, error) { return nil, errs.ErrNotImplemented } func (a *authServiceImpl) PasswordResetBegin(request models.PasswordResetBeginRequest) (bool, error) { var user database.User var generatedCode, hashedCode string var err error helper, db, err := database.NewDbHelperTransaction(a.dbctx) if err != nil { a.log.Error( "Failed to open a transaction", zap.Error(err)) return false, errs.ErrServerError } defer helper.RollbackOnError(err) ctx := context.TODO() cooldownTimeUnix, redisErr := a.redis.Get(ctx, fmt.Sprintf("email::%s::reset_cooldown", request.Email)).Int64() if redisErr != nil && redisErr != redis.Nil { a.log.Error( "Failed to get reset_cooldown state for user", zap.String("email", request.Email), zap.Error(redisErr)) return false, errs.ErrServerError } if time.Now().Unix() < cooldownTimeUnix { a.log.Warn( "Attempted to request a new password reset code for email on active reset cooldown", zap.String("email", request.Email)) return false, errs.ErrTooManyRequests } if user, err = db.TXQueries.GetUserByEmail(db.CTX, request.Email); err != nil { if errors.Is(err, pgx.ErrNoRows) { // Enable cooldown for the email despite that account does not exist err := a.redis.Set( ctx, fmt.Sprintf("email::%s::reset_cooldown", request.Email), time.Now().Add(10*time.Minute), time.Duration(10*time.Minute), ).Err() if err != nil { a.log.Error( "Failed to set reset cooldown for email", zap.Error(err)) return false, err } a.log.Warn( "Requested password reset email for unexistent user", zap.String("email", request.Email)) return true, nil } a.log.Error( "Failed to retrieve user from database", zap.String("email", request.Email), zap.Error(err)) return false, errs.ErrServerError } generatedCode = uuid.New().String() if hashedCode, err = utils.HashPassword(generatedCode); err != nil { a.log.Error( "Failed to hash password reset code for user", zap.String("username", user.Username), zap.Error(err)) return false, errs.ErrServerError } if _, err = db.TXQueries.CreateConfirmationCode(db.CTX, database.CreateConfirmationCodeParams{ UserID: user.ID, CodeType: int32(enums.PasswordResetCodeType), CodeHash: hashedCode, }); err != nil { a.log.Error( "Failed to save user password reset code to the database", zap.String("username", user.Username), zap.Error(err)) } err = a.redis.Set( ctx, fmt.Sprintf("email::%s::reset_cooldown", request.Email), time.Now().Add(10*time.Minute), time.Duration(10*time.Minute), ).Err() if err != nil { a.log.Error( "Failed to set reset cooldown for email. Cancelling password reset", zap.Error(err)) return false, err } if err = helper.Commit(); err != nil { a.log.Error( "Failed to commit transaction", zap.Error(err)) return false, errs.ErrServerError } return true, nil } func (a *authServiceImpl) PasswordResetComplete(request models.PasswordResetCompleteRequest) (*models.PasswordResetCompleteResponse, error) { var resetCode database.ConfirmationCode var user database.User var session database.Session var hashedPassword, accessToken, refreshToken string var err error helper, db, err := database.NewDbHelperTransaction(a.dbctx) if err != nil { a.log.Error( "Failed to open a transaction", zap.Error(err)) return nil, errs.ErrServerError } if user, err = db.TXQueries.GetUserByEmail(db.CTX, request.Email); err != nil { if errors.Is(err, pgx.ErrNoRows) { a.log.Warn( "Attempted to complete password reset for unregistered email", zap.String("email", request.Email), zap.Error(err)) return nil, errs.ErrForbidden } a.log.Error( "Failed to look up user of email while trying to complete password reset", zap.String("email", request.Email), zap.Error(err)) return nil, errs.ErrServerError } if resetCode, err = db.TXQueries.GetValidConfirmationCodeByCode(db.CTX, database.GetValidConfirmationCodeByCodeParams{ UserID: user.ID, CodeType: int32(enums.PasswordResetCodeType), Code: request.VerificationCode, }); err != nil { if errors.Is(err, pgx.ErrNoRows) { a.log.Warn( "Attempted to reset password for user using incorrect confirmation code", zap.String("email", request.Email), zap.String("username", user.Username), zap.String("provided_code", request.VerificationCode), zap.Error(err)) return nil, errs.ErrForbidden } } if err = db.TXQueries.UpdateConfirmationCode(db.CTX, database.UpdateConfirmationCodeParams{ ID: resetCode.ID, Used: utils.NewPointer(true), }); err != nil { a.log.Error( "Failed to invalidate password reset code upon use", zap.String("username", user.Username), zap.String("email", request.Email), zap.Int64("code_id", resetCode.ID), zap.Error(err)) return nil, errs.ErrServerError } if hashedPassword, err = utils.HashPassword(request.NewPassword); err != nil { a.log.Error( "Failed to hash new password as part of user password reset", zap.String("email", request.Email), zap.String("username", user.Username), zap.Error(err)) return nil, errs.ErrServerError } if err = db.TXQueries.UpdateLoginInformationByUsername(db.CTX, database.UpdateLoginInformationByUsernameParams{ Username: user.Username, PasswordHash: hashedPassword, }); err != nil { a.log.Error( "Failed to save new password to database as part of user password reset", zap.String("username", user.Username), zap.String("email", request.Email), zap.Error(err)) } if request.LogOutSessions { if err = db.TXQueries.TerminateAllSessionsForUserByUsername(db.CTX, user.Username); err != nil { a.log.Error( "Failed to log out older sessions as part of user password reset", zap.String("email", request.Email), zap.String("username", user.Username), zap.Error(err)) return nil, errs.ErrServerError } } if session, err = db.TXQueries.CreateSession(db.CTX, database.CreateSessionParams{ UserID: user.ID, Name: utils.NewPointer("First device"), Platform: utils.NewPointer("Unknown"), LatestIp: utils.NewPointer("Unknown"), }); err != nil { a.log.Error( "Failed to create new session for user as part of user password reset", zap.String("email", request.Email), zap.String("username", user.Username), zap.Error(err)) } if accessToken, refreshToken, err = utils.GenerateTokens(user.Username, session.Guid.String()); err != nil { a.log.Error( "Failed to generate tokens as part of user password reset", zap.String("email", request.Email), zap.String("username", user.Username), zap.Error(err)) return nil, errs.ErrServerError } response := models.PasswordResetCompleteResponse{ Tokens: models.Tokens{ AccessToken: accessToken, RefreshToken: refreshToken, }, } if err = helper.Commit(); err != nil { a.log.Error( "Failed to commit transaction", zap.Error(err)) return nil, errs.ErrServerError } return &response, nil }