22 files changed,
440 insertions(+),
39 deletions(-)
Author:
Smirnov Oleksandr
ss2316544@gmail.com
Committed by:
GitHub
noreply@github.com
Committed at:
2025-05-17 16:25:31 +0300
Parent:
5495cb4
jump to
M
cmd/server/main.go
··· 21 21 "github.com/olexsmir/onasty/internal/service/notesrv" 22 22 "github.com/olexsmir/onasty/internal/service/usersrv" 23 23 "github.com/olexsmir/onasty/internal/store/psql/noterepo" 24 + "github.com/olexsmir/onasty/internal/store/psql/passwordtokrepo" 24 25 "github.com/olexsmir/onasty/internal/store/psql/sessionrepo" 25 26 "github.com/olexsmir/onasty/internal/store/psql/userepo" 26 27 "github.com/olexsmir/onasty/internal/store/psql/vertokrepo" ··· 95 96 96 97 sessionrepo := sessionrepo.New(psqlDB) 97 98 vertokrepo := vertokrepo.New(psqlDB) 99 + pwdtokrepo := passwordtokrepo.NewPasswordResetTokenRepo(psqlDB) 98 100 99 101 userepo := userepo.New(psqlDB) 100 102 usercache := usercache.New(redisDB, cfg.CacheUsersTTL) ··· 102 104 userepo, 103 105 sessionrepo, 104 106 vertokrepo, 107 + pwdtokrepo, 105 108 userPasswordHasher, 106 109 jwtTokenizer, 107 110 mailermq, ··· 110 113 githubOauth, 111 114 cfg.JwtRefreshTokenTTL, 112 115 cfg.VerificationTokenTTL, 116 + cfg.ResetPasswordTokenTTL, 113 117 ) 114 118 115 119 notecache := notecache.New(redisDB, cfg.CacheNoteTTL)
M
e2e/apiv1_auth_test.go
··· 102 102 103 103 user := e.getLastUserByEmail(email) 104 104 token := e.getVerificationTokenByUserID(user.ID) 105 + e.Equal(token.Token, mockMailStore[email]) 106 + 105 107 httpResp = e.httpRequest(http.MethodGet, "/api/v1/auth/verify/"+token.Token, nil) 106 108 e.Equal(http.StatusOK, httpResp.Code) 107 109 ··· 136 138 ) 137 139 138 140 e.Equal(http.StatusOK, httpResp.Code) 141 + e.NotEmpty(mockMailStore[email]) 139 142 } 140 143 141 144 func (e *AppTestSuite) TestAuthV1_ResendVerificationEmail_wrong() { ··· 172 175 })) 173 176 174 177 e.Equal(httpResp.Code, t.expectedCode) 175 - 176 - // TODO: no email should be sent 178 + e.Empty(mockMailStore[t.email]) 177 179 } 178 180 } 179 181 ··· 342 344 userDB := e.getUserByUsername(username) 343 345 e.Equal(userDB.Username, username) 344 346 e.NoError(e.hasher.Compare(userDB.Password, newPassword)) 347 +} 348 + 349 +type ( 350 + apiV1AuthResetPasswordRequest struct { 351 + Email string `json:"email"` 352 + } 353 + apiV1AuthSetPasswordRequest struct { 354 + Password string `json:"password"` 355 + } 356 +) 357 + 358 +func (e *AppTestSuite) TestAuthV1_ResetPassword() { 359 + email := e.uuid() + "@test.com" 360 + uname := e.uuid() 361 + uid, _ := e.createAndSingIn(email, uname, "password") 362 + 363 + httpResp := e.httpRequest( 364 + http.MethodPost, 365 + "/api/v1/auth/reset-password", 366 + e.jsonify(apiV1AuthResetPasswordRequest{ 367 + Email: email, 368 + }), 369 + ) 370 + 371 + e.Equal(httpResp.Code, http.StatusOK) 372 + 373 + token := e.getResetPasswordTokenByUserID(uid) 374 + e.Empty(token.UsedAt) 375 + e.Equal(mockMailStore[email], token.Token) 376 + 377 + // set new password 378 + password := e.uuid() 379 + httpResp = e.httpRequest( 380 + http.MethodPost, 381 + "/api/v1/auth/reset-password/"+token.Token, 382 + e.jsonify(apiV1AuthSetPasswordRequest{ 383 + Password: password, 384 + }), 385 + ) 386 + 387 + dbUser := e.getUserByUsername(uname) 388 + e.Equal(httpResp.Code, http.StatusOK) 389 + e.NoError(e.hasher.Compare(dbUser.Password, password)) 390 + 391 + token = e.getResetPasswordTokenByUserID(uid) 392 + e.NotEmpty(token.UsedAt) 393 +} 394 + 395 +func (e *AppTestSuite) TestAuthV1_ResetPassword_nonExistentUser() { 396 + _, _ = e.createAndSingIn(e.uuid()+"@test.comd", e.uuid(), "password") 397 + httpResp := e.httpRequest( 398 + http.MethodPost, 399 + "/api/v1/auth/reset-password", 400 + e.jsonify(apiV1AuthResetPasswordRequest{ 401 + Email: e.uuid() + "@testing.com", 402 + }), 403 + ) 404 + 405 + e.Equal(httpResp.Code, http.StatusBadRequest) 345 406 } 346 407 347 408 // createAndSingIn creates an activated username, logs them in,
M
e2e/e2e_test.go
··· 19 19 "github.com/olexsmir/onasty/internal/service/notesrv" 20 20 "github.com/olexsmir/onasty/internal/service/usersrv" 21 21 "github.com/olexsmir/onasty/internal/store/psql/noterepo" 22 + "github.com/olexsmir/onasty/internal/store/psql/passwordtokrepo" 22 23 "github.com/olexsmir/onasty/internal/store/psql/sessionrepo" 23 24 "github.com/olexsmir/onasty/internal/store/psql/userepo" 24 25 "github.com/olexsmir/onasty/internal/store/psql/vertokrepo" ··· 103 104 104 105 sessionrepo := sessionrepo.New(e.postgresDB) 105 106 vertokrepo := vertokrepo.New(e.postgresDB) 107 + pwdtokrepo := passwordtokrepo.NewPasswordResetTokenRepo(e.postgresDB) 106 108 107 109 oauthProvider := newOauthProviderMock() 108 110 ··· 112 114 userepo, 113 115 sessionrepo, 114 116 vertokrepo, 117 + pwdtokrepo, 115 118 e.hasher, 116 119 e.jwtTokenizer, 117 120 newMailerMockService(), ··· 120 123 oauthProvider, 121 124 cfg.JwtRefreshTokenTTL, 122 125 cfg.VerificationTokenTTL, 126 + cfg.ResetPasswordTokenTTL, 123 127 ) 124 128 125 129 notecache := notecache.New(e.redisDB, cfg.CacheUsersTTL)
M
e2e/e2e_utils_db_test.go
··· 166 166 e.require.NoError(err) 167 167 return r 168 168 } 169 + 170 +func (e *AppTestSuite) getResetPasswordTokenByUserID(u uuid.UUID) userVerificationToken { 171 + query, args, err := pgq. 172 + Select("token", "used_at"). 173 + From("password_reset_tokens "). 174 + Where(pgq.Eq{"user_id": u.String()}). 175 + Limit(1). 176 + SQL() 177 + 178 + e.require.NoError(err) 179 + var r userVerificationToken 180 + err = e.postgresDB.QueryRow(e.ctx, query, args...).Scan(&r.Token, &r.UsedAt) 181 + e.require.NoError(err) 182 + return r 183 +}
M
e2e/mailer_mock_test.go
··· 8 8 9 9 var _ mailermq.Mailer = (*mailerMockService)(nil) 10 10 11 +var mockMailStore = make(map[string]string) 12 + 11 13 type mailerMockService struct{} 12 14 13 15 func newMailerMockService() *mailerMockService { 14 16 return &mailerMockService{} 15 17 } 16 18 17 -func (m mailerMockService) SendVerificationEmail( 19 +func (m *mailerMockService) SendVerificationEmail( 20 + _ context.Context, 21 + i mailermq.SendVerificationEmailRequest, 22 +) error { 23 + mockMailStore[i.Receiver] = i.Token 24 + return nil 25 +} 26 + 27 +func (m *mailerMockService) SendPasswordResetEmail( 18 28 _ context.Context, 19 - _ mailermq.SendVerificationEmailRequest, 29 + i mailermq.SendPasswordResetEmailRequest, 20 30 ) error { 31 + mockMailStore[i.Receiver] = i.Token 21 32 return nil 22 33 }
M
internal/config/config.go
··· 40 40 GitHubSecret string 41 41 GitHubRedirectURL string 42 42 43 - VerificationTokenTTL time.Duration 43 + VerificationTokenTTL time.Duration 44 + ResetPasswordTokenTTL time.Duration 44 45 45 46 MetricsEnabled bool 46 47 MetricsPort int ··· 92 93 GitHubSecret: getenvOrDefault("GITHUB_SECRET", ""), 93 94 GitHubRedirectURL: getenvOrDefault("GITHUB_REDIRECTURL", ""), 94 95 95 - VerificationTokenTTL: mustParseDuration( 96 - getenvOrDefault("VERIFICATION_TOKEN_TTL", "24h"), 97 - ), 96 + VerificationTokenTTL: mustParseDuration(getenvOrDefault("VERIFICATION_TOKEN_TTL", "24h")), 97 + ResetPasswordTokenTTL: mustParseDuration(getenvOrDefault("RESET_PASSWORD_TOKEN_TTL", "1h")), 98 98 99 99 MetricsPort: mustGetenvOrDefaultInt("METRICS_PORT", 3001), 100 100 MetricsEnabled: getenvOrDefault("METRICS_ENABLED", "true") == "true",
M
internal/events/mailermq/mailermq.go
··· 9 9 "github.com/olexsmir/onasty/internal/transport/http/reqid" 10 10 ) 11 11 12 +const sendTopic = "mailer.send" 13 + 12 14 type Mailer interface { 13 15 SendVerificationEmail(ctx context.Context, input SendVerificationEmailRequest) error 16 + SendPasswordResetEmail(ctx context.Context, input SendPasswordResetEmailRequest) error 14 17 } 15 18 16 19 type MailerMQ struct { ··· 51 54 return err 52 55 } 53 56 54 - resp, err := m.nc.RequestWithContext(ctx, "mailer.send", req) 57 + resp, err := m.nc.RequestWithContext(ctx, sendTopic, req) 58 + if err != nil { 59 + return err 60 + } 61 + 62 + return events.CheckRespForError(resp) 63 +} 64 + 65 +type SendPasswordResetEmailRequest struct { 66 + Receiver string 67 + Token string 68 +} 69 + 70 +func (m MailerMQ) SendPasswordResetEmail( 71 + ctx context.Context, 72 + inp SendPasswordResetEmailRequest, 73 +) error { 74 + req, err := json.Marshal(sendRequest{ 75 + RequestID: reqid.GetContext(ctx), 76 + Receiver: inp.Receiver, 77 + TemplateName: "reset_password", 78 + Options: map[string]string{ 79 + "token": inp.Token, 80 + }, 81 + }) 82 + if err != nil { 83 + return err 84 + } 85 + 86 + resp, err := m.nc.RequestWithContext(ctx, sendTopic, req) 55 87 if err != nil { 56 88 return err 57 89 }
A
internal/models/tokens.go
··· 1 +package models 2 + 3 +import ( 4 + "errors" 5 + "time" 6 + 7 + "github.com/gofrs/uuid/v5" 8 +) 9 + 10 +var ( 11 + ErrResetPasswordTokenExpired = errors.New("reset password token expired") 12 + ErrResetPasswordTokenNotFound = errors.New("reset password token not found") 13 +) 14 + 15 +type ResetPasswordToken struct { 16 + UserID uuid.UUID 17 + Token string 18 + CreatedAt time.Time 19 + ExpiresAt time.Time 20 +} 21 + 22 +func (p ResetPasswordToken) IsExpired() bool { 23 + return p.ExpiresAt.Before(time.Now()) 24 +}
M
internal/models/user.go
··· 13 13 ErrUsernameIsAlreadyInUse = errors.New("user: username is already in use") 14 14 ErrUserIsAlreadyVerified = errors.New("user: user is already verified") 15 15 16 - ErrVerificationTokenNotFound = errors.New("user: verification token not found") 17 - ErrUserIsNotActivated = errors.New("user: user is not activated") 16 + ErrResetPasswordTokenAlreadyUsed = errors.New("reset password token is already used") 17 + ErrVerificationTokenNotFound = errors.New("user: verification token not found") 18 + ErrUserIsNotActivated = errors.New("user: user is not activated") 18 19 19 20 ErrUserNotFound = errors.New("user: not found") 20 21 ErrUserWrongCredentials = errors.New("user: wrong credentials") ··· 40 41 return ErrUserInvalidEmail 41 42 } 42 43 43 - if len(u.Password) < 6 { 44 - return ErrUserInvalidPassword 45 - } 46 - 47 44 if len(u.Username) == 0 { 48 45 return ErrUserInvalidUsername 49 46 } 50 47 48 + return u.ValidatePassword() 49 +} 50 + 51 +func (u User) ValidatePassword() error { 52 + if len(u.Password) < 6 { 53 + return ErrUserInvalidPassword 54 + } 51 55 return nil 52 56 } 53 57
M
internal/service/usersrv/usersrv.go
··· 13 13 "github.com/olexsmir/onasty/internal/jwtutil" 14 14 "github.com/olexsmir/onasty/internal/models" 15 15 "github.com/olexsmir/onasty/internal/oauth" 16 + "github.com/olexsmir/onasty/internal/store/psql/passwordtokrepo" 16 17 "github.com/olexsmir/onasty/internal/store/psql/sessionrepo" 17 18 "github.com/olexsmir/onasty/internal/store/psql/userepo" 18 19 "github.com/olexsmir/onasty/internal/store/psql/vertokrepo" ··· 26 27 Logout(ctx context.Context, userID uuid.UUID) error 27 28 28 29 ChangePassword(ctx context.Context, userID uuid.UUID, inp dtos.ChangeUserPassword) error 30 + RequestPasswordReset(ctx context.Context, inp dtos.RequestResetPassword) error 31 + ResetPassword(ctx context.Context, inp dtos.ResetPassword) error 29 32 30 33 GetOAuthURL(providerName string) (string, error) 31 34 HandleOAuthLogin(ctx context.Context, providerName, code string) (dtos.Tokens, error) ··· 45 48 userstore userepo.UserStorer 46 49 sessionstore sessionrepo.SessionStorer 47 50 vertokrepo vertokrepo.VerificationTokenStorer 51 + pwdtokrepo passwordtokrepo.PasswordResetTokenStorer 52 + cache usercache.UserCacheer 53 + 48 54 hasher hasher.Hasher 49 55 jwtTokenizer jwtutil.JWTTokenizer 50 56 mailermq mailermq.Mailer 51 - cache usercache.UserCacheer 52 - googleOauth oauth.Provider 53 - githubOauth oauth.Provider 54 57 55 - refreshTokenTTL time.Duration 56 - verificationTokenTTL time.Duration 58 + googleOauth oauth.Provider 59 + githubOauth oauth.Provider 60 + 61 + refreshTokenTTL time.Duration 62 + verificationTokenTTL time.Duration 63 + resetPasswordTokenTTL time.Duration 57 64 } 58 65 59 66 func New( 60 67 userstore userepo.UserStorer, 61 68 sessionstore sessionrepo.SessionStorer, 62 69 vertokrepo vertokrepo.VerificationTokenStorer, 70 + pwdtokrepo passwordtokrepo.PasswordResetTokenStorer, 63 71 hasher hasher.Hasher, 64 72 jwtTokenizer jwtutil.JWTTokenizer, 65 73 mailermq mailermq.Mailer, 66 74 cache usercache.UserCacheer, 67 75 googleOauth, githubOauth oauth.Provider, 68 - refreshTokenTTL, verificationTokenTTL time.Duration, 76 + refreshTokenTTL, verificationTokenTTL, resetPasswordTokenTTL time.Duration, 69 77 ) *UserSrv { 70 78 return &UserSrv{ 71 - userstore: userstore, 72 - sessionstore: sessionstore, 73 - vertokrepo: vertokrepo, 74 - hasher: hasher, 75 - jwtTokenizer: jwtTokenizer, 76 - mailermq: mailermq, 77 - cache: cache, 78 - googleOauth: googleOauth, 79 - githubOauth: githubOauth, 80 - refreshTokenTTL: refreshTokenTTL, 81 - verificationTokenTTL: verificationTokenTTL, 79 + userstore: userstore, 80 + sessionstore: sessionstore, 81 + vertokrepo: vertokrepo, 82 + pwdtokrepo: pwdtokrepo, 83 + cache: cache, 84 + hasher: hasher, 85 + jwtTokenizer: jwtTokenizer, 86 + mailermq: mailermq, 87 + googleOauth: googleOauth, 88 + githubOauth: githubOauth, 89 + refreshTokenTTL: refreshTokenTTL, 90 + verificationTokenTTL: verificationTokenTTL, 91 + resetPasswordTokenTTL: resetPasswordTokenTTL, 82 92 } 83 93 } 84 94 ··· 180 190 ) error { 181 191 // TODO: compare current password with providede, and assert on mismatch 182 192 193 + //nolint:exhaustruct 194 + if err := (models.User{Password: inp.NewPassword}).ValidatePassword(); err != nil { 195 + return err 196 + } 197 + 183 198 oldPass, err := u.hasher.Hash(inp.CurrentPassword) 184 199 if err != nil { 185 200 return err ··· 195 210 } 196 211 197 212 return nil 213 +} 214 + 215 +func (u *UserSrv) RequestPasswordReset(ctx context.Context, inp dtos.RequestResetPassword) error { 216 + user, err := u.userstore.GetByEmail(ctx, inp.Email) 217 + if err != nil { 218 + return err 219 + } 220 + 221 + token := uuid.Must(uuid.NewV4()).String() 222 + if err := u.pwdtokrepo.Create(ctx, models.ResetPasswordToken{ 223 + UserID: user.ID, 224 + Token: token, 225 + CreatedAt: time.Now(), 226 + ExpiresAt: time.Now().Add(u.resetPasswordTokenTTL), 227 + }); err != nil { 228 + return err 229 + } 230 + 231 + if err := u.mailermq.SendPasswordResetEmail(ctx, mailermq.SendPasswordResetEmailRequest{ 232 + Receiver: inp.Email, 233 + Token: token, 234 + }); err != nil { 235 + return err 236 + } 237 + 238 + return nil 239 +} 240 + 241 +func (u *UserSrv) ResetPassword(ctx context.Context, inp dtos.ResetPassword) error { 242 + //nolint:exhaustruct 243 + if err := (models.User{Password: inp.NewPassword}).ValidatePassword(); err != nil { 244 + return err 245 + } 246 + 247 + uid, err := u.pwdtokrepo.GetUserIDByTokenAndMarkAsUsed(ctx, inp.Token, time.Now()) 248 + if err != nil { 249 + return err 250 + } 251 + 252 + hashedPassword, err := u.hasher.Hash(inp.NewPassword) 253 + if err != nil { 254 + return err 255 + } 256 + 257 + return u.userstore.SetPassword(ctx, uid, hashedPassword) 198 258 } 199 259 200 260 func (u *UserSrv) Verify(ctx context.Context, verificationKey string) error {
A
internal/store/psql/passwordtokrepo/passwordtokrepo.go
··· 1 +package passwordtokrepo 2 + 3 +import ( 4 + "context" 5 + "errors" 6 + "time" 7 + 8 + "github.com/gofrs/uuid/v5" 9 + "github.com/henvic/pgq" 10 + "github.com/jackc/pgx/v5" 11 + "github.com/olexsmir/onasty/internal/models" 12 + "github.com/olexsmir/onasty/internal/store/psqlutil" 13 +) 14 + 15 +type PasswordResetTokenStorer interface { 16 + Create(ctx context.Context, input models.ResetPasswordToken) error 17 + 18 + GetUserIDByTokenAndMarkAsUsed( 19 + ctx context.Context, 20 + token string, 21 + usedAT time.Time, 22 + ) (uuid.UUID, error) 23 +} 24 + 25 +var _ PasswordResetTokenStorer = (*PasswordResetTokenRepo)(nil) 26 + 27 +type PasswordResetTokenRepo struct { 28 + db *psqlutil.DB 29 +} 30 + 31 +func NewPasswordResetTokenRepo(db *psqlutil.DB) *PasswordResetTokenRepo { 32 + return &PasswordResetTokenRepo{ 33 + db: db, 34 + } 35 +} 36 + 37 +func (r *PasswordResetTokenRepo) Create(ctx context.Context, token models.ResetPasswordToken, 38 +) error { 39 + query, aggs, err := pgq. 40 + Insert("password_reset_tokens"). 41 + Columns("user_id", "token", "created_at", "expires_at"). 42 + Values(token.UserID, token.Token, token.CreatedAt, token.ExpiresAt). 43 + SQL() 44 + if err != nil { 45 + return err 46 + } 47 + 48 + _, err = r.db.Exec(ctx, query, aggs...) 49 + return err 50 +} 51 + 52 +func (r *PasswordResetTokenRepo) GetUserIDByTokenAndMarkAsUsed( 53 + ctx context.Context, 54 + token string, 55 + usedAt time.Time, 56 +) (uuid.UUID, error) { 57 + tx, err := r.db.Begin(ctx) 58 + if err != nil { 59 + return uuid.Nil, err 60 + } 61 + defer tx.Rollback(ctx) //nolint:errcheck 62 + 63 + var isUsed bool 64 + var expiresAt time.Time 65 + err = tx.QueryRow(ctx, "select (used_at is not null), expires_at from password_reset_tokens where token = $1", token). 66 + Scan(&isUsed, &expiresAt) 67 + if err != nil { 68 + if errors.Is(err, pgx.ErrNoRows) { 69 + return uuid.Nil, models.ErrResetPasswordTokenNotFound 70 + } 71 + return uuid.Nil, err 72 + } 73 + 74 + if isUsed { 75 + return uuid.Nil, models.ErrResetPasswordTokenAlreadyUsed 76 + } 77 + 78 + if time.Now().After(expiresAt) { 79 + return uuid.Nil, models.ErrResetPasswordTokenExpired 80 + } 81 + 82 + query := `--sql 83 +update password_reset_tokens 84 +set used_at = $1 85 +where token = $2 86 +returning user_id` 87 + 88 + var userID uuid.UUID 89 + err = tx.QueryRow(ctx, query, usedAt, token).Scan(&userID) 90 + if err != nil { 91 + return uuid.Nil, err 92 + } 93 + 94 + return userID, tx.Commit(ctx) 95 +}
M
internal/transport/http/apiv1/apiv1.go
··· 30 30 auth.POST("/refresh-tokens", a.refreshTokensHandler) 31 31 auth.GET("/verify/:token", a.verifyHandler) 32 32 auth.POST("/resend-verification-email", a.resendVerificationEmailHandler) 33 + auth.POST("/reset-password", a.requestResetPasswordHandler) 34 + auth.POST("/reset-password/:token", a.resetPasswordHandler) 33 35 34 36 authorized := auth.Group("/", a.authorizedMiddleware) 35 37 {
M
internal/transport/http/apiv1/auth.go
··· 119 119 c.Status(http.StatusOK) 120 120 } 121 121 122 +type requestResetPasswordRequest struct { 123 + Email string `json:"email"` 124 +} 125 + 126 +func (a *APIV1) requestResetPasswordHandler(c *gin.Context) { 127 + var req requestResetPasswordRequest 128 + if err := c.ShouldBindJSON(&req); err != nil { 129 + newError(c, http.StatusBadRequest, "invalid request") 130 + return 131 + } 132 + 133 + if err := a.usersrv.RequestPasswordReset(c.Request.Context(), dtos.RequestResetPassword{ 134 + Email: req.Email, 135 + }); err != nil { 136 + errorResponse(c, err) 137 + return 138 + } 139 + 140 + c.Status(http.StatusOK) 141 +} 142 + 143 +type resetPasswordRequest struct { 144 + Password string `json:"password"` 145 +} 146 + 147 +func (a *APIV1) resetPasswordHandler(c *gin.Context) { 148 + var req resetPasswordRequest 149 + if err := c.ShouldBindJSON(&req); err != nil { 150 + newError(c, http.StatusBadRequest, "invalid request") 151 + return 152 + } 153 + 154 + if err := a.usersrv.ResetPassword( 155 + c.Request.Context(), 156 + dtos.ResetPassword{ 157 + Token: c.Param("token"), 158 + NewPassword: req.Password, 159 + }, 160 + ); err != nil { 161 + errorResponse(c, err) 162 + return 163 + } 164 + 165 + c.Status(http.StatusOK) 166 +} 167 + 122 168 func (a *APIV1) logOutHandler(c *gin.Context) { 123 169 if err := a.usersrv.Logout(c.Request.Context(), a.getUserID(c)); err != nil { 124 170 errorResponse(c, err)
M
internal/transport/http/apiv1/response.go
··· 18 18 19 19 func errorResponse(c *gin.Context, err error) { 20 20 if errors.Is(err, usersrv.ErrProviderNotSupported) || 21 + errors.Is(err, models.ErrResetPasswordTokenAlreadyUsed) || 22 + errors.Is(err, models.ErrResetPasswordTokenExpired) || 21 23 errors.Is(err, models.ErrUserEmailIsAlreadyInUse) || 22 24 errors.Is(err, models.ErrUsernameIsAlreadyInUse) || 23 25 errors.Is(err, models.ErrUserIsAlreadyVerified) ||
M
mailer/service.go
··· 33 33 go func() { 34 34 select { 35 35 case <-ctx.Done(): 36 - slog.ErrorContext(ctx, "failed to send verification email", "err", ctx.Err()) 36 + slog.ErrorContext(ctx, "failed to send email", 37 + "template_name", templateName, 38 + "err", ctx.Err()) 37 39 return 38 40 default: 39 41 if err := s.mg.Send(ctx, receiver, t.Subject, t.Body); err != nil { 40 - slog.ErrorContext(ctx, "failed to send verification email", "err", err) 42 + slog.ErrorContext(ctx, "failed to send email", 43 + "template_name", templateName, 44 + "err", err) 41 45 } 42 46 cancel() 43 47 }
M
mailer/template.go
··· 13 13 type TemplateFunc func(args map[string]string) Template 14 14 15 15 func getTemplate(appURL string, templateName string) (TemplateFunc, error) { 16 - if templateName == "email_verification" { 16 + switch templateName { 17 + case "email_verification": 17 18 return emailVerificationTemplate(appURL), nil 19 + case "reset_password": 20 + return passwordResetTemplate(appURL), nil 21 + default: 22 + return nil, errors.New("failed to get template") //nolint:err113 18 23 } 19 - 20 - return nil, errors.New("failed to get template") //nolint:err113 21 24 } 22 25 23 26 func emailVerificationTemplate(appURL string) TemplateFunc { ··· 32 35 } 33 36 } 34 37 } 38 + 39 +func passwordResetTemplate(appURL string) TemplateFunc { 40 + return func(opts map[string]string) Template { 41 + return Template{ 42 + Subject: "Onasty: reset your password", 43 + // TODO: when ui is ready, change the link to the ui 44 + Body: fmt.Sprintf(`To reset your password, use this api: 45 +<a href="%[1]s/api/v1/auth/reset-password/%[2]s">%[1]s/api/v1/auth/reset-password/%[2]s</a> 46 +<br /> 47 +<br /> 48 +This link will expire after an hour.`, appURL, opts["token"]), 49 + } 50 + } 51 +}
A
migrations/20250509131258_password_reset_tokens.up.sql
··· 1 +CREATE TABLE password_reset_tokens ( 2 + id uuid PRIMARY KEY DEFAULT uuid_generate_v4 (), 3 + user_id uuid NOT NULL REFERENCES users (id), 4 + token varchar(255) NOT NULL UNIQUE, 5 + created_at timestamptz NOT NULL DEFAULT now(), 6 + expires_at timestamptz NOT NULL, 7 + used_at timestamptz DEFAULT NULL 8 +);