all repos

onasty @ 7236f0c

a one-time notes service
22 files changed, 440 insertions(+), 39 deletions(-)
feat: reset password (#110)

* setup boilerplate

* mailer: add template for password resetting

* feat: add password reset tokens repo

* feat(auth): implement "request password reset"

* fix e2e

* feat(auth): implement password reset

* add some more todo comments

* feat(usersrv): add reset password token ttl to config

* feat(usersrv): validate user password

* refactor: return error if token is already used

* refactor(mailermq): move topic to a var

* fixup! feat(usersrv): validate user password

* fix(auth): typos

* fixup! fix e2e

* fix(usersrv): fix constructor

* fix: naming and typos

* fixup! fix: naming and typos

* fixup! feat: add password reset tokens repo

* test(e2e): check sent emails

* test(e2e): test reset password

* chore: update todo comment

* refactor(passwordtokrepo): use model instead of passing every value by hands

* fix(auth): expired token

* fixup! refactor(passwordtokrepo): use model instead of passing every value by hands
Author: Smirnov Oleksandr ss2316544@gmail.com
Committed by: GitHub noreply@github.com
Committed at: 2025-05-17 16:25:31 +0300
Parent: 5495cb4
M .env.example

@@ -39,6 +39,7 @@

NATS_URL="nats:4222" VERIFICATION_TOKEN_TTL=48h +RESET_PASSWORD_TOKEN_TTL=1h RATELIMITER_RPS=100 RATELIMITER_BURST=10
M Taskfile.yml

@@ -8,6 +8,7 @@ migrate: ./migrations/Taskfile.yml

env: DOCKER_BUILDKIT: 1 + COMPOSE_DOCKER_CLI_BUILD: 1 tasks: run:
M cmd/server/main.go

@@ -21,6 +21,7 @@ "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/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"

@@ -95,6 +96,7 @@ mailermq := mailermq.New(nc)

sessionrepo := sessionrepo.New(psqlDB) vertokrepo := vertokrepo.New(psqlDB) + pwdtokrepo := passwordtokrepo.NewPasswordResetTokenRepo(psqlDB) userepo := userepo.New(psqlDB) usercache := usercache.New(redisDB, cfg.CacheUsersTTL)

@@ -102,6 +104,7 @@ usersrv := usersrv.New(

userepo, sessionrepo, vertokrepo, + pwdtokrepo, userPasswordHasher, jwtTokenizer, mailermq,

@@ -110,6 +113,7 @@ googleOauth,

githubOauth, cfg.JwtRefreshTokenTTL, cfg.VerificationTokenTTL, + cfg.ResetPasswordTokenTTL, ) notecache := notecache.New(redisDB, cfg.CacheNoteTTL)
M e2e/apiv1_auth_test.go

@@ -102,6 +102,8 @@ e.Equal(http.StatusCreated, httpResp.Code)

user := e.getLastUserByEmail(email) token := e.getVerificationTokenByUserID(user.ID) + e.Equal(token.Token, mockMailStore[email]) + httpResp = e.httpRequest(http.MethodGet, "/api/v1/auth/verify/"+token.Token, nil) e.Equal(http.StatusOK, httpResp.Code)

@@ -136,6 +138,7 @@ }),

) e.Equal(http.StatusOK, httpResp.Code) + e.NotEmpty(mockMailStore[email]) } func (e *AppTestSuite) TestAuthV1_ResendVerificationEmail_wrong() {

@@ -172,8 +175,7 @@ Password: t.password,

})) e.Equal(httpResp.Code, t.expectedCode) - - // TODO: no email should be sent + e.Empty(mockMailStore[t.email]) } }

@@ -342,6 +344,65 @@

userDB := e.getUserByUsername(username) e.Equal(userDB.Username, username) e.NoError(e.hasher.Compare(userDB.Password, newPassword)) +} + +type ( + apiV1AuthResetPasswordRequest struct { + Email string `json:"email"` + } + apiV1AuthSetPasswordRequest struct { + Password string `json:"password"` + } +) + +func (e *AppTestSuite) TestAuthV1_ResetPassword() { + email := e.uuid() + "@test.com" + uname := e.uuid() + uid, _ := e.createAndSingIn(email, uname, "password") + + httpResp := e.httpRequest( + http.MethodPost, + "/api/v1/auth/reset-password", + e.jsonify(apiV1AuthResetPasswordRequest{ + Email: email, + }), + ) + + e.Equal(httpResp.Code, http.StatusOK) + + token := e.getResetPasswordTokenByUserID(uid) + e.Empty(token.UsedAt) + e.Equal(mockMailStore[email], token.Token) + + // set new password + password := e.uuid() + httpResp = e.httpRequest( + http.MethodPost, + "/api/v1/auth/reset-password/"+token.Token, + e.jsonify(apiV1AuthSetPasswordRequest{ + Password: password, + }), + ) + + dbUser := e.getUserByUsername(uname) + e.Equal(httpResp.Code, http.StatusOK) + e.NoError(e.hasher.Compare(dbUser.Password, password)) + + token = e.getResetPasswordTokenByUserID(uid) + e.NotEmpty(token.UsedAt) +} + +func (e *AppTestSuite) TestAuthV1_ResetPassword_nonExistentUser() { + _, _ = e.createAndSingIn(e.uuid()+"@test.comd", e.uuid(), "password") + httpResp := e.httpRequest( + http.MethodPost, + "/api/v1/auth/reset-password", + e.jsonify(apiV1AuthResetPasswordRequest{ + Email: e.uuid() + "@testing.com", + }), + ) + + e.Equal(httpResp.Code, http.StatusBadRequest) } // createAndSingIn creates an activated username, logs them in,
M e2e/e2e_test.go

@@ -19,6 +19,7 @@ "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/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"

@@ -103,6 +104,7 @@ e.jwtTokenizer = jwtutil.NewJWTUtil(cfg.JwtSigningKey, time.Hour)

sessionrepo := sessionrepo.New(e.postgresDB) vertokrepo := vertokrepo.New(e.postgresDB) + pwdtokrepo := passwordtokrepo.NewPasswordResetTokenRepo(e.postgresDB) oauthProvider := newOauthProviderMock()

@@ -112,6 +114,7 @@ usersrv := usersrv.New(

userepo, sessionrepo, vertokrepo, + pwdtokrepo, e.hasher, e.jwtTokenizer, newMailerMockService(),

@@ -120,6 +123,7 @@ oauthProvider,

oauthProvider, cfg.JwtRefreshTokenTTL, cfg.VerificationTokenTTL, + cfg.ResetPasswordTokenTTL, ) notecache := notecache.New(e.redisDB, cfg.CacheUsersTTL)
M e2e/e2e_utils_db_test.go

@@ -166,3 +166,18 @@ err = e.postgresDB.QueryRow(e.ctx, query, args...).Scan(&r.Token, &r.UsedAt)

e.require.NoError(err) return r } + +func (e *AppTestSuite) getResetPasswordTokenByUserID(u uuid.UUID) userVerificationToken { + query, args, err := pgq. + Select("token", "used_at"). + From("password_reset_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.UsedAt) + e.require.NoError(err) + return r +}
M e2e/mailer_mock_test.go

@@ -8,15 +8,26 @@ )

var _ mailermq.Mailer = (*mailerMockService)(nil) +var mockMailStore = make(map[string]string) + type mailerMockService struct{} func newMailerMockService() *mailerMockService { return &mailerMockService{} } -func (m mailerMockService) SendVerificationEmail( +func (m *mailerMockService) SendVerificationEmail( + _ context.Context, + i mailermq.SendVerificationEmailRequest, +) error { + mockMailStore[i.Receiver] = i.Token + return nil +} + +func (m *mailerMockService) SendPasswordResetEmail( _ context.Context, - _ mailermq.SendVerificationEmailRequest, + i mailermq.SendPasswordResetEmailRequest, ) error { + mockMailStore[i.Receiver] = i.Token return nil }
M internal/config/config.go

@@ -40,7 +40,8 @@ GitHubClientID string

GitHubSecret string GitHubRedirectURL string - VerificationTokenTTL time.Duration + VerificationTokenTTL time.Duration + ResetPasswordTokenTTL time.Duration MetricsEnabled bool MetricsPort int

@@ -92,9 +93,8 @@ GitHubClientID: getenvOrDefault("GITHUB_CLIENTID", ""),

GitHubSecret: getenvOrDefault("GITHUB_SECRET", ""), GitHubRedirectURL: getenvOrDefault("GITHUB_REDIRECTURL", ""), - VerificationTokenTTL: mustParseDuration( - getenvOrDefault("VERIFICATION_TOKEN_TTL", "24h"), - ), + VerificationTokenTTL: mustParseDuration(getenvOrDefault("VERIFICATION_TOKEN_TTL", "24h")), + ResetPasswordTokenTTL: mustParseDuration(getenvOrDefault("RESET_PASSWORD_TOKEN_TTL", "1h")), MetricsPort: mustGetenvOrDefaultInt("METRICS_PORT", 3001), MetricsEnabled: getenvOrDefault("METRICS_ENABLED", "true") == "true",
M internal/dtos/user.go

@@ -22,6 +22,15 @@ CurrentPassword string

NewPassword string } +type RequestResetPassword struct { + Email string +} + +type ResetPassword struct { + Token string + NewPassword string +} + type Tokens struct { Access string Refresh string
M internal/events/mailermq/mailermq.go

@@ -9,8 +9,11 @@ "github.com/olexsmir/onasty/internal/events"

"github.com/olexsmir/onasty/internal/transport/http/reqid" ) +const sendTopic = "mailer.send" + type Mailer interface { SendVerificationEmail(ctx context.Context, input SendVerificationEmailRequest) error + SendPasswordResetEmail(ctx context.Context, input SendPasswordResetEmailRequest) error } type MailerMQ struct {

@@ -51,7 +54,36 @@ if err != nil {

return err } - resp, err := m.nc.RequestWithContext(ctx, "mailer.send", req) + resp, err := m.nc.RequestWithContext(ctx, sendTopic, req) + if err != nil { + return err + } + + return events.CheckRespForError(resp) +} + +type SendPasswordResetEmailRequest struct { + Receiver string + Token string +} + +func (m MailerMQ) SendPasswordResetEmail( + ctx context.Context, + inp SendPasswordResetEmailRequest, +) error { + req, err := json.Marshal(sendRequest{ + RequestID: reqid.GetContext(ctx), + Receiver: inp.Receiver, + TemplateName: "reset_password", + Options: map[string]string{ + "token": inp.Token, + }, + }) + if err != nil { + return err + } + + resp, err := m.nc.RequestWithContext(ctx, sendTopic, req) if err != nil { return err }
A internal/models/tokens.go

@@ -0,0 +1,24 @@

+package models + +import ( + "errors" + "time" + + "github.com/gofrs/uuid/v5" +) + +var ( + ErrResetPasswordTokenExpired = errors.New("reset password token expired") + ErrResetPasswordTokenNotFound = errors.New("reset password token not found") +) + +type ResetPasswordToken struct { + UserID uuid.UUID + Token string + CreatedAt time.Time + ExpiresAt time.Time +} + +func (p ResetPasswordToken) IsExpired() bool { + return p.ExpiresAt.Before(time.Now()) +}
M internal/models/user.go

@@ -13,8 +13,9 @@ ErrUserEmailIsAlreadyInUse = errors.New("user: email is already in use")

ErrUsernameIsAlreadyInUse = errors.New("user: username is already in use") ErrUserIsAlreadyVerified = errors.New("user: user is already verified") - ErrVerificationTokenNotFound = errors.New("user: verification token not found") - ErrUserIsNotActivated = errors.New("user: user is not activated") + ErrResetPasswordTokenAlreadyUsed = errors.New("reset password token is already used") + ErrVerificationTokenNotFound = errors.New("user: verification token not found") + ErrUserIsNotActivated = errors.New("user: user is not activated") ErrUserNotFound = errors.New("user: not found") ErrUserWrongCredentials = errors.New("user: wrong credentials")

@@ -40,14 +41,17 @@ if err != nil {

return ErrUserInvalidEmail } - if len(u.Password) < 6 { - return ErrUserInvalidPassword - } - if len(u.Username) == 0 { return ErrUserInvalidUsername } + return u.ValidatePassword() +} + +func (u User) ValidatePassword() error { + if len(u.Password) < 6 { + return ErrUserInvalidPassword + } return nil }
M internal/service/usersrv/usersrv.go

@@ -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/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"

@@ -26,6 +27,8 @@ RefreshTokens(ctx context.Context, refreshToken string) (dtos.Tokens, error)

Logout(ctx context.Context, userID uuid.UUID) error ChangePassword(ctx context.Context, userID uuid.UUID, inp dtos.ChangeUserPassword) error + RequestPasswordReset(ctx context.Context, inp dtos.RequestResetPassword) error + ResetPassword(ctx context.Context, inp dtos.ResetPassword) error GetOAuthURL(providerName string) (string, error) HandleOAuthLogin(ctx context.Context, providerName, code string) (dtos.Tokens, error)

@@ -45,40 +48,47 @@ type UserSrv struct {

userstore userepo.UserStorer sessionstore sessionrepo.SessionStorer vertokrepo vertokrepo.VerificationTokenStorer + pwdtokrepo passwordtokrepo.PasswordResetTokenStorer + cache usercache.UserCacheer + hasher hasher.Hasher jwtTokenizer jwtutil.JWTTokenizer mailermq mailermq.Mailer - cache usercache.UserCacheer - googleOauth oauth.Provider - githubOauth oauth.Provider - refreshTokenTTL time.Duration - verificationTokenTTL time.Duration + googleOauth oauth.Provider + githubOauth oauth.Provider + + refreshTokenTTL time.Duration + verificationTokenTTL time.Duration + resetPasswordTokenTTL time.Duration } func New( userstore userepo.UserStorer, sessionstore sessionrepo.SessionStorer, vertokrepo vertokrepo.VerificationTokenStorer, + pwdtokrepo passwordtokrepo.PasswordResetTokenStorer, hasher hasher.Hasher, jwtTokenizer jwtutil.JWTTokenizer, mailermq mailermq.Mailer, cache usercache.UserCacheer, googleOauth, githubOauth oauth.Provider, - refreshTokenTTL, verificationTokenTTL time.Duration, + refreshTokenTTL, verificationTokenTTL, resetPasswordTokenTTL time.Duration, ) *UserSrv { return &UserSrv{ - userstore: userstore, - sessionstore: sessionstore, - vertokrepo: vertokrepo, - hasher: hasher, - jwtTokenizer: jwtTokenizer, - mailermq: mailermq, - cache: cache, - googleOauth: googleOauth, - githubOauth: githubOauth, - refreshTokenTTL: refreshTokenTTL, - verificationTokenTTL: verificationTokenTTL, + userstore: userstore, + sessionstore: sessionstore, + vertokrepo: vertokrepo, + pwdtokrepo: pwdtokrepo, + cache: cache, + hasher: hasher, + jwtTokenizer: jwtTokenizer, + mailermq: mailermq, + googleOauth: googleOauth, + githubOauth: githubOauth, + refreshTokenTTL: refreshTokenTTL, + verificationTokenTTL: verificationTokenTTL, + resetPasswordTokenTTL: resetPasswordTokenTTL, } }

@@ -180,6 +190,11 @@ inp dtos.ChangeUserPassword,

) error { // TODO: compare current password with providede, and assert on mismatch + //nolint:exhaustruct + if err := (models.User{Password: inp.NewPassword}).ValidatePassword(); err != nil { + return err + } + oldPass, err := u.hasher.Hash(inp.CurrentPassword) if err != nil { return err

@@ -195,6 +210,51 @@ return err

} return nil +} + +func (u *UserSrv) RequestPasswordReset(ctx context.Context, inp dtos.RequestResetPassword) error { + user, err := u.userstore.GetByEmail(ctx, inp.Email) + if err != nil { + return err + } + + token := uuid.Must(uuid.NewV4()).String() + if err := u.pwdtokrepo.Create(ctx, models.ResetPasswordToken{ + UserID: user.ID, + Token: token, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(u.resetPasswordTokenTTL), + }); err != nil { + return err + } + + if err := u.mailermq.SendPasswordResetEmail(ctx, mailermq.SendPasswordResetEmailRequest{ + Receiver: inp.Email, + Token: token, + }); err != nil { + return err + } + + return nil +} + +func (u *UserSrv) ResetPassword(ctx context.Context, inp dtos.ResetPassword) error { + //nolint:exhaustruct + if err := (models.User{Password: inp.NewPassword}).ValidatePassword(); err != nil { + return err + } + + uid, err := u.pwdtokrepo.GetUserIDByTokenAndMarkAsUsed(ctx, inp.Token, time.Now()) + if err != nil { + return err + } + + hashedPassword, err := u.hasher.Hash(inp.NewPassword) + if err != nil { + return err + } + + return u.userstore.SetPassword(ctx, uid, hashedPassword) } func (u *UserSrv) Verify(ctx context.Context, verificationKey string) error {
A internal/store/psql/passwordtokrepo/passwordtokrepo.go

@@ -0,0 +1,95 @@

+package passwordtokrepo + +import ( + "context" + "errors" + "time" + + "github.com/gofrs/uuid/v5" + "github.com/henvic/pgq" + "github.com/jackc/pgx/v5" + "github.com/olexsmir/onasty/internal/models" + "github.com/olexsmir/onasty/internal/store/psqlutil" +) + +type PasswordResetTokenStorer interface { + Create(ctx context.Context, input models.ResetPasswordToken) error + + GetUserIDByTokenAndMarkAsUsed( + ctx context.Context, + token string, + usedAT time.Time, + ) (uuid.UUID, error) +} + +var _ PasswordResetTokenStorer = (*PasswordResetTokenRepo)(nil) + +type PasswordResetTokenRepo struct { + db *psqlutil.DB +} + +func NewPasswordResetTokenRepo(db *psqlutil.DB) *PasswordResetTokenRepo { + return &PasswordResetTokenRepo{ + db: db, + } +} + +func (r *PasswordResetTokenRepo) Create(ctx context.Context, token models.ResetPasswordToken, +) error { + query, aggs, err := pgq. + Insert("password_reset_tokens"). + Columns("user_id", "token", "created_at", "expires_at"). + Values(token.UserID, token.Token, token.CreatedAt, token.ExpiresAt). + SQL() + if err != nil { + return err + } + + _, err = r.db.Exec(ctx, query, aggs...) + return err +} + +func (r *PasswordResetTokenRepo) GetUserIDByTokenAndMarkAsUsed( + ctx context.Context, + token string, + usedAt time.Time, +) (uuid.UUID, error) { + tx, err := r.db.Begin(ctx) + if err != nil { + return uuid.Nil, 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 password_reset_tokens where token = $1", token). + Scan(&isUsed, &expiresAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, models.ErrResetPasswordTokenNotFound + } + return uuid.Nil, err + } + + if isUsed { + return uuid.Nil, models.ErrResetPasswordTokenAlreadyUsed + } + + if time.Now().After(expiresAt) { + return uuid.Nil, models.ErrResetPasswordTokenExpired + } + + query := `--sql +update password_reset_tokens +set used_at = $1 +where token = $2 +returning user_id` + + var userID uuid.UUID + err = tx.QueryRow(ctx, query, usedAt, token).Scan(&userID) + if err != nil { + return uuid.Nil, err + } + + return userID, tx.Commit(ctx) +}
M internal/transport/http/apiv1/apiv1.go

@@ -30,6 +30,8 @@ auth.POST("/signin", a.signInHandler)

auth.POST("/refresh-tokens", a.refreshTokensHandler) auth.GET("/verify/:token", a.verifyHandler) auth.POST("/resend-verification-email", a.resendVerificationEmailHandler) + auth.POST("/reset-password", a.requestResetPasswordHandler) + auth.POST("/reset-password/:token", a.resetPasswordHandler) authorized := auth.Group("/", a.authorizedMiddleware) {
M internal/transport/http/apiv1/auth.go

@@ -119,6 +119,52 @@

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) +} + func (a *APIV1) logOutHandler(c *gin.Context) { if err := a.usersrv.Logout(c.Request.Context(), a.getUserID(c)); err != nil { errorResponse(c, err)
M internal/transport/http/apiv1/response.go

@@ -18,6 +18,8 @@ }

func errorResponse(c *gin.Context, err error) { if errors.Is(err, usersrv.ErrProviderNotSupported) || + errors.Is(err, models.ErrResetPasswordTokenAlreadyUsed) || + errors.Is(err, models.ErrResetPasswordTokenExpired) || errors.Is(err, models.ErrUserEmailIsAlreadyInUse) || errors.Is(err, models.ErrUsernameIsAlreadyInUse) || errors.Is(err, models.ErrUserIsAlreadyVerified) ||
M mailer/README.md

@@ -37,5 +37,5 @@

#### Template specific options - `email_verification` - `token` the token that is used in verification link - - +- `reset_password` + - `token` the token that is used in password reset link
M mailer/service.go

@@ -33,11 +33,15 @@

go func() { select { case <-ctx.Done(): - slog.ErrorContext(ctx, "failed to send verification email", "err", ctx.Err()) + slog.ErrorContext(ctx, "failed to send email", + "template_name", templateName, + "err", ctx.Err()) return default: if err := s.mg.Send(ctx, receiver, t.Subject, t.Body); err != nil { - slog.ErrorContext(ctx, "failed to send verification email", "err", err) + slog.ErrorContext(ctx, "failed to send email", + "template_name", templateName, + "err", err) } cancel() }
M mailer/template.go

@@ -13,11 +13,14 @@

type TemplateFunc func(args map[string]string) Template func getTemplate(appURL string, templateName string) (TemplateFunc, error) { - if templateName == "email_verification" { + switch templateName { + case "email_verification": return emailVerificationTemplate(appURL), nil + case "reset_password": + return passwordResetTemplate(appURL), nil + default: + return nil, errors.New("failed to get template") //nolint:err113 } - - return nil, errors.New("failed to get template") //nolint:err113 } func emailVerificationTemplate(appURL string) TemplateFunc {

@@ -32,3 +35,17 @@ This link will expire after 24 hours.`, appURL, opts["token"]),

} } } + +func passwordResetTemplate(appURL string) TemplateFunc { + return func(opts map[string]string) Template { + return Template{ + Subject: "Onasty: reset your password", + // TODO: when ui is ready, change the link to the ui + Body: fmt.Sprintf(`To reset your password, use this api: +<a href="%[1]s/api/v1/auth/reset-password/%[2]s">%[1]s/api/v1/auth/reset-password/%[2]s</a> +<br /> +<br /> +This link will expire after an hour.`, appURL, opts["token"]), + } + } +}
A migrations/20250509131258_password_reset_tokens.down.sql

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

+DROP TABLE password_reset_tokens;
A migrations/20250509131258_password_reset_tokens.up.sql

@@ -0,0 +1,8 @@

+CREATE TABLE password_reset_tokens ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4 (), + user_id uuid NOT NULL REFERENCES users (id), + token varchar(255) NOT NULL UNIQUE, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + used_at timestamptz DEFAULT NULL +);