Match PHP payloads, already-activated Winter 500 HTML, and direct-DB seeding so the corpus can flip those routes to ported without onboarding. Co-authored-by: Cursor <cursoragent@cursor.com>
193 lines
5.7 KiB
Go
193 lines
5.7 KiB
Go
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
|
|
}
|
|
|
|
// IsAlreadyActivated reports whether the scoped user row is activated.
|
|
// A missing user is (false, nil); a real DB error is (false, err).
|
|
func IsAlreadyActivated(ctx context.Context, db *gorm.DB, userID uint) (bool, error) {
|
|
user, err := takeUser(ctx, db, userID, false)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if user == nil {
|
|
return false, nil
|
|
}
|
|
return user.IsActivated, nil
|
|
}
|
|
|
|
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.Session(&gorm.Session{NewDB: true, Context: 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
|
|
}
|