19 files changed,
476 insertions(+),
19 deletions(-)
Author:
Olexandr Smirnov
ss2316544@gmail.com
Committed by:
GitHub
noreply@github.com
Committed at:
2025-08-22 17:29:55 +0300
Parent:
844e778
jump to
M
cmd/api/main.go
··· 20 20 "github.com/olexsmir/onasty/internal/oauth" 21 21 "github.com/olexsmir/onasty/internal/service/notesrv" 22 22 "github.com/olexsmir/onasty/internal/service/usersrv" 23 + "github.com/olexsmir/onasty/internal/store/psql/changeemailrepo" 23 24 "github.com/olexsmir/onasty/internal/store/psql/noterepo" 24 25 "github.com/olexsmir/onasty/internal/store/psql/passwordtokrepo" 25 26 "github.com/olexsmir/onasty/internal/store/psql/sessionrepo" ··· 94 95 sessionrepo := sessionrepo.New(psqlDB) 95 96 vertokrepo := vertokrepo.New(psqlDB) 96 97 pwdtokrepo := passwordtokrepo.NewPasswordResetTokenRepo(psqlDB) 98 + changeemailrepo := changeemailrepo.New(psqlDB) 97 99 98 100 notecache := notecache.New(redisDB, cfg.CacheNoteTTL) 99 101 noterepo := noterepo.New(psqlDB) ··· 106 108 sessionrepo, 107 109 vertokrepo, 108 110 pwdtokrepo, 111 + changeemailrepo, 109 112 noterepo, 110 113 userPasswordHasher, 111 114 jwtTokenizer, ··· 116 119 cfg.JwtRefreshTokenTTL, 117 120 cfg.VerificationTokenTTL, 118 121 cfg.ResetPasswordTokenTTL, 122 + cfg.ChangeEmailTokenTTL, 119 123 ) 120 124 121 125 rateLimiterConfig := ratelimit.Config{
M
e2e/apiv1_auth_test.go
··· 1 1 package e2e_test 2 2 3 3 import ( 4 + "crypto/rand" 5 + "encoding/hex" 6 + "fmt" 4 7 "net/http" 5 8 "time" 6 9 ··· 447 450 e.Equal(httpResp.Code, http.StatusBadRequest) 448 451 } 449 452 453 +type apiv1AuthChangeEmailRequest struct { 454 + NewEmail string `json:"new_email"` 455 +} 456 + 457 +func (e *AppTestSuite) TestAuthV1_ChangeEmail() { 458 + oldEmail, newEmail := e.randomEmail(), e.randomEmail() 459 + uid, toks := e.createAndSingIn(oldEmail, e.uuid()) 460 + 461 + // request email change 462 + httpResp := e.httpRequest( 463 + http.MethodPost, 464 + "/api/v1/auth/change-email", 465 + e.jsonify(apiv1AuthChangeEmailRequest{ 466 + NewEmail: newEmail, 467 + }), 468 + toks.AccessToken, 469 + ) 470 + e.Equal(http.StatusOK, httpResp.Code) 471 + 472 + token := e.getChangeEmailTokenByUserID(uid) 473 + e.Empty(token.UsedAt) 474 + e.Equal(mockMailStore[oldEmail], token.Token) 475 + 476 + // confirm email change 477 + httpResp = e.httpRequest(http.MethodGet, "/api/v1/auth/change-email/"+token.Token, nil) 478 + e.Equal(http.StatusOK, httpResp.Code) 479 + 480 + updatedToken := e.getChangeEmailTokenByUserID(uid) 481 + e.NotEmpty(updatedToken.UsedAt) 482 + 483 + dbUser := e.getUserByEmail(token.Extra) 484 + e.Equal(dbUser.Email, newEmail) 485 +} 486 + 487 +func (e *AppTestSuite) TestAuthV1_ChangeEmail_wrongSameEmail() { 488 + email := e.randomEmail() 489 + _, toks := e.createAndSingIn(email, e.uuid()) 490 + 491 + // request email change 492 + httpResp := e.httpRequest( 493 + http.MethodPost, 494 + "/api/v1/auth/change-email", 495 + e.jsonify(apiv1AuthChangeEmailRequest{ 496 + NewEmail: email, 497 + }), 498 + toks.AccessToken, 499 + ) 500 + e.Equal(http.StatusBadRequest, httpResp.Code) 501 + 502 + var body errorResponse 503 + e.readBodyAndUnjsonify(httpResp.Body, &body) 504 + 505 + e.Equal(body.Message, models.ErrUserEmailIsAlreadyInUse.Error()) 506 +} 507 + 450 508 type getMeResponse struct { 451 509 Email string `json:"email"` 452 510 CreatedAt time.Time `json:"created_at"` ··· 500 558 501 559 return uid, body 502 560 } 561 + 562 +func (e *AppTestSuite) randomEmail() string { 563 + b := make([]byte, 4) 564 + _, _ = rand.Read(b) 565 + return fmt.Sprintf("user-%s@test.local", hex.EncodeToString(b)) 566 +}
M
e2e/e2e_test.go
··· 17 17 "github.com/olexsmir/onasty/internal/logger" 18 18 "github.com/olexsmir/onasty/internal/service/notesrv" 19 19 "github.com/olexsmir/onasty/internal/service/usersrv" 20 + "github.com/olexsmir/onasty/internal/store/psql/changeemailrepo" 20 21 "github.com/olexsmir/onasty/internal/store/psql/noterepo" 21 22 "github.com/olexsmir/onasty/internal/store/psql/passwordtokrepo" 22 23 "github.com/olexsmir/onasty/internal/store/psql/sessionrepo" ··· 102 103 sessionrepo := sessionrepo.New(e.postgresDB) 103 104 vertokrepo := vertokrepo.New(e.postgresDB) 104 105 pwdtokrepo := passwordtokrepo.NewPasswordResetTokenRepo(e.postgresDB) 106 + changeemailrepo := changeemailrepo.New(e.postgresDB) 105 107 106 108 stubOAuthProvider := newOauthProviderStub() 107 109 ··· 116 118 sessionrepo, 117 119 vertokrepo, 118 120 pwdtokrepo, 121 + changeemailrepo, 119 122 noterepo, 120 123 e.hasher, 121 124 e.jwtTokenizer, ··· 126 129 cfg.JwtRefreshTokenTTL, 127 130 cfg.VerificationTokenTTL, 128 131 cfg.ResetPasswordTokenTTL, 132 + cfg.ChangeEmailTokenTTL, 129 133 ) 130 134 131 135 // for testing purposes, it's ok to have high values ig
M
e2e/e2e_utils_db_test.go
··· 157 157 158 158 type userVerificationToken struct { 159 159 Token string 160 + Extra string // Extra field (optional) 160 161 UsedAt *time.Time 161 162 } 162 163 ··· 187 188 e.require.NoError(err) 188 189 return r 189 190 } 191 + 192 +func (e *AppTestSuite) getChangeEmailTokenByUserID(u uuid.UUID) userVerificationToken { 193 + query, args, err := pgq. 194 + Select("token", "new_email", "used_at"). 195 + From("change_email_tokens"). 196 + Where(pgq.Eq{"user_id": u.String()}). 197 + Limit(1). 198 + SQL() 199 + 200 + e.require.NoError(err) 201 + var r userVerificationToken 202 + err = e.postgresDB.QueryRow(e.ctx, query, args...).Scan(&r.Token, &r.Extra, &r.UsedAt) 203 + e.require.NoError(err) 204 + return r 205 +}
M
e2e/mailer_mock_test.go
··· 31 31 mockMailStore[i.Receiver] = i.Token 32 32 return nil 33 33 } 34 + 35 +func (m *mailerMockService) SendChangeEmailConfirmation( 36 + _ context.Context, 37 + i mailermq.SendChangeEmailConfirmationRequest, 38 +) error { 39 + mockMailStore[i.Receiver] = i.Token 40 + return nil 41 +}
M
internal/config/config.go
··· 53 53 54 54 VerificationTokenTTL time.Duration 55 55 ResetPasswordTokenTTL time.Duration 56 + ChangeEmailTokenTTL time.Duration 56 57 57 58 MetricsEnabled bool 58 59 MetricsPort int ··· 112 113 113 114 VerificationTokenTTL: mustParseDuration(getenvOrDefault("VERIFICATION_TOKEN_TTL", "24h")), 114 115 ResetPasswordTokenTTL: mustParseDuration(getenvOrDefault("RESET_PASSWORD_TOKEN_TTL", "1h")), 116 + ChangeEmailTokenTTL: mustParseDuration(getenvOrDefault("CHANGE_EMAIL_TOKEN_TTL", "24h")), 115 117 116 118 MetricsPort: mustGetenvOrDefaultInt("METRICS_PORT", 3001), 117 119 MetricsEnabled: getenvOrDefault("METRICS_ENABLED", "true") == "true",
M
internal/events/mailermq/mailermq.go
··· 17 17 18 18 // SendPasswordResetEmail sends an email with a password reset token to the user. 19 19 SendPasswordResetEmail(ctx context.Context, input SendPasswordResetEmailRequest) error 20 + 21 + // SendChangeEmailVerification sends an email with a change email verification token to the user. 22 + SendChangeEmailConfirmation(ctx context.Context, inp SendChangeEmailConfirmationRequest) error 20 23 } 21 24 22 25 type MailerMQ struct { ··· 93 96 94 97 return events.CheckRespForError(resp) 95 98 } 99 + 100 +type SendChangeEmailConfirmationRequest struct { 101 + Receiver string 102 + Token string 103 + NewEmail string 104 +} 105 + 106 +func (m MailerMQ) SendChangeEmailConfirmation( 107 + ctx context.Context, 108 + inp SendChangeEmailConfirmationRequest, 109 +) error { 110 + req, err := json.Marshal(sendRequest{ 111 + RequestID: reqid.GetContext(ctx), 112 + Receiver: inp.Receiver, 113 + TemplateName: "confirm_email_change", 114 + Options: map[string]string{ 115 + "token": inp.Token, 116 + "email": inp.NewEmail, 117 + }, 118 + }) 119 + if err != nil { 120 + return err 121 + } 122 + 123 + resp, err := m.nc.RequestWithContext(ctx, sendTopic, req) 124 + if err != nil { 125 + return err 126 + } 127 + 128 + return events.CheckRespForError(resp) 129 +}
M
internal/models/tokens.go
··· 2 2 3 3 import ( 4 4 "errors" 5 + "net/mail" 5 6 "time" 6 7 7 8 "github.com/gofrs/uuid/v5" ··· 10 11 var ( 11 12 ErrResetPasswordTokenExpired = errors.New("reset password token expired") 12 13 ErrResetPasswordTokenNotFound = errors.New("reset password token not found") 14 + 15 + ErrChangeEmailTokenExpired = errors.New("change email token expired") 16 + ErrChangeEmailTokenNotFound = errors.New("change email token not found") 17 + ErrChangeEmailTokenIsAlreadyUsed = errors.New("change email token is already used") 13 18 ) 14 19 15 20 type ResetPasswordToken struct { ··· 29 34 CreatedAt time.Time 30 35 ExpiresAt time.Time 31 36 } 37 + 38 +type ChangeEmailToken struct { 39 + UserID uuid.UUID 40 + Token string 41 + NewEmail string 42 + CreatedAt time.Time 43 + ExpiresAt time.Time 44 +} 45 + 46 +func (c ChangeEmailToken) IsExpired() bool { 47 + return c.ExpiresAt.Before(time.Now()) 48 +} 49 + 50 +func (c ChangeEmailToken) Validate() error { 51 + _, err := mail.ParseAddress(c.NewEmail) 52 + if err != nil { 53 + return ErrUserInvalidEmail 54 + } 55 + 56 + return nil 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/changeemailrepo" 16 17 "github.com/olexsmir/onasty/internal/store/psql/noterepo" 17 18 "github.com/olexsmir/onasty/internal/store/psql/passwordtokrepo" 18 19 "github.com/olexsmir/onasty/internal/store/psql/sessionrepo" ··· 49 50 // ResetPassword resets the user's password using the provided reset token. 50 51 ResetPassword(ctx context.Context, inp dtos.ResetPassword) error 51 52 53 + RequestEmailChange(ctx context.Context, userID uuid.UUID, inp dtos.ChangeEmail) error 54 + 55 + ChangeEmail(ctx context.Context, token string) error 56 + 52 57 // GetOAuthURL retrieves the OAuth URL for the specified provider. 53 58 GetOAuthURL(providerName string) (dtos.OAuthRedirect, error) 54 59 ··· 74 79 var _ UserServicer = (*UserSrv)(nil) 75 80 76 81 type UserSrv struct { 77 - userstore userepo.UserStorer 78 - sessionstore sessionrepo.SessionStorer 79 - vertokrepo vertokrepo.VerificationTokenStorer 80 - pwdtokrepo passwordtokrepo.PasswordResetTokenStorer 81 - notestore noterepo.NoteStorer 82 - cache usercache.UserCacheer 82 + userstore userepo.UserStorer 83 + sessionstore sessionrepo.SessionStorer 84 + vertokrepo vertokrepo.VerificationTokenStorer 85 + pwdtokrepo passwordtokrepo.PasswordResetTokenStorer 86 + changeemailrepo changeemailrepo.ChangeEmailStorer 87 + notestore noterepo.NoteStorer 88 + cache usercache.UserCacheer 83 89 84 90 hasher hasher.Hasher 85 91 jwtTokenizer jwtutil.JWTTokenizer ··· 91 97 refreshTokenTTL time.Duration 92 98 verificationTokenTTL time.Duration 93 99 resetPasswordTokenTTL time.Duration 100 + changeEmailTokenTTL time.Duration 94 101 } 95 102 96 103 func New( ··· 98 105 sessionstore sessionrepo.SessionStorer, 99 106 vertokrepo vertokrepo.VerificationTokenStorer, 100 107 pwdtokrepo passwordtokrepo.PasswordResetTokenStorer, 108 + changeemailrepo changeemailrepo.ChangeEmailStorer, 101 109 notestore noterepo.NoteStorer, 102 110 hasher hasher.Hasher, 103 111 jwtTokenizer jwtutil.JWTTokenizer, 104 112 mailermq mailermq.Mailer, 105 113 cache usercache.UserCacheer, 106 114 googleOauth, githubOauth oauth.Provider, 107 - refreshTokenTTL, verificationTokenTTL, resetPasswordTokenTTL time.Duration, 115 + refreshTokenTTL, verificationTokenTTL, resetPasswordTokenTTL, changeEmailTokenTTL time.Duration, 108 116 ) *UserSrv { 109 117 return &UserSrv{ 110 118 userstore: userstore, 111 119 sessionstore: sessionstore, 112 120 vertokrepo: vertokrepo, 113 121 pwdtokrepo: pwdtokrepo, 122 + changeemailrepo: changeemailrepo, 114 123 notestore: notestore, 115 124 cache: cache, 116 125 hasher: hasher, ··· 121 130 refreshTokenTTL: refreshTokenTTL, 122 131 verificationTokenTTL: verificationTokenTTL, 123 132 resetPasswordTokenTTL: resetPasswordTokenTTL, 133 + changeEmailTokenTTL: changeEmailTokenTTL, 124 134 } 125 135 } 126 136 ··· 312 322 } 313 323 314 324 return u.userstore.SetPassword(ctx, uid, hashedPassword) 325 +} 326 + 327 +func (u *UserSrv) RequestEmailChange( 328 + ctx context.Context, 329 + userID uuid.UUID, 330 + inp dtos.ChangeEmail, 331 +) error { 332 + user, err := u.userstore.GetByID(ctx, userID) 333 + if err != nil { 334 + return err 335 + } 336 + 337 + if user.Email == inp.NewEmail { 338 + return models.ErrUserEmailIsAlreadyInUse 339 + } 340 + 341 + token := uuid.Must(uuid.NewV4()).String() 342 + changeEmailInput := models.ChangeEmailToken{ 343 + UserID: userID, 344 + Token: token, 345 + NewEmail: inp.NewEmail, 346 + CreatedAt: time.Now(), 347 + ExpiresAt: time.Now().Add(u.changeEmailTokenTTL), 348 + } 349 + if err := changeEmailInput.Validate(); err != nil { 350 + return err 351 + } 352 + 353 + if err := u.changeemailrepo.Create(ctx, changeEmailInput); err != nil { 354 + return err 355 + } 356 + 357 + if err := u.mailermq.SendChangeEmailConfirmation(ctx, mailermq.SendChangeEmailConfirmationRequest{ 358 + Receiver: user.Email, 359 + Token: token, 360 + NewEmail: inp.NewEmail, 361 + }); err != nil { 362 + return err 363 + } 364 + 365 + return nil 366 +} 367 + 368 +func (u *UserSrv) ChangeEmail(ctx context.Context, givenToken string) error { 369 + token, err := u.changeemailrepo.GetByToken(ctx, givenToken) 370 + if err != nil { 371 + return err 372 + } 373 + 374 + user, err := u.userstore.GetByID(ctx, token.UserID) 375 + if err != nil { 376 + return err 377 + } 378 + 379 + if user.Email == token.NewEmail { 380 + return models.ErrUserEmailIsAlreadyInUse 381 + } 382 + 383 + if err := u.userstore.SetEmail(ctx, token.UserID, token.NewEmail); err != nil { 384 + return err 385 + } 386 + 387 + if err := u.changeemailrepo.MarkAsUsed(ctx, token.Token, time.Now()); err != nil { 388 + return err 389 + } 390 + 391 + return nil 315 392 } 316 393 317 394 func (u *UserSrv) Verify(ctx context.Context, verificationKey string) error {
A
internal/store/psql/changeemailrepo/changeemailrepo.go
··· 1 +package changeemailrepo 2 + 3 +import ( 4 + "context" 5 + "errors" 6 + "time" 7 + 8 + "github.com/jackc/pgx/v4" 9 + "github.com/olexsmir/onasty/internal/models" 10 + "github.com/olexsmir/onasty/internal/store/psqlutil" 11 +) 12 + 13 +type ChangeEmailStorer interface { 14 + // Create create a change email token. 15 + Create(ctx context.Context, input models.ChangeEmailToken) error 16 + 17 + // GetByToken returns change email token by its token. 18 + // Returns [models.ErrChangeEmailTokenNotFound] if not found. 19 + GetByToken(ctx context.Context, token string) (models.ChangeEmailToken, error) 20 + 21 + // MarkAsUsed marks change email token as used. 22 + // If not found, returns [models.ErrChangeEmailTokenNotFound]. 23 + // If token is already used, returns [models.ErrChangeEmailTokenIsAlreadyUsed]. 24 + // If token is expired, returns [models.ErrChangeEmailTokenExpired] 25 + MarkAsUsed(ctx context.Context, token string, usedAT time.Time) error 26 +} 27 + 28 +var _ ChangeEmailStorer = (*ChangeEmailRepo)(nil) 29 + 30 +type ChangeEmailRepo struct { 31 + db *psqlutil.DB 32 +} 33 + 34 +func New(db *psqlutil.DB) *ChangeEmailRepo { 35 + return &ChangeEmailRepo{ 36 + db: db, 37 + } 38 +} 39 + 40 +func (c *ChangeEmailRepo) Create(ctx context.Context, inp models.ChangeEmailToken) error { 41 + query := `--sql 42 +insert into change_email_tokens (user_id, new_email, token, created_at, expires_at) 43 +values ($1, $2, $3, $4, $5) 44 +` 45 + 46 + _, err := c.db.Exec(ctx, query, 47 + inp.UserID, inp.NewEmail, inp.Token, inp.CreatedAt, inp.ExpiresAt) 48 + return err 49 +} 50 + 51 +func (c *ChangeEmailRepo) GetByToken( 52 + ctx context.Context, 53 + token string, 54 +) (models.ChangeEmailToken, error) { 55 + query := `--sql 56 +select user_id, new_email, token, created_at, expires_at 57 +from change_email_tokens 58 +where token = $1 59 +` 60 + 61 + var res models.ChangeEmailToken 62 + err := c.db.QueryRow(ctx, query, token). 63 + Scan(&res.UserID, &res.NewEmail, &res.Token, &res.CreatedAt, &res.ExpiresAt) 64 + if errors.Is(err, pgx.ErrNoRows) { 65 + return models.ChangeEmailToken{}, models.ErrChangeEmailTokenNotFound 66 + } 67 + 68 + return res, err 69 +} 70 + 71 +func (c *ChangeEmailRepo) MarkAsUsed(ctx context.Context, token string, usedAT time.Time) error { 72 + tx, err := c.db.Begin(ctx) 73 + if err != nil { 74 + return err 75 + } 76 + defer tx.Rollback(ctx) //nolint:errcheck 77 + 78 + var isUsed bool 79 + var expiresAt time.Time 80 + err = tx.QueryRow(ctx, 81 + "select (used_at is not null), expires_at from change_email_tokens where token = $1", 82 + token). 83 + Scan(&isUsed, &expiresAt) 84 + if err != nil { 85 + if errors.Is(err, pgx.ErrNoRows) { 86 + return models.ErrChangeEmailTokenNotFound 87 + } 88 + return err 89 + } 90 + 91 + if isUsed { 92 + return models.ErrChangeEmailTokenIsAlreadyUsed 93 + } 94 + 95 + if time.Now().After(expiresAt) { 96 + return models.ErrChangeEmailTokenExpired 97 + } 98 + 99 + query := `--sql 100 +update change_email_tokens 101 +set used_at = $1 102 +where token = $2` 103 + 104 + _, err = tx.Exec(ctx, query, usedAT, token) 105 + if err != nil { 106 + return err 107 + } 108 + 109 + return tx.Commit(ctx) 110 +}
M
internal/store/psql/passwordtokrepo/passwordtokrepo.go
··· 42 42 43 43 func (r *PasswordResetTokenRepo) Create(ctx context.Context, token models.ResetPasswordToken, 44 44 ) error { 45 - query, aggs, err := pgq. 45 + query, args, err := pgq. 46 46 Insert("password_reset_tokens"). 47 47 Columns("user_id", "token", "created_at", "expires_at"). 48 48 Values(token.UserID, token.Token, token.CreatedAt, token.ExpiresAt). ··· 51 51 return err 52 52 } 53 53 54 - _, err = r.db.Exec(ctx, query, aggs...) 54 + _, err = r.db.Exec(ctx, query, args...) 55 55 return err 56 56 } 57 57
M
internal/store/psql/userepo/userepo.go
··· 38 38 // password should be hashed 39 39 SetPassword(ctx context.Context, userID uuid.UUID, newPassword string) error 40 40 41 + // SetEmail sets new email for user by their id 42 + SetEmail(ctx context.Context, userID uuid.UUID, email string) error 43 + 41 44 GetByOAuthID(ctx context.Context, provider, providerID string) (models.User, error) 42 45 LinkOAuthIdentity(ctx context.Context, userID uuid.UUID, provider, providerID string) error 43 46 ··· 217 220 return err 218 221 } 219 222 220 - _, err = r.db.Exec(ctx, query, args...) 221 - return err 223 + ct, err := r.db.Exec(ctx, query, args...) 224 + if err != nil { 225 + return err 226 + } 227 + 228 + if ct.RowsAffected() == 0 { 229 + return models.ErrUserNotFound 230 + } 231 + 232 + return nil 233 +} 234 + 235 +func (r *UserRepo) SetEmail(ctx context.Context, userID uuid.UUID, email string) error { 236 + query, args, err := pgq. 237 + Update("users"). 238 + Set("email", email). 239 + Where(pgq.Eq{"id": userID.String()}). 240 + SQL() 241 + if err != nil { 242 + return err 243 + } 244 + 245 + ct, err := r.db.Exec(ctx, query, args...) 246 + if err != nil { 247 + return err 248 + } 249 + 250 + if ct.RowsAffected() == 0 { 251 + return models.ErrUserNotFound 252 + } 253 + 254 + return nil 222 255 } 223 256 224 257 func (r *UserRepo) CheckIfUserExists(ctx context.Context, id uuid.UUID) (bool, error) {
M
internal/transport/http/apiv1/apiv1.go
··· 53 53 oauth.GET("/:provider/callback", a.oauthCallbackHandler) 54 54 } 55 55 56 + auth.GET("/change-email/:token", a.changeEmailHandler) 56 57 authorized := auth.Group("/", a.authorizedMiddleware) 57 58 { 58 59 authorized.POST("/logout", a.logOutHandler) 59 60 authorized.POST("/logout/all", a.logOutAllHandler) 60 61 authorized.POST("/change-password", a.changePasswordHandler) 62 + authorized.POST("/change-email", a.requestEmailChangeHandler) 61 63 } 62 64 } 63 65
M
internal/transport/http/apiv1/auth.go
··· 220 220 c.Status(http.StatusOK) 221 221 } 222 222 223 +type changeEmailRequest struct { 224 + NewEmail string `json:"new_email"` 225 +} 226 + 227 +func (a *APIV1) requestEmailChangeHandler(c *gin.Context) { 228 + var req changeEmailRequest 229 + if err := c.ShouldBindJSON(&req); err != nil { 230 + newError(c, http.StatusBadRequest, "invalid request") 231 + return 232 + } 233 + 234 + if err := a.usersrv.RequestEmailChange( 235 + c.Request.Context(), 236 + a.getUserID(c), 237 + dtos.ChangeEmail{ 238 + NewEmail: req.NewEmail, 239 + }); err != nil { 240 + errorResponse(c, err) 241 + return 242 + } 243 + 244 + c.Status(http.StatusOK) 245 +} 246 + 247 +func (a *APIV1) changeEmailHandler(c *gin.Context) { 248 + if err := a.usersrv.ChangeEmail(c.Request.Context(), c.Param("token")); err != nil { 249 + errorResponse(c, err) 250 + return 251 + } 252 + 253 + c.String(http.StatusOK, "email changed") 254 +} 255 + 223 256 const oatuhStateCookie = "oauth_state" 224 257 225 258 func (a *APIV1) oauthLoginHandler(c *gin.Context) {
M
mailer/README.md
··· 39 39 - `token` the token that is used in verification link 40 40 - `reset_password` 41 41 - `token` the token that is used in password reset link 42 +- `confirm_email_change` 43 + - `email` the email user want to set as new 44 + - `token` the token that is used in confirm link
M
mailer/template.go
··· 20 20 return emailVerificationTemplate(appURL), nil 21 21 case "reset_password": 22 22 return passwordResetTemplate(frontendURL), nil 23 + case "confirm_email_change": 24 + return confirmEmailChangeTemplate(appURL), nil 23 25 default: 24 26 return nil, ErrInvalidTemplate 25 27 } ··· 27 29 28 30 func emailVerificationTemplate(appURL string) TemplateFunc { 29 31 return func(opts map[string]string) Template { 32 + link := fmt.Sprintf("%[1]s/api/v1/auth/verify/%[2]s", appURL, opts["token"]) 33 + 30 34 return Template{ 31 35 Subject: "Onasty: verify your email", 32 36 Body: fmt.Sprintf(`To verify your email, please follow this link: 33 -<a href="%[1]s/api/v1/auth/verify/%[2]s">%[1]s/api/v1/auth/verify/%[2]s</a> 34 -<br /> 35 -<br /> 36 -This link will expire after 24 hours.`, appURL, opts["token"]), 37 +<a href="%[1]s">%[1]s</a> 38 +<br> 39 +<br> 40 +This link will expire after 24 hours.`, link), 37 41 } 38 42 } 39 43 } 40 44 41 45 func passwordResetTemplate(frontendURL string) TemplateFunc { 42 46 return func(opts map[string]string) Template { 47 + link := fmt.Sprintf("%[1]s/auth?token=%[2]s", frontendURL, opts["token"]) 48 + 43 49 return Template{ 44 50 Subject: "Onasty: reset your password", 45 51 Body: fmt.Sprintf(`To reset your password, use this api: 46 -<a href="%[1]s/auth?token=%[2]s">%[1]s/auth?token=%[2]s</a> 47 -<br /> 48 -<br /> 49 -This link will expire after an hour.`, frontendURL, opts["token"]), 52 +<a href="%[1]s">%[1]s</a> 53 +<br> 54 +<br> 55 +This link will expire after an hour.`, link), 56 + } 57 + } 58 +} 59 + 60 +func confirmEmailChangeTemplate(appURL string) TemplateFunc { 61 + return func(opts map[string]string) Template { 62 + link := fmt.Sprintf("%[1]s/api/v1/auth/change-email/%[2]s", appURL, opts["token"]) 63 + 64 + return Template{ 65 + Subject: "Onasty: confirm your email change", 66 + Body: fmt.Sprintf(` 67 +It seems like you have changed your email address to %[1]s. 68 +<br> 69 +To confirm this change, please follow this link: 70 +<a href="%[2]s">%[2]s</a> 71 +<br> 72 +<br> 73 +If you did not request email change, you can ignore this message. 74 +<br> 75 +This link will expire after 24 hours. 76 +`, opts["email"], link), 50 77 } 51 78 } 52 79 }
A
migrations/20250821143449_change_email_tokens.up.sql
··· 1 +CREATE TABLE change_email_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 + new_email varchar(255) NOT NULL UNIQUE, 6 + created_at timestamptz NOT NULL DEFAULT now(), 7 + expires_at timestamptz NOT NULL, 8 + used_at timestamptz DEFAULT NULL 9 +);