Hi {{ .Name }},
+We received a request to reset the password for {{ .Email }}.
+
+ Set a new password by opening this link:
+ {{ .PasswordResetURL }}
+
If you did not request this, you can ignore this email.
+ + diff --git a/registration/mails/password_reset_subject.tmpl b/registration/mails/password_reset_subject.tmpl new file mode 100644 index 0000000..04fbf6b --- /dev/null +++ b/registration/mails/password_reset_subject.tmpl @@ -0,0 +1 @@ +Reset your password diff --git a/registration/mails/password_reset_text.tmpl b/registration/mails/password_reset_text.tmpl new file mode 100644 index 0000000..55529a9 --- /dev/null +++ b/registration/mails/password_reset_text.tmpl @@ -0,0 +1,8 @@ +Hi {{ .Name }}, + +We received a request to reset the password for {{ .Email }}. + +Set a new password by opening this link: +{{ .PasswordResetURL }} + +If you did not request this, you can ignore this email. diff --git a/registration/migrations/2_create_password_reset_tokens.up.sql b/registration/migrations/2_create_password_reset_tokens.up.sql new file mode 100644 index 0000000..a841507 --- /dev/null +++ b/registration/migrations/2_create_password_reset_tokens.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE password_reset_tokens ( + token UUID PRIMARY KEY, + user_id UUID NOT NULL, + email TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ +); + +CREATE INDEX password_reset_tokens_user_id_idx ON password_reset_tokens (user_id); +CREATE INDEX password_reset_tokens_email_idx ON password_reset_tokens (email); diff --git a/registration/registration.go b/registration/registration.go index fe38881..dc47129 100644 --- a/registration/registration.go +++ b/registration/registration.go @@ -13,6 +13,7 @@ import ( texttemplate "text/template" "time" + authsvc "encore.app/auth" mailssvc "encore.app/mails" profilessvc "encore.app/profiles" "encore.dev/beta/errs" @@ -30,8 +31,9 @@ var db = sqldb.NewDatabase("registration", sqldb.DatabaseConfig{ var profilesDB = sqldb.Named("profiles") const ( - welcomeTokenTTL = 24 * time.Hour - frontendBaseURL = "http://localhost:9000/#" + welcomeTokenTTL = 24 * time.Hour + passwordResetTokenTTL = time.Hour + frontendBaseURL = "http://localhost:9000/#" ) type RegisterParams struct { @@ -51,6 +53,23 @@ type WelcomeResponse struct { UsedAt time.Time `json:"used_at"` } +type PasswordResetRequestParams struct { + Email string `json:"email"` +} + +type PasswordResetRequestResponse struct { + EmailSent bool `json:"email_sent"` +} + +type PasswordResetConfirmParams struct { + Password string `json:"password" encore:"sensitive"` +} + +type PasswordResetConfirmResponse struct { + Email string `json:"email"` + Reset bool `json:"reset"` +} + type EmailAvailabilityResponse struct { Email string `json:"email"` Available bool `json:"available"` @@ -155,6 +174,92 @@ func ConfirmWelcome(ctx context.Context, token uuid.UUID) (*WelcomeResponse, err return &WelcomeResponse{Email: email, Confirmed: true, UsedAt: confirmedAt}, nil } +// RequestPasswordReset creates a reset token and queues an email when the account exists. +// +//encore:api public method=POST path=/registration/password-reset +func RequestPasswordReset(ctx context.Context, p *PasswordResetRequestParams) (*PasswordResetRequestResponse, error) { + email, err := normalizeEmail(p.Email) + if err != nil { + return nil, err + } + + var profile profilessvc.Profile + err = profilesDB.QueryRow(ctx, ` + SELECT user_id, email, display_name, avatar_url, role, status, is_artist, created_at, updated_at + FROM user_profiles + WHERE email = $1 + `, email).Scan( + &profile.UserID, + &profile.Email, + &profile.DisplayName, + &profile.AvatarURL, + &profile.Role, + &profile.Status, + &profile.IsArtist, + &profile.CreatedAt, + &profile.UpdatedAt, + ) + if errors.Is(err, sqldb.ErrNoRows) { + return &PasswordResetRequestResponse{EmailSent: true}, nil + } + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "failed to load profile") + } + + token, err := createPasswordResetToken(ctx, &profile) + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "failed to create password reset token") + } + + mail, err := passwordResetMail(&profile, token) + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "failed to render password reset email") + } + if _, err := mailssvc.SendTransactional(ctx, mail); err != nil { + return nil, errs.WrapCode(err, errs.Internal, "failed to queue password reset email") + } + + return &PasswordResetRequestResponse{EmailSent: true}, nil +} + +// ConfirmPasswordReset validates a reset token and changes the account password. +// +//encore:api public method=POST path=/registration/password-reset/:token +func ConfirmPasswordReset(ctx context.Context, token uuid.UUID, p *PasswordResetConfirmParams) (*PasswordResetConfirmResponse, error) { + var userID uuid.UUID + var email string + var expiresAt time.Time + var usedAt *time.Time + err := db.QueryRow(ctx, ` + SELECT user_id, email, expires_at, used_at FROM password_reset_tokens WHERE token = $1 + `, token).Scan(&userID, &email, &expiresAt, &usedAt) + if errors.Is(err, sqldb.ErrNoRows) { + return nil, &errs.Error{Code: errs.NotFound, Message: "password reset token not found"} + } + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "failed to load password reset token") + } + if usedAt != nil { + return nil, &errs.Error{Code: errs.InvalidArgument, Message: "password reset token already used"} + } + if time.Now().After(expiresAt) { + return nil, &errs.Error{Code: errs.InvalidArgument, Message: "password reset token has expired"} + } + + if err := authsvc.SetUserPassword(ctx, userID, &authsvc.SetUserPasswordParams{UserID: userID, Password: p.Password}); err != nil { + return nil, err + } + + _, err = db.Exec(ctx, ` + UPDATE password_reset_tokens SET used_at = NOW() WHERE token = $1 + `, token) + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "failed to consume password reset token") + } + + return &PasswordResetConfirmResponse{Email: email, Reset: true}, nil +} + func createWelcomeToken(ctx context.Context, profile *profilessvc.Profile) (uuid.UUID, error) { token, err := uuid.NewV4() if err != nil { @@ -167,6 +272,30 @@ func createWelcomeToken(ctx context.Context, profile *profilessvc.Profile) (uuid return token, err } +func createPasswordResetToken(ctx context.Context, profile *profilessvc.Profile) (uuid.UUID, error) { + token, err := uuid.NewV4() + if err != nil { + return uuid.Nil, err + } + _, err = db.Exec(ctx, ` + INSERT INTO password_reset_tokens (token, user_id, email, expires_at) + VALUES ($1, $2, $3, $4) + `, token, profile.UserID, profile.Email, time.Now().Add(passwordResetTokenTTL)) + return token, err +} + +func normalizeEmail(email string) (string, error) { + email = strings.TrimSpace(email) + if email == "" { + return "", &errs.Error{Code: errs.InvalidArgument, Message: "email is required"} + } + parsed, err := mail.ParseAddress(email) + if err != nil { + return "", &errs.Error{Code: errs.InvalidArgument, Message: "invalid email"} + } + return parsed.Address, nil +} + func emailDomain(email string) (string, bool) { _, domain, ok := strings.Cut(email, "@") domain = strings.TrimSpace(strings.TrimSuffix(domain, ".")) @@ -234,7 +363,52 @@ type welcomeMailData struct { WelcomeURL string } -func renderTextTemplate(name string, data welcomeMailData) (string, error) { +func passwordResetMail(profile *profilessvc.Profile, token uuid.UUID) (*mailssvc.TransactionalMailParams, error) { + name := profile.DisplayName + if name == "" { + name = profile.Email + } + + data := passwordResetMailData{ + Name: name, + Email: profile.Email, + UserID: profile.UserID.String(), + PasswordResetURL: frontendBaseURL + "/password-reset?token=" + token.String(), + } + + subject, err := renderTextTemplate("mails/password_reset_subject.tmpl", data) + if err != nil { + return nil, err + } + textBody, err := renderTextTemplate("mails/password_reset_text.tmpl", data) + if err != nil { + return nil, err + } + htmlBody, err := renderHTMLTemplate("mails/password_reset_html.tmpl", data) + if err != nil { + return nil, err + } + + return &mailssvc.TransactionalMailParams{ + To: profile.Email, + Subject: subject, + TextBody: textBody, + HTMLBody: htmlBody, + Metadata: map[string]string{ + "kind": "password_reset", + "user_id": profile.UserID.String(), + }, + }, nil +} + +type passwordResetMailData struct { + Name string + Email string + UserID string + PasswordResetURL string +} + +func renderTextTemplate(name string, data any) (string, error) { tmpl, err := texttemplate.ParseFS(mailTemplates, name) if err != nil { return "", err @@ -246,7 +420,7 @@ func renderTextTemplate(name string, data welcomeMailData) (string, error) { return strings.TrimSpace(out.String()), nil } -func renderHTMLTemplate(name string, data welcomeMailData) (string, error) { +func renderHTMLTemplate(name string, data any) (string, error) { tmpl, err := htmltemplate.ParseFS(mailTemplates, name) if err != nil { return "", err