Files
honeyDueAPI/internal/models/user.go
Trey t 42a5533a56 Fix 113 hardening issues across entire Go backend
Security:
- Replace all binding: tags with validate: + c.Validate() in admin handlers
- Add rate limiting to auth endpoints (login, register, password reset)
- Add security headers (HSTS, XSS protection, nosniff, frame options)
- Wire Google Pub/Sub token verification into webhook handler
- Replace ParseUnverified with proper OIDC/JWKS key verification
- Verify inner Apple JWS signatures in webhook handler
- Add io.LimitReader (1MB) to all webhook body reads
- Add ownership verification to file deletion
- Move hardcoded admin credentials to env vars
- Add uniqueIndex to User.Email
- Hide ConfirmationCode from JSON serialization
- Mask confirmation codes in admin responses
- Use http.DetectContentType for upload validation
- Fix path traversal in storage service
- Replace os.Getenv with Viper in stripe service
- Sanitize Redis URLs before logging
- Separate DEBUG_FIXED_CODES from DEBUG flag
- Reject weak SECRET_KEY in production
- Add host check on /_next/* proxy routes
- Use explicit localhost CORS origins in debug mode
- Replace err.Error() with generic messages in all admin error responses

Critical fixes:
- Rewrite FCM to HTTP v1 API with OAuth 2.0 service account auth
- Fix user_customuser -> auth_user table names in raw SQL
- Fix dashboard verified query to use UserProfile model
- Add escapeLikeWildcards() to prevent SQL wildcard injection

Bug fixes:
- Add bounds checks for days/expiring_soon query params (1-3650)
- Add receipt_data/transaction_id empty-check to RestoreSubscription
- Change Active bool -> *bool in device handler
- Check all unchecked GORM/FindByIDWithProfile errors
- Add validation for notification hour fields (0-23)
- Add max=10000 validation on task description updates

Transactions & data integrity:
- Wrap registration flow in transaction
- Wrap QuickComplete in transaction
- Move image creation inside completion transaction
- Wrap SetSpecialties in transaction
- Wrap GetOrCreateToken in transaction
- Wrap completion+image deletion in transaction

Performance:
- Batch completion summaries (2 queries vs 2N)
- Reuse single http.Client in IAP validation
- Cache dashboard counts (30s TTL)
- Batch COUNT queries in admin user list
- Add Limit(500) to document queries
- Add reminder_stage+due_date filters to reminder queries
- Parse AllowedTypes once at init
- In-memory user cache in auth middleware (30s TTL)
- Timezone change detection cache
- Optimize P95 with per-endpoint sorted buffers
- Replace crypto/md5 with hash/fnv for ETags

Code quality:
- Add sync.Once to all monitoring Stop()/Close() methods
- Replace 8 fmt.Printf with zerolog in auth service
- Log previously discarded errors
- Standardize delete response shapes
- Route hardcoded English through i18n
- Remove FileURL from DocumentResponse (keep MediaURL only)
- Thread user timezone through kanban board responses
- Initialize empty slices to prevent null JSON
- Extract shared field map for task Update/UpdateTx
- Delete unused SoftDeleteModel, min(), formatCron, legacy handlers

Worker & jobs:
- Wire Asynq email infrastructure into worker
- Register HandleReminderLogCleanup with daily 3AM cron
- Use per-user timezone in HandleSmartReminder
- Replace direct DB queries with repository calls
- Delete legacy reminder handlers (~200 lines)
- Delete unused task type constants

Dependencies:
- Replace archived jung-kurt/gofpdf with go-pdf/fpdf
- Replace unmaintained gomail.v2 with wneessen/go-mail
- Add TODO for Echo jwt v3 transitive dep removal

Test infrastructure:
- Fix MakeRequest/SeedLookupData error handling
- Replace os.Exit(0) with t.Skip() in scope/consistency tests
- Add 11 new FCM v1 tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:14:13 -05:00

268 lines
9.6 KiB
Go

package models
import (
"crypto/rand"
"encoding/hex"
"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"
}
// SetPassword hashes and sets the password
func (u *User) SetPassword(password string) error {
// Django uses PBKDF2_SHA256 by default, but we'll use bcrypt for Go
// Note: This means passwords set by Django won't work with Go's check
// For migration, you'd need to either:
// 1. Force password reset for all users
// 2. Implement Django's PBKDF2 hasher in Go
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
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
type AuthToken struct {
Key string `gorm:"column:key;primaryKey;size:40" json:"key"`
UserID uint `gorm:"column:user_id;uniqueIndex;not null" json:"user_id"`
Created time.Time `gorm:"column:created;autoCreateTime" json:"created"`
// 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 key if not provided
func (t *AuthToken) BeforeCreate(tx *gorm.DB) error {
if t.Key == "" {
t.Key = generateToken()
}
if t.Created.IsZero() {
t.Created = time.Now().UTC()
}
return nil
}
// generateToken creates a random 40-character hex token
func generateToken() string {
b := make([]byte, 20)
rand.Read(b)
return hex.EncodeToString(b)
}
// 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)
}
// GenerateCode creates a random 6-digit code
func GenerateConfirmationCode() string {
b := make([]byte, 3)
rand.Read(b)
// Convert to 6-digit number
num := int(b[0])<<16 | int(b[1])<<8 | int(b[2])
return string(rune('0'+num%10)) + string(rune('0'+(num/10)%10)) +
string(rune('0'+(num/100)%10)) + string(rune('0'+(num/1000)%10)) +
string(rune('0'+(num/10000)%10)) + string(rune('0'+(num/100000)%10))
}
// 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), bcrypt.DefaultCost)
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"
}