onasty/internal/service/authsrv/authsrv.go (view raw)
Oleksandr Smirnov
Oleksandr Smirnov
olexsmir@gmail.com fix: don't return "wrong credentials" (#201), 9 months ago
olexsmir@gmail.com fix: don't return "wrong credentials" (#201), 9 months ago
| 1 | package authsrv |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "log/slog" |
| 7 | "time" |
| 8 | |
| 9 | "github.com/gofrs/uuid/v5" |
| 10 | "github.com/olexsmir/onasty/internal/dtos" |
| 11 | "github.com/olexsmir/onasty/internal/events/mailermq" |
| 12 | "github.com/olexsmir/onasty/internal/hasher" |
| 13 | "github.com/olexsmir/onasty/internal/jwtutil" |
| 14 | "github.com/olexsmir/onasty/internal/models" |
| 15 | "github.com/olexsmir/onasty/internal/oauth" |
| 16 | "github.com/olexsmir/onasty/internal/store/psql/sessionrepo" |
| 17 | "github.com/olexsmir/onasty/internal/store/psql/userepo" |
| 18 | "github.com/olexsmir/onasty/internal/store/psql/vertokrepo" |
| 19 | "github.com/olexsmir/onasty/internal/store/rdb/usercache" |
| 20 | ) |
| 21 | |
| 22 | type AuthServicer interface { |
| 23 | // SignUp creates a new user and sends a verification email. |
| 24 | // |
| 25 | // Uses [models.User.Validate] to validate credentials (see more possible returned errors). |
| 26 | // |
| 27 | // If provided email already in use returns [models.ErrUserEmailIsAlreadyInUse]. |
| 28 | // |
| 29 | SignUp(ctx context.Context, credentials dtos.SignUp) error |
| 30 | |
| 31 | // SignIn authenticates a user and returns access and refresh tokens. |
| 32 | // |
| 33 | // If user not found returns [models.ErrUserNotFound], and if credentials don't match [models.ErrUserWrongCredentials] |
| 34 | // |
| 35 | // If inactivated user tries to login, returns [models.ErrUserIsNotActivated] |
| 36 | // |
| 37 | SignIn(ctx context.Context, credentials dtos.SignIn) (dtos.Tokens, error) |
| 38 | |
| 39 | // RefreshTokens refreshes the access and refresh tokens using the provided refresh token. |
| 40 | // |
| 41 | // If couldn't find a user liked with token, returns [models.ErrUserNotFound] |
| 42 | // |
| 43 | RefreshTokens(ctx context.Context, refreshToken string) (dtos.Tokens, error) |
| 44 | |
| 45 | // Logout logs out a user by deleting the session associated with the provided refresh token. |
| 46 | Logout(ctx context.Context, userID uuid.UUID, refreshToken string) error |
| 47 | |
| 48 | // LogoutAll logs out a user by deleting all sessions associated with the user ID. |
| 49 | LogoutAll(ctx context.Context, userID uuid.UUID) error |
| 50 | |
| 51 | // GetOAuthURL retrieves the OAuth URL for the specified provider. |
| 52 | // |
| 53 | // If [providerName] is incorrect returns [ErrProviderNotSupported] |
| 54 | // |
| 55 | GetOAuthURL(providerName string) (dtos.OAuthRedirect, error) |
| 56 | |
| 57 | // HandleOAuthLogin handles the OAuth login process by exchanging the code for tokens. |
| 58 | // |
| 59 | HandleOAuthLogin(ctx context.Context, providerName, code string) (dtos.Tokens, error) |
| 60 | |
| 61 | // ParseJWTToken parses the JWT token and returns the payload. |
| 62 | // |
| 63 | // If token is expired, returns [jwtutil.ErrTokenExpired], |
| 64 | // |
| 65 | // If token is invalid returns: [jwturil.ErrTokenSignatureInvalid], [jwt.ErrUnexpectedSigningMethod] |
| 66 | // |
| 67 | ParseJWTToken(token string) (jwtutil.Payload, error) |
| 68 | |
| 69 | // CheckIfUserExists checks if a user exists by user ID. |
| 70 | CheckIfUserExists(ctx context.Context, userID uuid.UUID) (bool, error) |
| 71 | |
| 72 | // CheckIfUserIsActivated checks if a user is activated by user ID. |
| 73 | CheckIfUserIsActivated(ctx context.Context, userID uuid.UUID) (bool, error) |
| 74 | } |
| 75 | |
| 76 | var _ AuthServicer = (*AuthSrv)(nil) |
| 77 | |
| 78 | type AuthSrv struct { |
| 79 | userstore userepo.UserStorer |
| 80 | sessionstore sessionrepo.SessionStorer |
| 81 | vertokrepo vertokrepo.VerificationTokenStorer |
| 82 | cache usercache.UserCacheer |
| 83 | |
| 84 | hasher hasher.Hasher |
| 85 | jwtTokenizer jwtutil.JWTTokenizer |
| 86 | mailermq mailermq.Mailer |
| 87 | |
| 88 | googleOauth oauth.Provider |
| 89 | githubOauth oauth.Provider |
| 90 | |
| 91 | refreshTokenTTL time.Duration |
| 92 | verificationTokenTTL time.Duration |
| 93 | } |
| 94 | |
| 95 | func New( |
| 96 | userstore userepo.UserStorer, |
| 97 | sessionstore sessionrepo.SessionStorer, |
| 98 | vertokrepo vertokrepo.VerificationTokenStorer, |
| 99 | cache usercache.UserCacheer, |
| 100 | hasher hasher.Hasher, |
| 101 | jwtTokenizer jwtutil.JWTTokenizer, |
| 102 | mailermq mailermq.Mailer, |
| 103 | googleOauth, githubOauth oauth.Provider, |
| 104 | refreshTokenTTL, verificationTokenTTL time.Duration, |
| 105 | ) *AuthSrv { |
| 106 | return &AuthSrv{ |
| 107 | userstore: userstore, |
| 108 | sessionstore: sessionstore, |
| 109 | vertokrepo: vertokrepo, |
| 110 | cache: cache, |
| 111 | hasher: hasher, |
| 112 | jwtTokenizer: jwtTokenizer, |
| 113 | mailermq: mailermq, |
| 114 | googleOauth: googleOauth, |
| 115 | githubOauth: githubOauth, |
| 116 | refreshTokenTTL: refreshTokenTTL, |
| 117 | verificationTokenTTL: verificationTokenTTL, |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | func (a *AuthSrv) SignUp(ctx context.Context, inp dtos.SignUp) error { |
| 122 | user := models.User{ |
| 123 | ID: uuid.Nil, // nil, since we do not know it yet |
| 124 | Email: inp.Email, |
| 125 | Activated: false, |
| 126 | Password: inp.Password, |
| 127 | CreatedAt: inp.CreatedAt, |
| 128 | LastLoginAt: inp.LastLoginAt, |
| 129 | } |
| 130 | if err := user.Validate(); err != nil { |
| 131 | return err |
| 132 | } |
| 133 | |
| 134 | hashedPassword, err := a.hasher.Hash(inp.Password) |
| 135 | if err != nil { |
| 136 | return err |
| 137 | } |
| 138 | |
| 139 | user.Password = hashedPassword |
| 140 | |
| 141 | userID, err := a.userstore.Create(ctx, user) |
| 142 | if err != nil { |
| 143 | return err |
| 144 | } |
| 145 | |
| 146 | verificationToken := uuid.Must(uuid.NewV4()).String() |
| 147 | if err := a.vertokrepo.Create(ctx, models.VerificationToken{ |
| 148 | UserID: userID, |
| 149 | Token: verificationToken, |
| 150 | CreatedAt: time.Now(), |
| 151 | ExpiresAt: time.Now().Add(a.verificationTokenTTL), |
| 152 | }); err != nil { |
| 153 | return err |
| 154 | } |
| 155 | |
| 156 | if err := a.mailermq.SendVerificationEmail(ctx, mailermq.SendVerificationEmailRequest{ |
| 157 | Receiver: inp.Email, |
| 158 | Token: verificationToken, |
| 159 | }); err != nil { |
| 160 | return err |
| 161 | } |
| 162 | |
| 163 | return nil |
| 164 | } |
| 165 | |
| 166 | func (a *AuthSrv) SignIn(ctx context.Context, inp dtos.SignIn) (dtos.Tokens, error) { |
| 167 | user, err := a.userstore.GetByEmail(ctx, inp.Email) |
| 168 | if err != nil { |
| 169 | return dtos.Tokens{}, err |
| 170 | } |
| 171 | |
| 172 | if err = a.hasher.Compare(user.Password, inp.Password); err != nil { |
| 173 | if errors.Is(err, hasher.ErrMismatchedHashes) { |
| 174 | return dtos.Tokens{}, models.ErrUserNotFound |
| 175 | } |
| 176 | return dtos.Tokens{}, err |
| 177 | } |
| 178 | |
| 179 | if !user.IsActivated() { |
| 180 | return dtos.Tokens{}, models.ErrUserIsNotActivated |
| 181 | } |
| 182 | |
| 183 | return a.issueTokens(ctx, user.ID) |
| 184 | } |
| 185 | |
| 186 | func (a *AuthSrv) RefreshTokens(ctx context.Context, rtoken string) (dtos.Tokens, error) { |
| 187 | userID, err := a.sessionstore.GetUserIDByRefreshToken(ctx, rtoken) |
| 188 | if err != nil { |
| 189 | return dtos.Tokens{}, err |
| 190 | } |
| 191 | |
| 192 | tokens, err := a.createTokens(userID) |
| 193 | if err != nil { |
| 194 | return dtos.Tokens{}, err |
| 195 | } |
| 196 | |
| 197 | if err := a.sessionstore.Update(ctx, userID, rtoken, tokens.Refresh); err != nil { |
| 198 | return dtos.Tokens{}, err |
| 199 | } |
| 200 | |
| 201 | return dtos.Tokens{ |
| 202 | Access: tokens.Access, |
| 203 | Refresh: tokens.Refresh, |
| 204 | }, nil |
| 205 | } |
| 206 | |
| 207 | func (a *AuthSrv) Logout(ctx context.Context, userID uuid.UUID, refreshToken string) error { |
| 208 | return a.sessionstore.Delete(ctx, userID, refreshToken) |
| 209 | } |
| 210 | |
| 211 | func (a *AuthSrv) LogoutAll(ctx context.Context, userID uuid.UUID) error { |
| 212 | return a.sessionstore.DeleteAllByUserID(ctx, userID) |
| 213 | } |
| 214 | |
| 215 | func (a *AuthSrv) CheckIfUserExists(ctx context.Context, uid uuid.UUID) (bool, error) { |
| 216 | isExists, err := a.cache.GetIsExists(ctx, uid.String()) |
| 217 | if err == nil { |
| 218 | return isExists, nil |
| 219 | } |
| 220 | slog.ErrorContext(ctx, "failed to fetch 'is user exists' cache", "err", err) |
| 221 | |
| 222 | isExists, err = a.userstore.CheckIfUserExists(ctx, uid) |
| 223 | if err != nil { |
| 224 | return false, err |
| 225 | } |
| 226 | |
| 227 | if err := a.cache.SetIsExists(ctx, uid.String(), isExists); err != nil { |
| 228 | slog.ErrorContext(ctx, "failed to update 'is user exists' cache", "err", err) |
| 229 | } |
| 230 | |
| 231 | return isExists, nil |
| 232 | } |
| 233 | |
| 234 | func (a *AuthSrv) CheckIfUserIsActivated(ctx context.Context, uid uuid.UUID) (bool, error) { |
| 235 | isActivated, err := a.cache.GetIsActivated(ctx, uid.String()) |
| 236 | if err == nil { |
| 237 | return isActivated, nil |
| 238 | } |
| 239 | slog.ErrorContext(ctx, "failed to fetch 'is user activated' cache", "err", err) |
| 240 | |
| 241 | isActivated, err = a.userstore.CheckIfUserIsActivated(ctx, uid) |
| 242 | if err != nil { |
| 243 | return false, err |
| 244 | } |
| 245 | |
| 246 | if err := a.cache.SetIsActivated(ctx, uid.String(), isActivated); err != nil { |
| 247 | slog.ErrorContext(ctx, "failed to update 'is user activated' cache", "err", err) |
| 248 | } |
| 249 | |
| 250 | return isActivated, nil |
| 251 | } |