feat(07-03): add password reset and account activation

Reset and activation codes expire on a TTL, and a missing issued-at still accepts a cutover code. Forgot-password always returns the same 200 body.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-09-22 16:30:22 +02:00
parent 97a39dc34c
commit cb50d673c6
5 changed files with 673 additions and 1 deletions

179
classes/codes.go Normal file
View File

@@ -0,0 +1,179 @@
package classes
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"errors"
"time"
"git.golem15.com/golem15/fonoteka/plugins/golem15/user/models"
"gorm.io/gorm"
)
type resetTTLKey struct{}
type activationTTLKey struct{}
// ContextWithCodeTTL stores the reset and activation lifetimes used by Verify.
// A missing value falls back to 60 minutes and 72 hours.
func ContextWithCodeTTL(ctx context.Context, reset, activation time.Duration) context.Context {
if ctx == nil {
ctx = context.Background()
}
ctx = context.WithValue(ctx, resetTTLKey{}, reset)
return context.WithValue(ctx, activationTTLKey{}, activation)
}
func resetTTL(ctx context.Context) time.Duration {
if ctx != nil {
if d, ok := ctx.Value(resetTTLKey{}).(time.Duration); ok && d > 0 {
return d
}
}
return 60 * time.Minute
}
func activationTTL(ctx context.Context) time.Duration {
if ctx != nil {
if d, ok := ctx.Value(activationTTLKey{}).(time.Duration); ok && d > 0 {
return d
}
}
return 72 * time.Hour
}
func newCode() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
// IssueResetCode stores a fresh plain-text reset code and returns it.
func IssueResetCode(ctx context.Context, db *gorm.DB, user *models.User) (string, error) {
return issueCode(ctx, db, user, "reset_password_code", "reset_password_code_issued_at")
}
// IssueActivationCode stores a fresh plain-text activation code and returns it.
func IssueActivationCode(ctx context.Context, db *gorm.DB, user *models.User) (string, error) {
return issueCode(ctx, db, user, "activation_code", "activation_code_issued_at")
}
func issueCode(ctx context.Context, db *gorm.DB, user *models.User, codeCol, issuedCol string) (string, error) {
if user == nil || user.ID == 0 || db == nil {
return "", errors.New("golem15.user: cannot issue a code without a saved user")
}
code, err := newCode()
if err != nil {
return "", err
}
now := time.Now()
err = db.WithContext(ctx).Model(&models.User{}).Where("id = ?", user.ID).UpdateColumns(map[string]any{
codeCol: code,
issuedCol: now,
}).Error
if err != nil {
return "", err
}
switch codeCol {
case "reset_password_code":
user.ResetPasswordCode = &code
user.ResetPasswordCodeIssuedAt = &now
default:
user.ActivationCode = &code
user.ActivationCodeIssuedAt = &now
}
return code, nil
}
// VerifyResetCode accepts the stored code inside the reset TTL.
// A NULL issued-at is treated as issued now. Success clears the code and
// marks the password as self-set. The caller stores the new password.
func VerifyResetCode(ctx context.Context, db *gorm.DB, userID uint, code string) (*models.User, bool) {
user, err := takeUser(ctx, db, userID, false)
if err != nil || user == nil || user.ResetPasswordCode == nil {
return nil, false
}
if !codeFresh(user.ResetPasswordCodeIssuedAt, resetTTL(ctx)) || !codesEqual(code, *user.ResetPasswordCode) {
return nil, false
}
if err := db.WithContext(ctx).Model(&models.User{}).Where("id = ?", user.ID).UpdateColumns(map[string]any{
"reset_password_code": nil,
"reset_password_code_issued_at": nil,
"has_self_set_password": true,
}).Error; err != nil {
return nil, false
}
user.ResetPasswordCode = nil
user.ResetPasswordCodeIssuedAt = nil
user.HasSelfSetPassword = true
return user, true
}
// VerifyActivationCode accepts the stored code inside the activation TTL.
// A soft-deleted row is visible and is restored on success.
func VerifyActivationCode(ctx context.Context, db *gorm.DB, userID uint, code string) (*models.User, bool) {
user, err := takeUser(ctx, db, userID, true)
if err != nil || user == nil || user.IsActivated || user.ActivationCode == nil {
return nil, false
}
if !codeFresh(user.ActivationCodeIssuedAt, activationTTL(ctx)) || !codesEqual(code, *user.ActivationCode) {
return nil, false
}
if user.DeletedAt.Valid {
if err := db.WithContext(ctx).Unscoped().Model(&models.User{}).Where("id = ?", user.ID).Update("deleted_at", nil).Error; err != nil {
return nil, false
}
user.DeletedAt = gorm.DeletedAt{}
}
now := time.Now()
if err := db.WithContext(ctx).Model(&models.User{}).Where("id = ?", user.ID).UpdateColumns(map[string]any{
"is_activated": true,
"activated_at": now,
"activation_code": nil,
"activation_code_issued_at": nil,
}).Error; err != nil {
return nil, false
}
user.IsActivated = true
user.ActivatedAt = &now
user.ActivationCode = nil
user.ActivationCodeIssuedAt = nil
return user, true
}
func takeUser(ctx context.Context, db *gorm.DB, id uint, unscoped bool) (*models.User, error) {
if db == nil || id == 0 {
return nil, nil
}
q := db.WithContext(ctx)
if unscoped {
q = q.Unscoped()
}
var user models.User
err := q.Where("id = ?", id).Take(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &user, nil
}
func codeFresh(issued *time.Time, ttl time.Duration) bool {
at := time.Now()
if issued != nil && !issued.IsZero() {
at = *issued
}
return !time.Now().After(at.Add(ttl))
}
func codesEqual(got, stored string) bool {
if len(got) != len(stored) {
return false
}
return subtle.ConstantTimeCompare([]byte(got), []byte(stored)) == 1
}

99
classes/codes_test.go Normal file
View File

@@ -0,0 +1,99 @@
package classes
import (
"os"
"strings"
"testing"
"time"
"git.golem15.com/golem15/fonoteka/plugins/golem15/user/models"
)
func flipHex(b byte) string {
if b == '0' {
return "1"
}
return "0"
}
func TestCodes(t *testing.T) {
db := throttleDB(t)
src, err := os.ReadFile("codes.go")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(src), "subtle.ConstantTimeCompare") {
t.Fatal("code compare must use crypto/subtle.ConstantTimeCompare")
}
user := &models.User{Email: "reset@example.test", Password: "x"}
if err := db.Create(user).Error; err != nil {
t.Fatal(err)
}
code, err := IssueResetCode(t.Context(), db, user)
if err != nil || code == "" {
t.Fatalf("issue reset: %q %v", code, err)
}
if user.ResetPasswordCodeIssuedAt == nil || time.Since(*user.ResetPasswordCodeIssuedAt) > 2*time.Second {
t.Fatalf("issued at = %v", user.ResetPasswordCodeIssuedAt)
}
act, err := IssueActivationCode(t.Context(), db, user)
if err != nil || act == "" {
t.Fatalf("issue activation: %q %v", act, err)
}
got, ok := VerifyResetCode(t.Context(), db, user.ID, code)
if !ok || got == nil || !got.HasSelfSetPassword {
t.Fatalf("verify reset = %v %#v", ok, got)
}
var row models.User
if err := db.Where("id = ?", user.ID).Take(&row).Error; err != nil {
t.Fatal(err)
}
if row.ResetPasswordCode != nil {
t.Fatalf("code not cleared: %#v", row.ResetPasswordCode)
}
if _, ok := VerifyResetCode(t.Context(), db, user.ID, code); ok {
t.Fatal("consumed code still verified")
}
again, err := IssueResetCode(t.Context(), db, user)
if err != nil {
t.Fatal(err)
}
if _, ok := VerifyResetCode(t.Context(), db, user.ID, again[:len(again)-1]+flipHex(again[len(again)-1])); ok {
t.Fatal("last-byte mismatch verified")
}
if _, ok := VerifyResetCode(t.Context(), db, user.ID, flipHex(again[0])+again[1:]); ok {
t.Fatal("first-byte mismatch verified")
}
past := time.Now().Add(-61 * time.Minute)
if err := db.Model(&models.User{}).Where("id = ?", user.ID).Update("reset_password_code_issued_at", past).Error; err != nil {
t.Fatal(err)
}
if _, ok := VerifyResetCode(t.Context(), db, user.ID, again); ok {
t.Fatal("expired code verified")
}
fresh := "cutover-code-value"
if err := db.Exec(`UPDATE users SET reset_password_code = ?, reset_password_code_issued_at = NULL WHERE id = ?`, fresh, user.ID).Error; err != nil {
t.Fatal(err)
}
if _, ok := VerifyResetCode(t.Context(), db, user.ID, fresh); !ok {
t.Fatal("null issued-at must be treated as issued now")
}
pending := &models.User{Email: "activate@example.test", Password: "x"}
if err := db.Create(pending).Error; err != nil {
t.Fatal(err)
}
activation, err := IssueActivationCode(t.Context(), db, pending)
if err != nil {
t.Fatal(err)
}
activated, ok := VerifyActivationCode(t.Context(), db, pending.ID, activation)
if !ok || activated == nil || !activated.IsActivated {
t.Fatalf("activate = %v %#v", ok, activated)
}
}