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" }