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 }