@@ -20,6 +20,7 @@ "github.com/olexsmir/onasty/internal/metrics"
"github.com/olexsmir/onasty/internal/oauth" "github.com/olexsmir/onasty/internal/service/notesrv" "github.com/olexsmir/onasty/internal/service/usersrv" + "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"@@ -94,6 +95,7 @@
sessionrepo := sessionrepo.New(psqlDB) vertokrepo := vertokrepo.New(psqlDB) pwdtokrepo := passwordtokrepo.NewPasswordResetTokenRepo(psqlDB) + changeemailrepo := changeemailrepo.New(psqlDB) notecache := notecache.New(redisDB, cfg.CacheNoteTTL) noterepo := noterepo.New(psqlDB)@@ -106,6 +108,7 @@ userepo,
sessionrepo, vertokrepo, pwdtokrepo, + changeemailrepo, noterepo, userPasswordHasher, jwtTokenizer,@@ -116,6 +119,7 @@ githubOauth,
cfg.JwtRefreshTokenTTL, cfg.VerificationTokenTTL, cfg.ResetPasswordTokenTTL, + cfg.ChangeEmailTokenTTL, ) rateLimiterConfig := ratelimit.Config{
@@ -1,6 +1,9 @@
package e2e_test import ( + "crypto/rand" + "encoding/hex" + "fmt" "net/http" "time"@@ -447,6 +450,61 @@
e.Equal(httpResp.Code, http.StatusBadRequest) } +type apiv1AuthChangeEmailRequest struct { + NewEmail string `json:"new_email"` +} + +func (e *AppTestSuite) TestAuthV1_ChangeEmail() { + oldEmail, newEmail := e.randomEmail(), e.randomEmail() + uid, toks := e.createAndSingIn(oldEmail, e.uuid()) + + // request email change + httpResp := e.httpRequest( + http.MethodPost, + "/api/v1/auth/change-email", + e.jsonify(apiv1AuthChangeEmailRequest{ + NewEmail: newEmail, + }), + toks.AccessToken, + ) + e.Equal(http.StatusOK, httpResp.Code) + + token := e.getChangeEmailTokenByUserID(uid) + e.Empty(token.UsedAt) + e.Equal(mockMailStore[oldEmail], token.Token) + + // confirm email change + httpResp = e.httpRequest(http.MethodGet, "/api/v1/auth/change-email/"+token.Token, nil) + e.Equal(http.StatusOK, httpResp.Code) + + updatedToken := e.getChangeEmailTokenByUserID(uid) + e.NotEmpty(updatedToken.UsedAt) + + dbUser := e.getUserByEmail(token.Extra) + e.Equal(dbUser.Email, newEmail) +} + +func (e *AppTestSuite) TestAuthV1_ChangeEmail_wrongSameEmail() { + email := e.randomEmail() + _, toks := e.createAndSingIn(email, e.uuid()) + + // request email change + httpResp := e.httpRequest( + http.MethodPost, + "/api/v1/auth/change-email", + e.jsonify(apiv1AuthChangeEmailRequest{ + NewEmail: email, + }), + toks.AccessToken, + ) + e.Equal(http.StatusBadRequest, httpResp.Code) + + var body errorResponse + e.readBodyAndUnjsonify(httpResp.Body, &body) + + e.Equal(body.Message, models.ErrUserEmailIsAlreadyInUse.Error()) +} + type getMeResponse struct { Email string `json:"email"` CreatedAt time.Time `json:"created_at"`@@ -500,3 +558,9 @@ e.readBodyAndUnjsonify(httpResp.Body, &body)
return uid, body } + +func (e *AppTestSuite) randomEmail() string { + b := make([]byte, 4) + _, _ = rand.Read(b) + return fmt.Sprintf("user-%s@test.local", hex.EncodeToString(b)) +}
@@ -17,6 +17,7 @@ "github.com/olexsmir/onasty/internal/jwtutil"
"github.com/olexsmir/onasty/internal/logger" "github.com/olexsmir/onasty/internal/service/notesrv" "github.com/olexsmir/onasty/internal/service/usersrv" + "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"@@ -102,6 +103,7 @@
sessionrepo := sessionrepo.New(e.postgresDB) vertokrepo := vertokrepo.New(e.postgresDB) pwdtokrepo := passwordtokrepo.NewPasswordResetTokenRepo(e.postgresDB) + changeemailrepo := changeemailrepo.New(e.postgresDB) stubOAuthProvider := newOauthProviderStub()@@ -116,6 +118,7 @@ userepo,
sessionrepo, vertokrepo, pwdtokrepo, + changeemailrepo, noterepo, e.hasher, e.jwtTokenizer,@@ -126,6 +129,7 @@ stubOAuthProvider,
cfg.JwtRefreshTokenTTL, cfg.VerificationTokenTTL, cfg.ResetPasswordTokenTTL, + cfg.ChangeEmailTokenTTL, ) // for testing purposes, it's ok to have high values ig
@@ -157,6 +157,7 @@ }
type userVerificationToken struct { Token string + Extra string // Extra field (optional) UsedAt *time.Time }@@ -187,3 +188,18 @@ err = e.postgresDB.QueryRow(e.ctx, query, args...).Scan(&r.Token, &r.UsedAt)
e.require.NoError(err) return r } + +func (e *AppTestSuite) getChangeEmailTokenByUserID(u uuid.UUID) userVerificationToken { + query, args, err := pgq. + Select("token", "new_email", "used_at"). + From("change_email_tokens"). + Where(pgq.Eq{"user_id": u.String()}). + Limit(1). + SQL() + + e.require.NoError(err) + var r userVerificationToken + err = e.postgresDB.QueryRow(e.ctx, query, args...).Scan(&r.Token, &r.Extra, &r.UsedAt) + e.require.NoError(err) + return r +}
@@ -31,3 +31,11 @@ ) error {
mockMailStore[i.Receiver] = i.Token return nil } + +func (m *mailerMockService) SendChangeEmailConfirmation( + _ context.Context, + i mailermq.SendChangeEmailConfirmationRequest, +) error { + mockMailStore[i.Receiver] = i.Token + return nil +}
@@ -53,6 +53,7 @@ GitHubRedirectURL string
VerificationTokenTTL time.Duration ResetPasswordTokenTTL time.Duration + ChangeEmailTokenTTL time.Duration MetricsEnabled bool MetricsPort int@@ -112,6 +113,7 @@ GitHubRedirectURL: getenvOrDefault("GITHUB_REDIRECTURL", ""),
VerificationTokenTTL: mustParseDuration(getenvOrDefault("VERIFICATION_TOKEN_TTL", "24h")), ResetPasswordTokenTTL: mustParseDuration(getenvOrDefault("RESET_PASSWORD_TOKEN_TTL", "1h")), + ChangeEmailTokenTTL: mustParseDuration(getenvOrDefault("CHANGE_EMAIL_TOKEN_TTL", "24h")), MetricsPort: mustGetenvOrDefaultInt("METRICS_PORT", 3001), MetricsEnabled: getenvOrDefault("METRICS_ENABLED", "true") == "true",
@@ -50,3 +50,7 @@ CreatedAt time.Time
LastLoginAt time.Time NotesCreated int } + +type ChangeEmail struct { + NewEmail string +}
@@ -17,6 +17,9 @@ SendVerificationEmail(ctx context.Context, input SendVerificationEmailRequest) error
// SendPasswordResetEmail sends an email with a password reset token to the user. SendPasswordResetEmail(ctx context.Context, input SendPasswordResetEmailRequest) error + + // SendChangeEmailVerification sends an email with a change email verification token to the user. + SendChangeEmailConfirmation(ctx context.Context, inp SendChangeEmailConfirmationRequest) error } type MailerMQ struct {@@ -93,3 +96,34 @@ }
return events.CheckRespForError(resp) } + +type SendChangeEmailConfirmationRequest struct { + Receiver string + Token string + NewEmail string +} + +func (m MailerMQ) SendChangeEmailConfirmation( + ctx context.Context, + inp SendChangeEmailConfirmationRequest, +) error { + req, err := json.Marshal(sendRequest{ + RequestID: reqid.GetContext(ctx), + Receiver: inp.Receiver, + TemplateName: "confirm_email_change", + Options: map[string]string{ + "token": inp.Token, + "email": inp.NewEmail, + }, + }) + if err != nil { + return err + } + + resp, err := m.nc.RequestWithContext(ctx, sendTopic, req) + if err != nil { + return err + } + + return events.CheckRespForError(resp) +}
@@ -2,6 +2,7 @@ package models
import ( "errors" + "net/mail" "time" "github.com/gofrs/uuid/v5"@@ -10,6 +11,10 @@
var ( ErrResetPasswordTokenExpired = errors.New("reset password token expired") ErrResetPasswordTokenNotFound = errors.New("reset password token not found") + + ErrChangeEmailTokenExpired = errors.New("change email token expired") + ErrChangeEmailTokenNotFound = errors.New("change email token not found") + ErrChangeEmailTokenIsAlreadyUsed = errors.New("change email token is already used") ) type ResetPasswordToken struct {@@ -29,3 +34,24 @@ Token string
CreatedAt time.Time ExpiresAt time.Time } + +type ChangeEmailToken struct { + UserID uuid.UUID + Token string + NewEmail string + CreatedAt time.Time + ExpiresAt time.Time +} + +func (c ChangeEmailToken) IsExpired() bool { + return c.ExpiresAt.Before(time.Now()) +} + +func (c ChangeEmailToken) Validate() error { + _, err := mail.ParseAddress(c.NewEmail) + if err != nil { + return ErrUserInvalidEmail + } + + return nil +}
@@ -13,6 +13,7 @@ "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"@@ -49,6 +50,10 @@
// ResetPassword resets the user's password using the provided reset token. ResetPassword(ctx context.Context, inp dtos.ResetPassword) error + 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)@@ -74,12 +79,13 @@
var _ UserServicer = (*UserSrv)(nil) type UserSrv struct { - userstore userepo.UserStorer - sessionstore sessionrepo.SessionStorer - vertokrepo vertokrepo.VerificationTokenStorer - pwdtokrepo passwordtokrepo.PasswordResetTokenStorer - notestore noterepo.NoteStorer - cache usercache.UserCacheer + 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@@ -91,6 +97,7 @@
refreshTokenTTL time.Duration verificationTokenTTL time.Duration resetPasswordTokenTTL time.Duration + changeEmailTokenTTL time.Duration } func New(@@ -98,19 +105,21 @@ 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 time.Duration, + refreshTokenTTL, verificationTokenTTL, resetPasswordTokenTTL, changeEmailTokenTTL time.Duration, ) *UserSrv { return &UserSrv{ userstore: userstore, sessionstore: sessionstore, vertokrepo: vertokrepo, pwdtokrepo: pwdtokrepo, + changeemailrepo: changeemailrepo, notestore: notestore, cache: cache, hasher: hasher,@@ -121,6 +130,7 @@ githubOauth: githubOauth,
refreshTokenTTL: refreshTokenTTL, verificationTokenTTL: verificationTokenTTL, resetPasswordTokenTTL: resetPasswordTokenTTL, + changeEmailTokenTTL: changeEmailTokenTTL, } }@@ -312,6 +322,73 @@ return err
} return u.userstore.SetPassword(ctx, uid, hashedPassword) +} + +func (u *UserSrv) RequestEmailChange( + ctx context.Context, + userID uuid.UUID, + inp dtos.ChangeEmail, +) error { + user, err := u.userstore.GetByID(ctx, userID) + if err != nil { + return err + } + + if user.Email == inp.NewEmail { + return models.ErrUserEmailIsAlreadyInUse + } + + token := uuid.Must(uuid.NewV4()).String() + changeEmailInput := models.ChangeEmailToken{ + UserID: userID, + Token: token, + NewEmail: inp.NewEmail, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(u.changeEmailTokenTTL), + } + if err := changeEmailInput.Validate(); err != nil { + return err + } + + if err := u.changeemailrepo.Create(ctx, changeEmailInput); err != nil { + return err + } + + if err := u.mailermq.SendChangeEmailConfirmation(ctx, mailermq.SendChangeEmailConfirmationRequest{ + Receiver: user.Email, + Token: token, + NewEmail: inp.NewEmail, + }); err != nil { + return err + } + + return nil +} + +func (u *UserSrv) ChangeEmail(ctx context.Context, givenToken string) error { + token, err := u.changeemailrepo.GetByToken(ctx, givenToken) + if err != nil { + return err + } + + user, err := u.userstore.GetByID(ctx, token.UserID) + if err != nil { + return err + } + + if user.Email == token.NewEmail { + return models.ErrUserEmailIsAlreadyInUse + } + + if err := u.userstore.SetEmail(ctx, token.UserID, token.NewEmail); err != nil { + return err + } + + if err := u.changeemailrepo.MarkAsUsed(ctx, token.Token, time.Now()); err != nil { + return err + } + + return nil } func (u *UserSrv) Verify(ctx context.Context, verificationKey string) error {
@@ -0,0 +1,110 @@
+package changeemailrepo + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v4" + "github.com/olexsmir/onasty/internal/models" + "github.com/olexsmir/onasty/internal/store/psqlutil" +) + +type ChangeEmailStorer interface { + // Create create a change email token. + Create(ctx context.Context, input models.ChangeEmailToken) error + + // GetByToken returns change email token by its token. + // Returns [models.ErrChangeEmailTokenNotFound] if not found. + GetByToken(ctx context.Context, token string) (models.ChangeEmailToken, error) + + // MarkAsUsed marks change email token as used. + // If not found, returns [models.ErrChangeEmailTokenNotFound]. + // If token is already used, returns [models.ErrChangeEmailTokenIsAlreadyUsed]. + // If token is expired, returns [models.ErrChangeEmailTokenExpired] + MarkAsUsed(ctx context.Context, token string, usedAT time.Time) error +} + +var _ ChangeEmailStorer = (*ChangeEmailRepo)(nil) + +type ChangeEmailRepo struct { + db *psqlutil.DB +} + +func New(db *psqlutil.DB) *ChangeEmailRepo { + return &ChangeEmailRepo{ + db: db, + } +} + +func (c *ChangeEmailRepo) Create(ctx context.Context, inp models.ChangeEmailToken) error { + query := `--sql +insert into change_email_tokens (user_id, new_email, token, created_at, expires_at) +values ($1, $2, $3, $4, $5) +` + + _, err := c.db.Exec(ctx, query, + inp.UserID, inp.NewEmail, inp.Token, inp.CreatedAt, inp.ExpiresAt) + return err +} + +func (c *ChangeEmailRepo) GetByToken( + ctx context.Context, + token string, +) (models.ChangeEmailToken, error) { + query := `--sql +select user_id, new_email, token, created_at, expires_at +from change_email_tokens +where token = $1 +` + + var res models.ChangeEmailToken + err := c.db.QueryRow(ctx, query, token). + Scan(&res.UserID, &res.NewEmail, &res.Token, &res.CreatedAt, &res.ExpiresAt) + if errors.Is(err, pgx.ErrNoRows) { + return models.ChangeEmailToken{}, models.ErrChangeEmailTokenNotFound + } + + return res, err +} + +func (c *ChangeEmailRepo) MarkAsUsed(ctx context.Context, token string, usedAT time.Time) error { + tx, err := c.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck + + var isUsed bool + var expiresAt time.Time + err = tx.QueryRow(ctx, + "select (used_at is not null), expires_at from change_email_tokens where token = $1", + token). + Scan(&isUsed, &expiresAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return models.ErrChangeEmailTokenNotFound + } + return err + } + + if isUsed { + return models.ErrChangeEmailTokenIsAlreadyUsed + } + + if time.Now().After(expiresAt) { + return models.ErrChangeEmailTokenExpired + } + + query := `--sql +update change_email_tokens +set used_at = $1 +where token = $2` + + _, err = tx.Exec(ctx, query, usedAT, token) + if err != nil { + return err + } + + return tx.Commit(ctx) +}
@@ -42,7 +42,7 @@ }
func (r *PasswordResetTokenRepo) Create(ctx context.Context, token models.ResetPasswordToken, ) error { - query, aggs, err := pgq. + query, args, err := pgq. Insert("password_reset_tokens"). Columns("user_id", "token", "created_at", "expires_at"). Values(token.UserID, token.Token, token.CreatedAt, token.ExpiresAt).@@ -51,7 +51,7 @@ if err != nil {
return err } - _, err = r.db.Exec(ctx, query, aggs...) + _, err = r.db.Exec(ctx, query, args...) return err }
@@ -38,6 +38,9 @@ // SetPassword sets new password for user by their id
// password should be hashed SetPassword(ctx context.Context, userID uuid.UUID, newPassword string) error + // SetEmail sets new email for user by their id + SetEmail(ctx context.Context, userID uuid.UUID, email string) error + GetByOAuthID(ctx context.Context, provider, providerID string) (models.User, error) LinkOAuthIdentity(ctx context.Context, userID uuid.UUID, provider, providerID string) error@@ -217,8 +220,38 @@ if err != nil {
return err } - _, err = r.db.Exec(ctx, query, args...) - return err + ct, err := r.db.Exec(ctx, query, args...) + if err != nil { + return err + } + + if ct.RowsAffected() == 0 { + return models.ErrUserNotFound + } + + return nil +} + +func (r *UserRepo) SetEmail(ctx context.Context, userID uuid.UUID, email string) error { + query, args, err := pgq. + Update("users"). + Set("email", email). + Where(pgq.Eq{"id": userID.String()}). + SQL() + if err != nil { + return err + } + + ct, err := r.db.Exec(ctx, query, args...) + if err != nil { + return err + } + + if ct.RowsAffected() == 0 { + return models.ErrUserNotFound + } + + return nil } func (r *UserRepo) CheckIfUserExists(ctx context.Context, id uuid.UUID) (bool, error) {
@@ -53,11 +53,13 @@ oauth.GET("/:provider", a.oauthLoginHandler)
oauth.GET("/:provider/callback", a.oauthCallbackHandler) } + auth.GET("/change-email/:token", a.changeEmailHandler) authorized := auth.Group("/", a.authorizedMiddleware) { authorized.POST("/logout", a.logOutHandler) authorized.POST("/logout/all", a.logOutAllHandler) authorized.POST("/change-password", a.changePasswordHandler) + authorized.POST("/change-email", a.requestEmailChangeHandler) } }
@@ -220,6 +220,39 @@
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) {
@@ -39,3 +39,6 @@ - `email_verification`
- `token` the token that is used in verification link - `reset_password` - `token` the token that is used in password reset link +- `confirm_email_change` + - `email` the email user want to set as new + - `token` the token that is used in confirm link
@@ -20,6 +20,8 @@ case "email_verification":
return emailVerificationTemplate(appURL), nil case "reset_password": return passwordResetTemplate(frontendURL), nil + case "confirm_email_change": + return confirmEmailChangeTemplate(appURL), nil default: return nil, ErrInvalidTemplate }@@ -27,26 +29,51 @@ }
func emailVerificationTemplate(appURL string) TemplateFunc { return func(opts map[string]string) Template { + link := fmt.Sprintf("%[1]s/api/v1/auth/verify/%[2]s", appURL, opts["token"]) + return Template{ Subject: "Onasty: verify your email", Body: fmt.Sprintf(`To verify your email, please follow this link: -<a href="%[1]s/api/v1/auth/verify/%[2]s">%[1]s/api/v1/auth/verify/%[2]s</a> -<br /> -<br /> -This link will expire after 24 hours.`, appURL, opts["token"]), +<a href="%[1]s">%[1]s</a> +<br> +<br> +This link will expire after 24 hours.`, link), } } } func passwordResetTemplate(frontendURL string) TemplateFunc { return func(opts map[string]string) Template { + link := fmt.Sprintf("%[1]s/auth?token=%[2]s", frontendURL, opts["token"]) + return Template{ Subject: "Onasty: reset your password", Body: fmt.Sprintf(`To reset your password, use this api: -<a href="%[1]s/auth?token=%[2]s">%[1]s/auth?token=%[2]s</a> -<br /> -<br /> -This link will expire after an hour.`, frontendURL, opts["token"]), +<a href="%[1]s">%[1]s</a> +<br> +<br> +This link will expire after an hour.`, link), + } + } +} + +func confirmEmailChangeTemplate(appURL string) TemplateFunc { + return func(opts map[string]string) Template { + link := fmt.Sprintf("%[1]s/api/v1/auth/change-email/%[2]s", appURL, opts["token"]) + + return Template{ + Subject: "Onasty: confirm your email change", + Body: fmt.Sprintf(` +It seems like you have changed your email address to %[1]s. +<br> +To confirm this change, please follow this link: +<a href="%[2]s">%[2]s</a> +<br> +<br> +If you did not request email change, you can ignore this message. +<br> +This link will expire after 24 hours. +`, opts["email"], link), } } }
@@ -0,0 +1,1 @@
+DROP TABLE change_email_tokens;
@@ -0,0 +1,9 @@
+CREATE TABLE change_email_tokens ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4 (), + user_id uuid NOT NULL REFERENCES users (id), + token varchar(255) NOT NULL UNIQUE, + new_email varchar(255) NOT NULL UNIQUE, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + used_at timestamptz DEFAULT NULL +);