all repos

onasty @ 3b5e67f9c8eb6a915c65d5a30d0fffe901227f2e

a one-time notes service
12 files changed, 546 insertions(+), 423 deletions(-)
refactor(api): split `usersrv` responsibilities (#195)

* refactor: extract all auth logic into auth service

* refactor(authsrv): make cache logic more straight-forward

* fix: grammar

* refactor(api): extract user handlers to separate file

* refactor(apiv1): update formatting of couldBeAuthorizedMiddleware

* Back out "refactor(apiv1): update formatting of couldBeAuthorizedMiddleware"

This backs out commit 87d7b3c661a3f6f50cf4dbc787d971b5735cdf07.

* fix(authsrv): log error if cache error'ed
Author: Olexandr Smirnov olexsmir@gmail.com
Committed by: GitHub noreply@github.com
Committed at: 2025-08-27 14:17:35 +0300
Parent: f469c12
M cmd/api/main.go

@@ -18,6 +18,7 @@ "github.com/olexsmir/onasty/internal/jwtutil"

"github.com/olexsmir/onasty/internal/logger" "github.com/olexsmir/onasty/internal/metrics" "github.com/olexsmir/onasty/internal/oauth" + "github.com/olexsmir/onasty/internal/service/authsrv" "github.com/olexsmir/onasty/internal/service/notesrv" "github.com/olexsmir/onasty/internal/service/usersrv" "github.com/olexsmir/onasty/internal/store/psql/changeemailrepo"

@@ -105,21 +106,29 @@ userepo := userepo.New(psqlDB)

usercache := usercache.New(redisDB, cfg.CacheUsersTTL) usersrv := usersrv.New( userepo, - sessionrepo, vertokrepo, pwdtokrepo, changeemailrepo, noterepo, userPasswordHasher, + mailermq, + cfg.VerificationTokenTTL, + cfg.ResetPasswordTokenTTL, + cfg.ChangeEmailTokenTTL, + ) + + authsrv := authsrv.New( + userepo, + sessionrepo, + vertokrepo, + usercache, + userPasswordHasher, jwtTokenizer, mailermq, - usercache, googleOauth, githubOauth, cfg.JwtRefreshTokenTTL, cfg.VerificationTokenTTL, - cfg.ResetPasswordTokenTTL, - cfg.ChangeEmailTokenTTL, ) rateLimiterConfig := ratelimit.Config{

@@ -135,6 +144,7 @@ Burst: cfg.SlowRateLimiterBurst,

} handler := httptransport.NewTransport( + authsrv, usersrv, notesrv, cfg.AppEnv,
M e2e/e2e_test.go

@@ -15,6 +15,7 @@ "github.com/olexsmir/onasty/internal/config"

"github.com/olexsmir/onasty/internal/hasher" "github.com/olexsmir/onasty/internal/jwtutil" "github.com/olexsmir/onasty/internal/logger" + "github.com/olexsmir/onasty/internal/service/authsrv" "github.com/olexsmir/onasty/internal/service/notesrv" "github.com/olexsmir/onasty/internal/service/usersrv" "github.com/olexsmir/onasty/internal/store/psql/changeemailrepo"

@@ -106,6 +107,7 @@ pwdtokrepo := passwordtokrepo.NewPasswordResetTokenRepo(e.postgresDB)

changeemailrepo := changeemailrepo.New(e.postgresDB) stubOAuthProvider := newOauthProviderStub() + mailerMockService := newMailerMockService() notecache := notecache.New(e.redisDB, cfg.CacheUsersTTL) noterepo := noterepo.New(e.postgresDB)

@@ -115,21 +117,29 @@ userepo := userepo.New(e.postgresDB)

usercache := usercache.New(e.redisDB, cfg.CacheUsersTTL) usersrv := usersrv.New( userepo, - sessionrepo, vertokrepo, pwdtokrepo, changeemailrepo, noterepo, e.hasher, - e.jwtTokenizer, - newMailerMockService(), + mailerMockService, + cfg.VerificationTokenTTL, + cfg.ResetPasswordTokenTTL, + cfg.ChangeEmailTokenTTL, + ) + + authsrv := authsrv.New( + userepo, + sessionrepo, + vertokrepo, usercache, + e.hasher, + e.jwtTokenizer, + mailerMockService, stubOAuthProvider, stubOAuthProvider, cfg.JwtRefreshTokenTTL, cfg.VerificationTokenTTL, - cfg.ResetPasswordTokenTTL, - cfg.ChangeEmailTokenTTL, ) // for testing purposes, it's ok to have high values ig

@@ -140,6 +150,7 @@ Burst: 1000,

} handler := httptransport.NewTransport( + authsrv, usersrv, notesrv, cfg.AppEnv,
A internal/service/authsrv/authsrv.go

@@ -0,0 +1,251 @@

+package authsrv + +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/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 AuthServicer interface { + // SignUp creates a new user and sends a verification email. + // + // Uses [models.User.Validate] to validate credentials (see more possible returned errors). + // + // If provided email already in use returns [models.ErrUserEmailIsAlreadyInUse]. + // + SignUp(ctx context.Context, credentials dtos.SignUp) error + + // SignIn authenticates a user and returns access and refresh tokens. + // + // If user not found returns [models.ErrUserNotFound], and if credentials don't match [models.ErrUserWrongCredentials] + // + // If inactivated user tries to login, returns [models.ErrUserIsNotActivated] + // + SignIn(ctx context.Context, credentials dtos.SignIn) (dtos.Tokens, error) + + // RefreshTokens refreshes the access and refresh tokens using the provided refresh token. + // + // If couldn't find a user liked with token, returns [models.ErrUserNotFound] + // + RefreshTokens(ctx context.Context, refreshToken string) (dtos.Tokens, error) + + // Logout logs out a user by deleting the session associated with the provided refresh token. + Logout(ctx context.Context, userID uuid.UUID, refreshToken string) error + + // LogoutAll logs out a user by deleting all sessions associated with the user ID. + LogoutAll(ctx context.Context, userID uuid.UUID) error + + // GetOAuthURL retrieves the OAuth URL for the specified provider. + // + // If [providerName] is incorrect returns [ErrProviderNotSupported] + // + GetOAuthURL(providerName string) (dtos.OAuthRedirect, error) + + // HandleOAuthLogin handles the OAuth login process by exchanging the code for tokens. + // + HandleOAuthLogin(ctx context.Context, providerName, code string) (dtos.Tokens, error) + + // ParseJWTToken parses the JWT token and returns the payload. + // + // If token is expired, returns [jwtutil.ErrTokenExpired], + // + // If token is invalid returns: [jwturil.ErrTokenSignatureInvalid], [jwt.ErrUnexpectedSigningMethod] + // + ParseJWTToken(token string) (jwtutil.Payload, error) + + // CheckIfUserExists checks if a user exists by user ID. + CheckIfUserExists(ctx context.Context, userID uuid.UUID) (bool, error) + + // CheckIfUserIsActivated checks if a user is activated by user ID. + CheckIfUserIsActivated(ctx context.Context, userID uuid.UUID) (bool, error) +} + +var _ AuthServicer = (*AuthSrv)(nil) + +type AuthSrv struct { + userstore userepo.UserStorer + sessionstore sessionrepo.SessionStorer + vertokrepo vertokrepo.VerificationTokenStorer + cache usercache.UserCacheer + + hasher hasher.Hasher + jwtTokenizer jwtutil.JWTTokenizer + mailermq mailermq.Mailer + + googleOauth oauth.Provider + githubOauth oauth.Provider + + refreshTokenTTL time.Duration + verificationTokenTTL time.Duration +} + +func New( + userstore userepo.UserStorer, + sessionstore sessionrepo.SessionStorer, + vertokrepo vertokrepo.VerificationTokenStorer, + cache usercache.UserCacheer, + hasher hasher.Hasher, + jwtTokenizer jwtutil.JWTTokenizer, + mailermq mailermq.Mailer, + googleOauth, githubOauth oauth.Provider, + refreshTokenTTL, verificationTokenTTL time.Duration, +) *AuthSrv { + return &AuthSrv{ + userstore: userstore, + sessionstore: sessionstore, + vertokrepo: vertokrepo, + cache: cache, + hasher: hasher, + jwtTokenizer: jwtTokenizer, + mailermq: mailermq, + googleOauth: googleOauth, + githubOauth: githubOauth, + refreshTokenTTL: refreshTokenTTL, + verificationTokenTTL: verificationTokenTTL, + } +} + +func (a *AuthSrv) SignUp(ctx context.Context, inp dtos.SignUp) error { + user := models.User{ + ID: uuid.Nil, // nil, since we do not know it yet + Email: inp.Email, + Activated: false, + Password: inp.Password, + CreatedAt: inp.CreatedAt, + LastLoginAt: inp.LastLoginAt, + } + if err := user.Validate(); err != nil { + return err + } + + hashedPassword, err := a.hasher.Hash(inp.Password) + if err != nil { + return err + } + + user.Password = hashedPassword + + userID, err := a.userstore.Create(ctx, user) + if err != nil { + return err + } + + verificationToken := uuid.Must(uuid.NewV4()).String() + if err := a.vertokrepo.Create(ctx, models.VerificationToken{ + UserID: userID, + Token: verificationToken, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(a.verificationTokenTTL), + }); err != nil { + return err + } + + if err := a.mailermq.SendVerificationEmail(ctx, mailermq.SendVerificationEmailRequest{ + Receiver: inp.Email, + Token: verificationToken, + }); err != nil { + return err + } + + return nil +} + +func (a *AuthSrv) SignIn(ctx context.Context, inp dtos.SignIn) (dtos.Tokens, error) { + user, err := a.userstore.GetByEmail(ctx, inp.Email) + if err != nil { + return dtos.Tokens{}, err + } + + if err = a.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 + } + + return a.issueTokens(ctx, user.ID) +} + +func (a *AuthSrv) RefreshTokens(ctx context.Context, rtoken string) (dtos.Tokens, error) { + userID, err := a.sessionstore.GetUserIDByRefreshToken(ctx, rtoken) + if err != nil { + return dtos.Tokens{}, err + } + + tokens, err := a.createTokens(userID) + if err != nil { + return dtos.Tokens{}, err + } + + if err := a.sessionstore.Update(ctx, userID, rtoken, tokens.Refresh); err != nil { + return dtos.Tokens{}, err + } + + return dtos.Tokens{ + Access: tokens.Access, + Refresh: tokens.Refresh, + }, nil +} + +func (a *AuthSrv) Logout(ctx context.Context, userID uuid.UUID, refreshToken string) error { + return a.sessionstore.Delete(ctx, userID, refreshToken) +} + +func (a *AuthSrv) LogoutAll(ctx context.Context, userID uuid.UUID) error { + return a.sessionstore.DeleteAllByUserID(ctx, userID) +} + +func (a *AuthSrv) CheckIfUserExists(ctx context.Context, uid uuid.UUID) (bool, error) { + isExists, err := a.cache.GetIsExists(ctx, uid.String()) + if err == nil { + return isExists, nil + } + slog.ErrorContext(ctx, "failed to fetch 'is user exists' cache", "err", err) + + isExists, err = a.userstore.CheckIfUserExists(ctx, uid) + if err != nil { + return false, err + } + + if err := a.cache.SetIsExists(ctx, uid.String(), isExists); err != nil { + slog.ErrorContext(ctx, "failed to update 'is user exists' cache", "err", err) + } + + return isExists, nil +} + +func (a *AuthSrv) CheckIfUserIsActivated(ctx context.Context, uid uuid.UUID) (bool, error) { + isActivated, err := a.cache.GetIsActivated(ctx, uid.String()) + if err == nil { + return isActivated, nil + } + slog.ErrorContext(ctx, "failed to fetch 'is user activated' cache", "err", err) + + isActivated, err = a.userstore.CheckIfUserIsActivated(ctx, uid) + if err != nil { + return false, err + } + + if err := a.cache.SetIsActivated(ctx, uid.String(), isActivated); err != nil { + slog.ErrorContext(ctx, "failed to update 'is user activated' cache", "err", err) + } + + return isActivated, nil +}
A internal/service/authsrv/jwt.go

@@ -0,0 +1,44 @@

+package authsrv + +import ( + "context" + "time" + + "github.com/gofrs/uuid/v5" + "github.com/olexsmir/onasty/internal/dtos" + "github.com/olexsmir/onasty/internal/jwtutil" +) + +func (a *AuthSrv) ParseJWTToken(token string) (jwtutil.Payload, error) { + return a.jwtTokenizer.Parse(token) +} + +func (a AuthSrv) issueTokens(ctx context.Context, userID uuid.UUID) (dtos.Tokens, error) { + toks, err := a.createTokens(userID) + if err != nil { + return dtos.Tokens{}, err + } + + if err := a.sessionstore.Set(ctx, userID, toks.Refresh, time.Now().Add(a.refreshTokenTTL)); err != nil { + return dtos.Tokens{}, err + } + + return toks, nil +} + +func (a AuthSrv) createTokens(userID uuid.UUID) (dtos.Tokens, error) { + accessToken, err := a.jwtTokenizer.AccessToken(jwtutil.Payload{UserID: userID.String()}) + if err != nil { + return dtos.Tokens{}, err + } + + refreshToken, err := a.jwtTokenizer.RefreshToken() + if err != nil { + return dtos.Tokens{}, err + } + + return dtos.Tokens{ + Access: accessToken, + Refresh: refreshToken, + }, err +}
M internal/service/authsrv/oauth.gointernal/service/authsrv/oauth.go

@@ -1,4 +1,4 @@

-package usersrv +package authsrv import ( "context"

@@ -19,18 +19,18 @@ googleProvider = "google"

githubProvider = "github" ) -func (u *UserSrv) GetOAuthURL(providerName string) (dtos.OAuthRedirect, error) { +func (a *AuthSrv) GetOAuthURL(providerName string) (dtos.OAuthRedirect, error) { state := uuid.Must(uuid.NewV4()).String() switch providerName { case googleProvider: return dtos.OAuthRedirect{ - URL: u.googleOauth.GetAuthURL(state), + URL: a.googleOauth.GetAuthURL(state), State: state, }, nil case githubProvider: return dtos.OAuthRedirect{ - URL: u.githubOauth.GetAuthURL(state), + URL: a.githubOauth.GetAuthURL(state), State: state, }, nil default:

@@ -38,31 +38,29 @@ return dtos.OAuthRedirect{}, ErrProviderNotSupported

} } -func (u *UserSrv) HandleOAuthLogin( +func (a *AuthSrv) HandleOAuthLogin( ctx context.Context, providerName, code string, ) (dtos.Tokens, error) { - userInfo, err := u.getUserInfoBasedOnProvider(ctx, providerName, code) + userInfo, err := a.getUserInfoBasedOnProvider(ctx, providerName, code) if err != nil { return dtos.Tokens{}, err } - userID, err := u.getUserByOAuthIDOrCreateOne(ctx, userInfo) + userID, err := a.getUserByOAuthIDOrCreateOne(ctx, userInfo) if err != nil { return dtos.Tokens{}, err } - if err = u.userstore.LinkOAuthIdentity(ctx, userID, userInfo.Provider, userInfo.ProviderID); err != nil { + if err = a.userstore.LinkOAuthIdentity(ctx, userID, userInfo.Provider, userInfo.ProviderID); err != nil { slog.ErrorContext(ctx, "failed to link user identity", "user_id", userID, "err", err) return dtos.Tokens{}, err } - tokens, err := u.issueTokens(ctx, userID) - - return tokens, err + return a.issueTokens(ctx, userID) } -func (u *UserSrv) getUserInfoBasedOnProvider( +func (a *AuthSrv) getUserInfoBasedOnProvider( ctx context.Context, providerName, code string, ) (oauth.UserInfo, error) {

@@ -71,9 +69,9 @@ var err error

switch providerName { case googleProvider: - userInfo, err = u.googleOauth.ExchangeCode(ctx, code) + userInfo, err = a.googleOauth.ExchangeCode(ctx, code) case githubProvider: - userInfo, err = u.githubOauth.ExchangeCode(ctx, code) + userInfo, err = a.githubOauth.ExchangeCode(ctx, code) default: return oauth.UserInfo{}, ErrProviderNotSupported }

@@ -81,14 +79,14 @@

return userInfo, err } -func (u *UserSrv) getUserByOAuthIDOrCreateOne( +func (a *AuthSrv) getUserByOAuthIDOrCreateOne( ctx context.Context, info oauth.UserInfo, ) (uuid.UUID, error) { - user, err := u.userstore.GetByOAuthID(ctx, info.Provider, info.ProviderID) + user, err := a.userstore.GetByOAuthID(ctx, info.Provider, info.ProviderID) if err != nil { if errors.Is(err, models.ErrUserNotFound) { - uid, cerr := u.userstore.Create(ctx, models.User{ + uid, cerr := a.userstore.Create(ctx, models.User{ ID: uuid.Nil, Email: info.Email, Activated: true,
M internal/service/usersrv/usersrv.go

@@ -2,42 +2,21 @@ 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/changeemailrepo" "github.com/olexsmir/onasty/internal/store/psql/noterepo" "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 creates a new user and sends verification email. - SignUp(ctx context.Context, inp dtos.SignUp) (uuid.UUID, error) - - // SignIn authenticates a user and returns access and refresh tokens. - SignIn(ctx context.Context, inp dtos.SignIn) (dtos.Tokens, error) - - // RefreshTokens refreshes the access and refresh tokens using the provided refresh token. - RefreshTokens(ctx context.Context, refreshToken string) (dtos.Tokens, error) - - // Logout logs out a user by deleting the session associated with the provided refresh token. - Logout(ctx context.Context, userID uuid.UUID, refreshToken string) error - - // LogoutAll logs out a user by deleting all sessions associated with the user ID. - LogoutAll(ctx context.Context, userID uuid.UUID) error - // GetUserInfo retrieves user information by user ID. GetUserInfo(ctx context.Context, userID uuid.UUID) (dtos.UserInfo, error)

@@ -54,47 +33,25 @@ RequestEmailChange(ctx context.Context, userID uuid.UUID, inp dtos.ChangeEmail) error

ChangeEmail(ctx context.Context, token string) error - // GetOAuthURL retrieves the OAuth URL for the specified provider. - GetOAuthURL(providerName string) (dtos.OAuthRedirect, error) - - // HandleOAuthLogin handles the OAuth login process by exchanging the code for tokens. - HandleOAuthLogin(ctx context.Context, providerName, code string) (dtos.Tokens, error) - // Verify verifies the user's email using the provided verification key. Verify(ctx context.Context, verificationKey string) error // ResendVerificationEmail resends the verification email to the user. ResendVerificationEmail(ctx context.Context, inp dtos.ResendVerificationEmail) error - - // ParseJWTToken parses the JWT token and returns the payload. - ParseJWTToken(token string) (jwtutil.Payload, error) - - // CheckIfUserExists checks if a user exists by user ID. - CheckIfUserExists(ctx context.Context, userID uuid.UUID) (bool, error) - - // CheckIfUserIsActivated checks if a user is activated by user ID. - 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 changeemailrepo changeemailrepo.ChangeEmailStorer notestore noterepo.NoteStorer - cache usercache.UserCacheer - hasher hasher.Hasher - jwtTokenizer jwtutil.JWTTokenizer - mailermq mailermq.Mailer - - googleOauth oauth.Provider - githubOauth oauth.Provider + hasher hasher.Hasher + mailermq mailermq.Mailer - refreshTokenTTL time.Duration verificationTokenTTL time.Duration resetPasswordTokenTTL time.Duration changeEmailTokenTTL time.Duration

@@ -102,112 +59,28 @@ }

func New( userstore userepo.UserStorer, - sessionstore sessionrepo.SessionStorer, vertokrepo vertokrepo.VerificationTokenStorer, pwdtokrepo passwordtokrepo.PasswordResetTokenStorer, changeemailrepo changeemailrepo.ChangeEmailStorer, notestore noterepo.NoteStorer, hasher hasher.Hasher, - jwtTokenizer jwtutil.JWTTokenizer, mailermq mailermq.Mailer, - cache usercache.UserCacheer, - googleOauth, githubOauth oauth.Provider, - refreshTokenTTL, verificationTokenTTL, resetPasswordTokenTTL, changeEmailTokenTTL time.Duration, + verificationTokenTTL, resetPasswordTokenTTL, changeEmailTokenTTL time.Duration, ) *UserSrv { return &UserSrv{ userstore: userstore, - sessionstore: sessionstore, vertokrepo: vertokrepo, pwdtokrepo: pwdtokrepo, changeemailrepo: changeemailrepo, notestore: notestore, - cache: cache, hasher: hasher, - jwtTokenizer: jwtTokenizer, mailermq: mailermq, - googleOauth: googleOauth, - githubOauth: githubOauth, - refreshTokenTTL: refreshTokenTTL, verificationTokenTTL: verificationTokenTTL, resetPasswordTokenTTL: resetPasswordTokenTTL, changeEmailTokenTTL: changeEmailTokenTTL, } } -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, refreshToken string) error { - return u.sessionstore.Delete(ctx, userID, refreshToken) -} - -func (u *UserSrv) LogoutAll(ctx context.Context, userID uuid.UUID) error { - return u.sessionstore.DeleteAllByUserID(ctx, userID) -} - func (u *UserSrv) GetUserInfo(ctx context.Context, userID uuid.UUID) (dtos.UserInfo, error) { user, err := u.userstore.GetByID(ctx, userID) if err != nil {

@@ -224,27 +97,6 @@ Email: user.Email,

CreatedAt: user.CreatedAt, LastLoginAt: user.LastLoginAt, NotesCreated: int(count), - }, nil -} - -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 }

@@ -431,77 +283,3 @@ }

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 -}
M internal/transport/http/apiv1/apiv1.go

@@ -3,12 +3,14 @@

import ( "github.com/gin-gonic/gin" "github.com/olexsmir/onasty/internal/config" + "github.com/olexsmir/onasty/internal/service/authsrv" "github.com/olexsmir/onasty/internal/service/notesrv" "github.com/olexsmir/onasty/internal/service/usersrv" "github.com/olexsmir/onasty/internal/transport/http/ratelimit" ) type APIV1 struct { + authsrv authsrv.AuthServicer usersrv usersrv.UserServicer notesrv notesrv.NoteServicer slowRatelimitCfg ratelimit.Config

@@ -17,6 +19,7 @@ domain string

} func NewAPIV1( + as authsrv.AuthServicer, us usersrv.UserServicer, ns notesrv.NoteServicer, slowRatelimitCfg ratelimit.Config,

@@ -24,6 +27,7 @@ env config.Environment,

domain string, ) *APIV1 { return &APIV1{ + authsrv: as, usersrv: us, notesrv: ns, slowRatelimitCfg: slowRatelimitCfg,
M internal/transport/http/apiv1/auth.go

@@ -20,7 +20,7 @@ newError(c, http.StatusBadRequest, "invalid request")

return } - if _, err := a.usersrv.SignUp(c.Request.Context(), dtos.SignUp{ + if err := a.authsrv.SignUp(c.Request.Context(), dtos.SignUp{ Email: req.Email, Password: req.Password, CreatedAt: time.Now(),

@@ -50,7 +50,7 @@ newError(c, http.StatusBadRequest, "invalid request")

return } - toks, err := a.usersrv.SignIn(c.Request.Context(), dtos.SignIn{ + toks, err := a.authsrv.SignIn(c.Request.Context(), dtos.SignIn{ Email: req.Email, Password: req.Password, })

@@ -76,7 +76,7 @@ newError(c, http.StatusBadRequest, "invalid request")

return } - toks, err := a.usersrv.RefreshTokens(c.Request.Context(), req.RefreshToken) + toks, err := a.authsrv.RefreshTokens(c.Request.Context(), req.RefreshToken) if err != nil { errorResponse(c, err) return

@@ -88,84 +88,6 @@ RefreshToken: toks.Refresh,

}) } -func (a *APIV1) verifyHandler(c *gin.Context) { - if err := a.usersrv.Verify(c.Request.Context(), c.Param("token")); err != nil { - errorResponse(c, err) - return - } - - c.String(http.StatusOK, "email verified") -} - -type resendVerificationEmailRequest struct { - Email string `json:"email"` -} - -func (a *APIV1) resendVerificationEmailHandler(c *gin.Context) { - var req resendVerificationEmailRequest - if err := c.ShouldBindJSON(&req); err != nil { - newError(c, http.StatusBadRequest, "invalid request") - return - } - - if err := a.usersrv.ResendVerificationEmail( - c.Request.Context(), - dtos.ResendVerificationEmail{ - Email: req.Email, - }); err != nil { - errorResponse(c, err) - return - } - - c.Status(http.StatusOK) -} - -type requestResetPasswordRequest struct { - Email string `json:"email"` -} - -func (a *APIV1) requestResetPasswordHandler(c *gin.Context) { - var req requestResetPasswordRequest - if err := c.ShouldBindJSON(&req); err != nil { - newError(c, http.StatusBadRequest, "invalid request") - return - } - - if err := a.usersrv.RequestPasswordReset(c.Request.Context(), dtos.RequestResetPassword{ - Email: req.Email, - }); err != nil { - errorResponse(c, err) - return - } - - c.Status(http.StatusOK) -} - -type resetPasswordRequest struct { - Password string `json:"password"` -} - -func (a *APIV1) resetPasswordHandler(c *gin.Context) { - var req resetPasswordRequest - if err := c.ShouldBindJSON(&req); err != nil { - newError(c, http.StatusBadRequest, "invalid request") - return - } - - if err := a.usersrv.ResetPassword( - c.Request.Context(), - dtos.ResetPassword{ - Token: c.Param("token"), - NewPassword: req.Password, - }, - ); err != nil { - errorResponse(c, err) - return - } - - c.Status(http.StatusOK) -} - type logoutRequest struct { RefreshToken string `json:"refresh_token"` }

@@ -177,7 +99,11 @@ newError(c, http.StatusBadRequest, "invalid request")

return } - if err := a.usersrv.Logout(c.Request.Context(), a.getUserID(c), req.RefreshToken); err != nil { + if err := a.authsrv.Logout( + c.Request.Context(), + a.getUserID(c), + req.RefreshToken, + ); err != nil { errorResponse(c, err) return }

@@ -186,7 +112,7 @@ c.Status(http.StatusNoContent)

} func (a *APIV1) logOutAllHandler(c *gin.Context) { - if err := a.usersrv.LogoutAll(c.Request.Context(), a.getUserID(c)); err != nil { + if err := a.authsrv.LogoutAll(c.Request.Context(), a.getUserID(c)); err != nil { errorResponse(c, err) return }

@@ -194,69 +120,10 @@

c.Status(http.StatusNoContent) } -type changePasswordRequest struct { - CurrentPassword string `json:"current_password"` - NewPassword string `json:"new_password"` -} - -func (a *APIV1) changePasswordHandler(c *gin.Context) { - var req changePasswordRequest - if err := c.ShouldBindJSON(&req); err != nil { - newError(c, http.StatusBadRequest, "invalid request") - return - } - - if err := a.usersrv.ChangePassword( - c.Request.Context(), - a.getUserID(c), - dtos.ChangeUserPassword{ - CurrentPassword: req.CurrentPassword, - NewPassword: req.NewPassword, - }); err != nil { - errorResponse(c, err) - return - } - - c.Status(http.StatusOK) -} - -type changeEmailRequest struct { - NewEmail string `json:"new_email"` -} - -func (a *APIV1) requestEmailChangeHandler(c *gin.Context) { - var req changeEmailRequest - if err := c.ShouldBindJSON(&req); err != nil { - newError(c, http.StatusBadRequest, "invalid request") - return - } - - if err := a.usersrv.RequestEmailChange( - c.Request.Context(), - a.getUserID(c), - dtos.ChangeEmail{ - NewEmail: req.NewEmail, - }); err != nil { - errorResponse(c, err) - return - } - - c.Status(http.StatusOK) -} - -func (a *APIV1) changeEmailHandler(c *gin.Context) { - if err := a.usersrv.ChangeEmail(c.Request.Context(), c.Param("token")); err != nil { - errorResponse(c, err) - return - } - - c.String(http.StatusOK, "email changed") -} - const oatuhStateCookie = "oauth_state" func (a *APIV1) oauthLoginHandler(c *gin.Context) { - redirectInfo, err := a.usersrv.GetOAuthURL(c.Param("provider")) + redirectInfo, err := a.authsrv.GetOAuthURL(c.Param("provider")) if err != nil { errorResponse(c, err) return

@@ -283,7 +150,7 @@ newError(c, http.StatusBadRequest, "invalid oauth state")

return } - tokens, err := a.usersrv.HandleOAuthLogin( + tokens, err := a.authsrv.HandleOAuthLogin( c.Request.Context(), c.Param("provider"), c.Query("code"),

@@ -298,25 +165,3 @@ AccessToken: tokens.Access,

RefreshToken: tokens.Refresh, }) } - -type getMeResponse struct { - Email string `json:"email"` - CreatedAt time.Time `json:"created_at"` - LastLoginAt time.Time `json:"last_login_at"` - NotesCreated int `json:"notes_created"` -} - -func (a *APIV1) getMeHandler(c *gin.Context) { - uinfo, err := a.usersrv.GetUserInfo(c.Request.Context(), a.getUserID(c)) - if err != nil { - errorResponse(c, err) - return - } - - c.JSON(http.StatusOK, getMeResponse{ - Email: uinfo.Email, - CreatedAt: uinfo.CreatedAt, - LastLoginAt: uinfo.LastLoginAt, - NotesCreated: uinfo.NotesCreated, - }) -}
M internal/transport/http/apiv1/middleware.go

@@ -90,6 +90,7 @@ }

// getUserId returns userId from the context // getting user id is only possible if user is authorized +// // if userID is not set, [uuid.Nil] will be returned. func (a *APIV1) getUserID(c *gin.Context) uuid.UUID { userID, exists := c.Get(userIDCtxKey)

@@ -106,14 +107,14 @@ return uid

} func (a *APIV1) validateAuthorizedUser(ctx context.Context, accessToken string) (uuid.UUID, error) { - tokenPayload, err := a.usersrv.ParseJWTToken(accessToken) + tokenPayload, err := a.authsrv.ParseJWTToken(accessToken) if err != nil { return uuid.Nil, err } userID := uuid.Must(uuid.FromString(tokenPayload.UserID)) - ok, err := a.usersrv.CheckIfUserExists(ctx, userID) + ok, err := a.authsrv.CheckIfUserExists(ctx, userID) if err != nil { return uuid.Nil, err }

@@ -122,7 +123,7 @@ if !ok {

return uuid.Nil, ErrUnauthorized } - ok, err = a.usersrv.CheckIfUserIsActivated(ctx, userID) + ok, err = a.authsrv.CheckIfUserIsActivated(ctx, userID) if err != nil { return uuid.Nil, err }
M internal/transport/http/apiv1/response.go

@@ -8,8 +8,8 @@

"github.com/gin-gonic/gin" "github.com/olexsmir/onasty/internal/jwtutil" "github.com/olexsmir/onasty/internal/models" + "github.com/olexsmir/onasty/internal/service/authsrv" "github.com/olexsmir/onasty/internal/service/notesrv" - "github.com/olexsmir/onasty/internal/service/usersrv" ) var ErrUnauthorized = errors.New("unauthorized")

@@ -19,7 +19,7 @@ Message string `json:"message"`

} func errorResponse(c *gin.Context, err error) { - if errors.Is(err, usersrv.ErrProviderNotSupported) || + if errors.Is(err, authsrv.ErrProviderNotSupported) || errors.Is(err, models.ErrResetPasswordTokenAlreadyUsed) || errors.Is(err, models.ErrResetPasswordTokenExpired) || errors.Is(err, models.ErrUserEmailIsAlreadyInUse) ||
A internal/transport/http/apiv1/user.go

@@ -0,0 +1,177 @@

+package apiv1 + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/olexsmir/onasty/internal/dtos" +) + +type getMeResponse struct { + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` + LastLoginAt time.Time `json:"last_login_at"` + NotesCreated int `json:"notes_created"` +} + +func (a *APIV1) getMeHandler(c *gin.Context) { + uinfo, err := a.usersrv.GetUserInfo(c.Request.Context(), a.getUserID(c)) + if err != nil { + errorResponse(c, err) + return + } + + c.JSON(http.StatusOK, getMeResponse{ + Email: uinfo.Email, + CreatedAt: uinfo.CreatedAt, + LastLoginAt: uinfo.LastLoginAt, + NotesCreated: uinfo.NotesCreated, + }) +} + +type changePasswordRequest struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` +} + +func (a *APIV1) changePasswordHandler(c *gin.Context) { + var req changePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + newError(c, http.StatusBadRequest, "invalid request") + return + } + + if err := a.usersrv.ChangePassword( + c.Request.Context(), + a.getUserID(c), + dtos.ChangeUserPassword{ + CurrentPassword: req.CurrentPassword, + NewPassword: req.NewPassword, + }); err != nil { + errorResponse(c, err) + return + } + + c.Status(http.StatusOK) +} + +type requestResetPasswordRequest struct { + Email string `json:"email"` +} + +func (a *APIV1) requestResetPasswordHandler(c *gin.Context) { + var req requestResetPasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + newError(c, http.StatusBadRequest, "invalid request") + return + } + + if err := a.usersrv.RequestPasswordReset( + c.Request.Context(), + dtos.RequestResetPassword{ + Email: req.Email, + }, + ); err != nil { + errorResponse(c, err) + return + } + + c.Status(http.StatusOK) +} + +type resetPasswordRequest struct { + Password string `json:"password"` +} + +func (a *APIV1) resetPasswordHandler(c *gin.Context) { + var req resetPasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + newError(c, http.StatusBadRequest, "invalid request") + return + } + + if err := a.usersrv.ResetPassword( + c.Request.Context(), + dtos.ResetPassword{ + Token: c.Param("token"), + NewPassword: req.Password, + }, + ); err != nil { + errorResponse(c, err) + return + } + + c.Status(http.StatusOK) +} + +type changeEmailRequest struct { + NewEmail string `json:"new_email"` +} + +func (a *APIV1) requestEmailChangeHandler(c *gin.Context) { + var req changeEmailRequest + if err := c.ShouldBindJSON(&req); err != nil { + newError(c, http.StatusBadRequest, "invalid request") + return + } + + if err := a.usersrv.RequestEmailChange( + c.Request.Context(), + a.getUserID(c), + dtos.ChangeEmail{ + NewEmail: req.NewEmail, + }); err != nil { + errorResponse(c, err) + return + } + + c.Status(http.StatusOK) +} + +func (a *APIV1) changeEmailHandler(c *gin.Context) { + if err := a.usersrv.ChangeEmail( + c.Request.Context(), + c.Param("token"), + ); err != nil { + errorResponse(c, err) + return + } + + c.String(http.StatusOK, "email changed") +} + +func (a *APIV1) verifyHandler(c *gin.Context) { + if err := a.usersrv.Verify( + c.Request.Context(), + c.Param("token"), + ); err != nil { + errorResponse(c, err) + return + } + + c.String(http.StatusOK, "email verified") +} + +type resendVerificationEmailRequest struct { + Email string `json:"email"` +} + +func (a *APIV1) resendVerificationEmailHandler(c *gin.Context) { + var req resendVerificationEmailRequest + if err := c.ShouldBindJSON(&req); err != nil { + newError(c, http.StatusBadRequest, "invalid request") + return + } + + if err := a.usersrv.ResendVerificationEmail( + c.Request.Context(), + dtos.ResendVerificationEmail{ + Email: req.Email, + }); err != nil { + errorResponse(c, err) + return + } + + c.Status(http.StatusOK) +}
M internal/transport/http/http.go

@@ -6,6 +6,7 @@ "time"

"github.com/gin-gonic/gin" "github.com/olexsmir/onasty/internal/config" + "github.com/olexsmir/onasty/internal/service/authsrv" "github.com/olexsmir/onasty/internal/service/notesrv" "github.com/olexsmir/onasty/internal/service/usersrv" "github.com/olexsmir/onasty/internal/transport/http/apiv1"

@@ -14,6 +15,7 @@ "github.com/olexsmir/onasty/internal/transport/http/reqid"

) type Transport struct { + authsrv authsrv.AuthServicer usersrv usersrv.UserServicer notesrv notesrv.NoteServicer

@@ -27,6 +29,7 @@ slowRatelimitCfg ratelimit.Config

} func NewTransport( + as authsrv.AuthServicer, us usersrv.UserServicer, ns notesrv.NoteServicer, env config.Environment,

@@ -37,6 +40,7 @@ ratelimitCfg ratelimit.Config,

slowRatelimitCfg ratelimit.Config, ) *Transport { return &Transport{ + authsrv: as, usersrv: us, notesrv: ns, env: env,

@@ -62,7 +66,7 @@ api := r.Group("/api")

{ api.GET("/ping", t.pingHandler) apiv1. - NewAPIV1(t.usersrv, t.notesrv, t.slowRatelimitCfg, t.env, t.domain). + NewAPIV1(t.authsrv, t.usersrv, t.notesrv, t.slowRatelimitCfg, t.env, t.domain). Routes(api.Group("/v1")) }