package usersrv import ( "context" "errors" "log/slog" "time" "github.com/gofrs/uuid/v5" "github.com/olexsmir/onasty/internal/dtos" "github.com/olexsmir/onasty/internal/events/mailermq" "github.com/olexsmir/onasty/internal/hasher" "github.com/olexsmir/onasty/internal/jwtutil" "github.com/olexsmir/onasty/internal/models" "github.com/olexsmir/onasty/internal/oauth" "github.com/olexsmir/onasty/internal/store/psql/passwordtokrepo" "github.com/olexsmir/onasty/internal/store/psql/sessionrepo" "github.com/olexsmir/onasty/internal/store/psql/userepo" "github.com/olexsmir/onasty/internal/store/psql/vertokrepo" "github.com/olexsmir/onasty/internal/store/rdb/usercache" ) type UserServicer interface { SignUp(ctx context.Context, inp dtos.SignUp) (uuid.UUID, error) SignIn(ctx context.Context, inp dtos.SignIn) (dtos.Tokens, error) RefreshTokens(ctx context.Context, refreshToken string) (dtos.Tokens, error) Logout(ctx context.Context, userID uuid.UUID) error ChangePassword(ctx context.Context, userID uuid.UUID, inp dtos.ChangeUserPassword) error RequestPasswordReset(ctx context.Context, inp dtos.RequestResetPassword) error ResetPassword(ctx context.Context, inp dtos.ResetPassword) error GetOAuthURL(providerName string) (string, error) HandleOAuthLogin(ctx context.Context, providerName, code string) (dtos.Tokens, error) Verify(ctx context.Context, verificationKey string) error ResendVerificationEmail(ctx context.Context, credentials dtos.SignIn) error ParseJWTToken(token string) (jwtutil.Payload, error) CheckIfUserExists(ctx context.Context, userID uuid.UUID) (bool, error) CheckIfUserIsActivated(ctx context.Context, userID uuid.UUID) (bool, error) } var _ UserServicer = (*UserSrv)(nil) type UserSrv struct { userstore userepo.UserStorer sessionstore sessionrepo.SessionStorer vertokrepo vertokrepo.VerificationTokenStorer pwdtokrepo passwordtokrepo.PasswordResetTokenStorer cache usercache.UserCacheer hasher hasher.Hasher jwtTokenizer jwtutil.JWTTokenizer mailermq mailermq.Mailer googleOauth oauth.Provider githubOauth oauth.Provider refreshTokenTTL time.Duration verificationTokenTTL time.Duration resetPasswordTokenTTL time.Duration } func New( userstore userepo.UserStorer, sessionstore sessionrepo.SessionStorer, vertokrepo vertokrepo.VerificationTokenStorer, pwdtokrepo passwordtokrepo.PasswordResetTokenStorer, hasher hasher.Hasher, jwtTokenizer jwtutil.JWTTokenizer, mailermq mailermq.Mailer, cache usercache.UserCacheer, googleOauth, githubOauth oauth.Provider, refreshTokenTTL, verificationTokenTTL, resetPasswordTokenTTL time.Duration, ) *UserSrv { return &UserSrv{ userstore: userstore, sessionstore: sessionstore, vertokrepo: vertokrepo, pwdtokrepo: pwdtokrepo, cache: cache, hasher: hasher, jwtTokenizer: jwtTokenizer, mailermq: mailermq, googleOauth: googleOauth, githubOauth: githubOauth, refreshTokenTTL: refreshTokenTTL, verificationTokenTTL: verificationTokenTTL, resetPasswordTokenTTL: resetPasswordTokenTTL, } } func (u *UserSrv) SignUp(ctx context.Context, inp dtos.SignUp) (uuid.UUID, error) { user := models.User{ ID: uuid.Nil, // nil, because it does not get used here Email: inp.Email, Activated: false, Password: inp.Password, CreatedAt: inp.CreatedAt, LastLoginAt: inp.LastLoginAt, } if err := user.Validate(); err != nil { return uuid.Nil, err } hashedPassword, err := u.hasher.Hash(inp.Password) if err != nil { return uuid.UUID{}, err } user.Password = hashedPassword userID, err := u.userstore.Create(ctx, user) if err != nil { return uuid.Nil, err } verificationToken := uuid.Must(uuid.NewV4()).String() if err := u.vertokrepo.Create(ctx, models.VerificationToken{ UserID: userID, Token: verificationToken, CreatedAt: time.Now(), ExpiresAt: time.Now().Add(u.verificationTokenTTL), }); err != nil { return uuid.Nil, err } if err := u.mailermq.SendVerificationEmail(ctx, mailermq.SendVerificationEmailRequest{ Receiver: inp.Email, Token: verificationToken, }); err != nil { return uuid.Nil, err } return userID, nil } func (u *UserSrv) SignIn(ctx context.Context, inp dtos.SignIn) (dtos.Tokens, error) { user, err := u.userstore.GetByEmail(ctx, inp.Email) if err != nil { return dtos.Tokens{}, err } if err = u.hasher.Compare(user.Password, inp.Password); err != nil { if errors.Is(err, hasher.ErrMismatchedHashes) { return dtos.Tokens{}, models.ErrUserWrongCredentials } return dtos.Tokens{}, err } if !user.IsActivated() { return dtos.Tokens{}, models.ErrUserIsNotActivated } tokens, err := u.issueTokens(ctx, user.ID) return tokens, err } func (u *UserSrv) Logout(ctx context.Context, userID uuid.UUID) error { return u.sessionstore.Delete(ctx, userID) } func (u *UserSrv) RefreshTokens(ctx context.Context, rtoken string) (dtos.Tokens, error) { userID, err := u.sessionstore.GetUserIDByRefreshToken(ctx, rtoken) if err != nil { return dtos.Tokens{}, err } tokens, err := u.createTokens(userID) if err != nil { return dtos.Tokens{}, err } if err := u.sessionstore.Update(ctx, userID, rtoken, tokens.Refresh); err != nil { return dtos.Tokens{}, err } return dtos.Tokens{ Access: tokens.Access, Refresh: tokens.Refresh, }, nil } func (u *UserSrv) ChangePassword( ctx context.Context, userID uuid.UUID, inp dtos.ChangeUserPassword, ) error { // TODO: compare current password with providede, and assert on mismatch //nolint:exhaustruct if err := (models.User{Password: inp.NewPassword}).ValidatePassword(); err != nil { return err } oldPass, err := u.hasher.Hash(inp.CurrentPassword) if err != nil { return err } newPass, err := u.hasher.Hash(inp.NewPassword) if err != nil { return err } if err := u.userstore.ChangePassword(ctx, userID, oldPass, newPass); err != nil { return err } return nil } func (u *UserSrv) RequestPasswordReset(ctx context.Context, inp dtos.RequestResetPassword) error { user, err := u.userstore.GetByEmail(ctx, inp.Email) if err != nil { return err } token := uuid.Must(uuid.NewV4()).String() if err := u.pwdtokrepo.Create(ctx, models.ResetPasswordToken{ UserID: user.ID, Token: token, CreatedAt: time.Now(), ExpiresAt: time.Now().Add(u.resetPasswordTokenTTL), }); err != nil { return err } if err := u.mailermq.SendPasswordResetEmail(ctx, mailermq.SendPasswordResetEmailRequest{ Receiver: inp.Email, Token: token, }); err != nil { return err } return nil } func (u *UserSrv) ResetPassword(ctx context.Context, inp dtos.ResetPassword) error { //nolint:exhaustruct if err := (models.User{Password: inp.NewPassword}).ValidatePassword(); err != nil { return err } uid, err := u.pwdtokrepo.GetUserIDByTokenAndMarkAsUsed(ctx, inp.Token, time.Now()) if err != nil { return err } hashedPassword, err := u.hasher.Hash(inp.NewPassword) if err != nil { return err } return u.userstore.SetPassword(ctx, uid, hashedPassword) } func (u *UserSrv) Verify(ctx context.Context, verificationKey string) error { uid, err := u.vertokrepo.GetUserIDByTokenAndMarkAsUsed(ctx, verificationKey, time.Now()) if err != nil { return err } return u.userstore.MarkUserAsActivated(ctx, uid) } func (u *UserSrv) ResendVerificationEmail(ctx context.Context, inp dtos.SignIn) error { user, err := u.userstore.GetByEmail(ctx, inp.Email) if err != nil { return err } if err = u.hasher.Compare(user.Password, inp.Password); err != nil { return models.ErrUserWrongCredentials } if user.Activated { return models.ErrUserIsAlreadyVerified } token, err := u.vertokrepo.GetTokenOrUpdateTokenByUserID( ctx, user.ID, uuid.Must(uuid.NewV4()).String(), time.Now().Add(u.verificationTokenTTL)) if err != nil { return err } if err := u.mailermq.SendVerificationEmail(ctx, mailermq.SendVerificationEmailRequest{ Receiver: inp.Email, Token: token, }); err != nil { return err } return nil } func (u *UserSrv) ParseJWTToken(token string) (jwtutil.Payload, error) { return u.jwtTokenizer.Parse(token) } func (u UserSrv) CheckIfUserExists(ctx context.Context, id uuid.UUID) (bool, error) { r, err := u.cache.GetIsExists(ctx, id.String()) if err == nil { return r, nil } slog.ErrorContext(ctx, "usercache", "err", err) isExists, err := u.userstore.CheckIfUserExists(ctx, id) if err != nil { return false, err } if err := u.cache.SetIsExists(ctx, id.String(), isExists); err != nil { slog.ErrorContext(ctx, "usercache", "err", err) } return isExists, nil } func (u *UserSrv) CheckIfUserIsActivated(ctx context.Context, userID uuid.UUID) (bool, error) { r, err := u.cache.GetIsActivated(ctx, userID.String()) if err == nil { return r, nil } slog.ErrorContext(ctx, "usercache", "err", err) isActivated, err := u.userstore.CheckIfUserIsActivated(ctx, userID) if err != nil { return false, err } if err := u.cache.SetIsActivated(ctx, userID.String(), isActivated); err != nil { slog.ErrorContext(ctx, "usercache", "err", err) } return isActivated, nil } func (u UserSrv) createTokens(userID uuid.UUID) (dtos.Tokens, error) { accessToken, err := u.jwtTokenizer.AccessToken(jwtutil.Payload{UserID: userID.String()}) if err != nil { return dtos.Tokens{}, err } refreshToken, err := u.jwtTokenizer.RefreshToken() if err != nil { return dtos.Tokens{}, err } return dtos.Tokens{ Access: accessToken, Refresh: refreshToken, }, err } func (u UserSrv) issueTokens(ctx context.Context, userID uuid.UUID) (dtos.Tokens, error) { toks, err := u.createTokens(userID) if err != nil { return dtos.Tokens{}, err } if err := u.sessionstore.Set(ctx, userID, toks.Refresh, time.Now().Add(u.refreshTokenTTL)); err != nil { return dtos.Tokens{}, err } return toks, nil }