c77ff07ce9
Remediation of the 2026-05-12/13 audits (78 findings + cluster gaps), tracked in deploy-k3s/SECURITY.md, plus fixes from two independent post-remediation reviews. Auth & sessions: - SHA-256 hashed auth-token storage (C1); prior-token cache eviction on re-login (MEDIUM-1) - local Google JWKS verification, iss/aud/exp checks (C2/C3) - constant-time login + generic errors (L1/LIVE-L11/LIVE-L13) - per-account login lockout keyed on distinct source IPs (M5/MEDIUM-3) - verified-email gating, login rate limiting (LIVE-L19, H1-H3) IAP & webhooks: - Apple/Google cross-account replay protection (C5/C6/C10/C13, H5/H6) - migrations 000003-000006 (token hashing, IAP replay, audit_log + webhook_event_log table creation, append-only audit log) Authorization & races: - file-ownership owner-OR-member fix (C7), atomic share-code join (C9/H9), device-token reassignment (C8/LOW-3) Secrets & deploy: - secrets file-mounted at /etc/honeydue/secrets, not env (F8); Redis password out of the ConfigMap (HIGH-1); B2 keys reconciled - digest-pinned images, admin ingress hardening, CSP/HSTS, /metrics lockdown; kubeconfig 0600, etcd secrets-encryption, fail2ban + unattended-upgrades at provision; secret-rotation runbook Build, vet, and the full test suite (incl. -race) pass; the goose migration chain is verified against PostgreSQL 16. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
303 lines
11 KiB
Go
303 lines
11 KiB
Go
package models
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// User represents the auth_user table (Django's default User model)
|
|
type User struct {
|
|
ID uint `gorm:"primaryKey" json:"id"`
|
|
Password string `gorm:"column:password;size:128;not null" json:"-"`
|
|
LastLogin *time.Time `gorm:"column:last_login" json:"last_login,omitempty"`
|
|
IsSuperuser bool `gorm:"column:is_superuser;default:false" json:"is_superuser"`
|
|
Username string `gorm:"column:username;uniqueIndex;size:150;not null" json:"username"`
|
|
FirstName string `gorm:"column:first_name;size:150" json:"first_name"`
|
|
LastName string `gorm:"column:last_name;size:150" json:"last_name"`
|
|
Email string `gorm:"column:email;uniqueIndex;size:254" json:"email"`
|
|
IsStaff bool `gorm:"column:is_staff;default:false" json:"is_staff"`
|
|
IsActive bool `gorm:"column:is_active;default:true" json:"is_active"`
|
|
DateJoined time.Time `gorm:"column:date_joined;autoCreateTime" json:"date_joined"`
|
|
|
|
// Relations (not stored in auth_user table)
|
|
Profile *UserProfile `gorm:"foreignKey:UserID" json:"profile,omitempty"`
|
|
AuthToken *AuthToken `gorm:"foreignKey:UserID" json:"-"`
|
|
OwnedResidences []Residence `gorm:"foreignKey:OwnerID" json:"-"`
|
|
SharedResidences []Residence `gorm:"many2many:residence_residence_users;" json:"-"`
|
|
NotificationPref *NotificationPreference `gorm:"foreignKey:UserID" json:"-"`
|
|
Subscription *UserSubscription `gorm:"foreignKey:UserID" json:"-"`
|
|
}
|
|
|
|
// TableName returns the table name for GORM
|
|
func (User) TableName() string {
|
|
return "auth_user"
|
|
}
|
|
|
|
// BcryptCost is the bcrypt work factor for password and code hashing.
|
|
// 12 (audit M2) is stronger than bcrypt.DefaultCost (10).
|
|
const BcryptCost = 12
|
|
|
|
// SetPassword hashes and sets the password
|
|
func (u *User) SetPassword(password string) error {
|
|
// Django uses PBKDF2_SHA256 by default, but we use bcrypt for Go.
|
|
// Passwords set by Django won't verify with Go's bcrypt check — those
|
|
// users must reset their password after migration.
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
u.Password = string(hash)
|
|
return nil
|
|
}
|
|
|
|
// CheckPassword verifies a password against the stored hash
|
|
func (u *User) CheckPassword(password string) bool {
|
|
err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password))
|
|
return err == nil
|
|
}
|
|
|
|
// GetFullName returns the user's full name
|
|
func (u *User) GetFullName() string {
|
|
if u.FirstName != "" && u.LastName != "" {
|
|
return u.FirstName + " " + u.LastName
|
|
}
|
|
if u.FirstName != "" {
|
|
return u.FirstName
|
|
}
|
|
return u.Username
|
|
}
|
|
|
|
// AuthToken represents the user_authtoken table.
|
|
//
|
|
// Audit C1: the Key column stores the SHA-256 hash of the token, never the
|
|
// token itself. The raw token is handed to the client exactly once, at
|
|
// creation, via the non-persisted Plaintext field — it is never stored or
|
|
// logged. A database compromise therefore yields no usable session tokens.
|
|
type AuthToken struct {
|
|
Key string `gorm:"column:key;primaryKey;size:64" json:"-"`
|
|
UserID uint `gorm:"column:user_id;uniqueIndex;not null" json:"user_id"`
|
|
Created time.Time `gorm:"column:created;autoCreateTime" json:"created"`
|
|
|
|
// Plaintext is the raw token value. It is never persisted (gorm:"-")
|
|
// and is only populated on a freshly-created token so the caller can
|
|
// return it to the client. On a token loaded from the DB it is "".
|
|
Plaintext string `gorm:"-" json:"-"`
|
|
|
|
// Relations
|
|
User User `gorm:"foreignKey:UserID" json:"-"`
|
|
}
|
|
|
|
// TableName returns the table name for GORM
|
|
func (AuthToken) TableName() string {
|
|
return "user_authtoken"
|
|
}
|
|
|
|
// BeforeCreate generates a token if one is not already set, storing only
|
|
// its hash in Key and the raw value in the non-persisted Plaintext field.
|
|
func (t *AuthToken) BeforeCreate(tx *gorm.DB) error {
|
|
if t.Key == "" {
|
|
raw := generateToken()
|
|
t.Plaintext = raw
|
|
t.Key = HashToken(raw)
|
|
}
|
|
if t.Created.IsZero() {
|
|
t.Created = time.Now().UTC()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// generateToken creates a random 40-character hex token (the raw value).
|
|
func generateToken() string {
|
|
b := make([]byte, 20)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// HashToken returns the at-rest representation of an auth token: the
|
|
// hex-encoded SHA-256 hash. Auth tokens are 160-bit random values, so a
|
|
// fast deterministic hash is appropriate — there is nothing to brute-force,
|
|
// and determinism preserves the single indexed-lookup query in the auth
|
|
// middleware. The raw token is never stored.
|
|
func HashToken(raw string) string {
|
|
sum := sha256.Sum256([]byte(raw))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// GetOrCreate gets an existing token or creates a new one for the user
|
|
func GetOrCreateToken(tx *gorm.DB, userID uint) (*AuthToken, error) {
|
|
var token AuthToken
|
|
result := tx.Where("user_id = ?", userID).First(&token)
|
|
|
|
if result.Error == gorm.ErrRecordNotFound {
|
|
token = AuthToken{UserID: userID}
|
|
if err := tx.Create(&token).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
} else if result.Error != nil {
|
|
return nil, result.Error
|
|
}
|
|
|
|
return &token, nil
|
|
}
|
|
|
|
// UserProfile represents the user_userprofile table
|
|
type UserProfile struct {
|
|
BaseModel
|
|
UserID uint `gorm:"column:user_id;uniqueIndex;not null" json:"user_id"`
|
|
Verified bool `gorm:"column:verified;default:false" json:"verified"`
|
|
Bio string `gorm:"column:bio;type:text" json:"bio"`
|
|
PhoneNumber string `gorm:"column:phone_number;size:15" json:"phone_number"`
|
|
DateOfBirth *time.Time `gorm:"column:date_of_birth;type:date" json:"date_of_birth,omitempty"`
|
|
ProfilePicture string `gorm:"column:profile_picture;size:100" json:"profile_picture"`
|
|
|
|
// Relations
|
|
User User `gorm:"foreignKey:UserID" json:"-"`
|
|
}
|
|
|
|
// TableName returns the table name for GORM
|
|
func (UserProfile) TableName() string {
|
|
return "user_userprofile"
|
|
}
|
|
|
|
// ConfirmationCode represents the user_confirmationcode table
|
|
type ConfirmationCode struct {
|
|
BaseModel
|
|
UserID uint `gorm:"column:user_id;index;not null" json:"user_id"`
|
|
Code string `gorm:"column:code;size:6;not null" json:"-"`
|
|
ExpiresAt time.Time `gorm:"column:expires_at;not null" json:"expires_at"`
|
|
IsUsed bool `gorm:"column:is_used;default:false" json:"is_used"`
|
|
|
|
// Relations
|
|
User User `gorm:"foreignKey:UserID" json:"-"`
|
|
}
|
|
|
|
// TableName returns the table name for GORM
|
|
func (ConfirmationCode) TableName() string {
|
|
return "user_confirmationcode"
|
|
}
|
|
|
|
// IsValid checks if the confirmation code is still valid
|
|
func (c *ConfirmationCode) IsValid() bool {
|
|
return !c.IsUsed && time.Now().UTC().Before(c.ExpiresAt)
|
|
}
|
|
|
|
// GenerateConfirmationCode creates a uniformly-random 6-digit code using
|
|
// rejection sampling on crypto/rand (audit H4 — removes the modulo bias of
|
|
// the previous implementation).
|
|
func GenerateConfirmationCode() string {
|
|
for {
|
|
var b [4]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
continue
|
|
}
|
|
// 4294000000 is the largest multiple of 1e6 <= MaxUint32; rejecting
|
|
// the tail above it makes n % 1000000 perfectly uniform.
|
|
n := binary.BigEndian.Uint32(b[:])
|
|
if n < 4294000000 {
|
|
return fmt.Sprintf("%06d", n%1000000)
|
|
}
|
|
}
|
|
}
|
|
|
|
// PasswordResetCode represents the user_passwordresetcode table
|
|
type PasswordResetCode struct {
|
|
BaseModel
|
|
UserID uint `gorm:"column:user_id;index;not null" json:"user_id"`
|
|
CodeHash string `gorm:"column:code_hash;size:128;not null" json:"-"`
|
|
ResetToken string `gorm:"column:reset_token;uniqueIndex;size:64;not null" json:"reset_token"`
|
|
ExpiresAt time.Time `gorm:"column:expires_at;not null" json:"expires_at"`
|
|
Used bool `gorm:"column:used;default:false" json:"used"`
|
|
Attempts int `gorm:"column:attempts;default:0" json:"attempts"`
|
|
MaxAttempts int `gorm:"column:max_attempts;default:5" json:"max_attempts"`
|
|
|
|
// Relations
|
|
User User `gorm:"foreignKey:UserID" json:"-"`
|
|
}
|
|
|
|
// TableName returns the table name for GORM
|
|
func (PasswordResetCode) TableName() string {
|
|
return "user_passwordresetcode"
|
|
}
|
|
|
|
// SetCode hashes and stores the reset code
|
|
func (p *PasswordResetCode) SetCode(code string) error {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(code), BcryptCost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.CodeHash = string(hash)
|
|
return nil
|
|
}
|
|
|
|
// CheckCode verifies a code against the stored hash
|
|
func (p *PasswordResetCode) CheckCode(code string) bool {
|
|
err := bcrypt.CompareHashAndPassword([]byte(p.CodeHash), []byte(code))
|
|
return err == nil
|
|
}
|
|
|
|
// IsValid checks if the reset code is still valid
|
|
func (p *PasswordResetCode) IsValid() bool {
|
|
return !p.Used && time.Now().UTC().Before(p.ExpiresAt) && p.Attempts < p.MaxAttempts
|
|
}
|
|
|
|
// IncrementAttempts increments the attempt counter
|
|
func (p *PasswordResetCode) IncrementAttempts(tx *gorm.DB) error {
|
|
p.Attempts++
|
|
return tx.Model(p).Update("attempts", p.Attempts).Error
|
|
}
|
|
|
|
// MarkAsUsed marks the code as used
|
|
func (p *PasswordResetCode) MarkAsUsed(tx *gorm.DB) error {
|
|
p.Used = true
|
|
return tx.Model(p).Update("used", true).Error
|
|
}
|
|
|
|
// GenerateResetToken creates a URL-safe token
|
|
func GenerateResetToken() string {
|
|
b := make([]byte, 32)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// AppleSocialAuth represents a user's linked Apple ID for Sign in with Apple
|
|
type AppleSocialAuth struct {
|
|
ID uint `gorm:"primaryKey" json:"id"`
|
|
UserID uint `gorm:"uniqueIndex;not null" json:"user_id"`
|
|
User User `gorm:"foreignKey:UserID" json:"-"`
|
|
AppleID string `gorm:"column:apple_id;size:255;uniqueIndex;not null" json:"apple_id"` // Apple's unique subject ID
|
|
Email string `gorm:"column:email;size:254" json:"email"` // May be private relay
|
|
IsPrivateEmail bool `gorm:"column:is_private_email;default:false" json:"is_private_email"`
|
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
|
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
|
|
}
|
|
|
|
// TableName returns the table name for GORM
|
|
func (AppleSocialAuth) TableName() string {
|
|
return "user_applesocialauth"
|
|
}
|
|
|
|
// GoogleSocialAuth represents a user's linked Google account for Sign in with Google
|
|
type GoogleSocialAuth struct {
|
|
ID uint `gorm:"primaryKey" json:"id"`
|
|
UserID uint `gorm:"uniqueIndex;not null" json:"user_id"`
|
|
User User `gorm:"foreignKey:UserID" json:"-"`
|
|
GoogleID string `gorm:"column:google_id;size:255;uniqueIndex;not null" json:"google_id"` // Google's unique subject ID
|
|
Email string `gorm:"column:email;size:254" json:"email"`
|
|
Name string `gorm:"column:name;size:255" json:"name"`
|
|
Picture string `gorm:"column:picture;size:512" json:"picture"` // Profile picture URL
|
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
|
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
|
|
}
|
|
|
|
// TableName returns the table name for GORM
|
|
func (GoogleSocialAuth) TableName() string {
|
|
return "user_googlesocialauth"
|
|
}
|