Prefix replacement of the old application-nested module path in go.mod and every import. No identifier, string, table, migration ID, config key or comment changes.
193 lines
5.6 KiB
Go
193 lines
5.6 KiB
Go
package classes
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"errors"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-user-plugin/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
|
|
}
|