// Package users manages the admin users table (login accounts). // Passwords are bcrypt'd; minimum cost is 12 (≈300ms on modern hw). // The users table is the authoritative source for login; the legacy // setup-store admin is migrated into this table on first login. package users import ( "context" "errors" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/pquerna/otp/totp" "golang.org/x/crypto/bcrypt" ) const passwordCost = 12 var ( ErrNotFound = errors.New("user not found") ErrEmailTaken = errors.New("email already in use") ) type User struct { ID int64 `json:"id"` Email string `json:"email"` Role string `json:"role"` Active bool `json:"active"` TOTPEnabled bool `json:"totp_enabled"` LastLoginAt *time.Time `json:"last_login_at"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // AuthInfo is returned by FindForAuth — contains credentials needed during login. type AuthInfo struct { User PasswordHash string TOTPSecret *string } type Repo struct { pool *pgxpool.Pool } func New(pool *pgxpool.Pool) *Repo { return &Repo{pool: pool} } const selectCols = `id, email, role, active, totp_enabled, last_login_at, created_at, updated_at` func scan(row pgx.Row) (User, error) { var u User err := row.Scan(&u.ID, &u.Email, &u.Role, &u.Active, &u.TOTPEnabled, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt) return u, err } func (r *Repo) List(ctx context.Context) ([]User, error) { rows, err := r.pool.Query(ctx, `SELECT `+selectCols+` FROM users ORDER BY created_at`) if err != nil { return nil, err } defer rows.Close() var out []User for rows.Next() { u, err := scan(rows) if err != nil { return nil, err } out = append(out, u) } return out, rows.Err() } // FindByEmail returns the user + bcrypt hash. ErrNotFound if absent. func (r *Repo) FindByEmail(ctx context.Context, email string) (User, string, error) { var u User var hash string err := r.pool.QueryRow(ctx, `SELECT `+selectCols+`, password_hash FROM users WHERE lower(email)=lower($1)`, email).Scan(&u.ID, &u.Email, &u.Role, &u.Active, &u.TOTPEnabled, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt, &hash) if errors.Is(err, pgx.ErrNoRows) { return u, "", ErrNotFound } return u, hash, err } // FindForAuth returns full auth credentials including TOTP secret. ErrNotFound if absent. func (r *Repo) FindForAuth(ctx context.Context, email string) (*AuthInfo, error) { var a AuthInfo err := r.pool.QueryRow(ctx, `SELECT `+selectCols+`, password_hash, totp_secret FROM users WHERE lower(email)=lower($1)`, email).Scan(&a.ID, &a.Email, &a.Role, &a.Active, &a.TOTPEnabled, &a.LastLoginAt, &a.CreatedAt, &a.UpdatedAt, &a.PasswordHash, &a.TOTPSecret) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } return &a, err } // GenerateTOTPSecret creates a new TOTP secret for the given email and returns // the secret + the otpauth:// provisioning URI (for QR code rendering in the UI). // The secret is NOT saved yet — call ConfirmTOTP after the user verifies the code. func GenerateTOTPSecret(email string) (secret, uri string, err error) { key, err := totp.Generate(totp.GenerateOpts{ Issuer: "EdgeGuard", AccountName: email, }) if err != nil { return "", "", err } return key.Secret(), key.URL(), nil } // ConfirmTOTP verifies the given TOTP code against the (not-yet-saved) secret // and, on success, persists it and enables TOTP for the user. func (r *Repo) ConfirmTOTP(ctx context.Context, userID int64, secret, code string) error { if !totp.Validate(code, secret) { return errors.New("invalid_totp_code") } tag, err := r.pool.Exec(ctx, `UPDATE users SET totp_secret=$1, totp_enabled=true, updated_at=NOW() WHERE id=$2`, secret, userID) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } // DisableTOTP clears the TOTP secret and disables 2FA for the given user. func (r *Repo) DisableTOTP(ctx context.Context, userID int64) error { tag, err := r.pool.Exec(ctx, `UPDATE users SET totp_secret=NULL, totp_enabled=false, updated_at=NOW() WHERE id=$1`, userID) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } // VerifyTOTP checks a live TOTP code against the stored secret. func VerifyTOTP(secret, code string) bool { return totp.Validate(code, secret) } func (r *Repo) Count(ctx context.Context) (int, error) { var n int err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&n) return n, err } func (r *Repo) Create(ctx context.Context, email, password, role string, active bool) (User, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), passwordCost) if err != nil { return User{}, err } u, err := scan(r.pool.QueryRow(ctx, `INSERT INTO users (email, password_hash, role, active) VALUES ($1,$2,$3,$4) RETURNING `+selectCols, email, string(hash), role, active)) if isUnique(err) { return User{}, ErrEmailTaken } return u, err } // Upsert: insert or update — used to migrate the legacy setup-store admin // into the DB on first successful login without clobbering an existing row. func (r *Repo) Upsert(ctx context.Context, email, password, role string, active bool) (User, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), passwordCost) if err != nil { return User{}, err } u, err := scan(r.pool.QueryRow(ctx, `INSERT INTO users (email, password_hash, role, active) VALUES ($1,$2,$3,$4) ON CONFLICT (email) DO NOTHING RETURNING `+selectCols, email, string(hash), role, active)) if errors.Is(err, pgx.ErrNoRows) { // row already existed — return what's there without overwriting u, _, err = r.FindByEmail(ctx, email) } return u, err } func (r *Repo) Update(ctx context.Context, id int64, email, role string, active bool) (User, error) { u, err := scan(r.pool.QueryRow(ctx, `UPDATE users SET email=$1, role=$2, active=$3, updated_at=NOW() WHERE id=$4 RETURNING `+selectCols, email, role, active, id)) if errors.Is(err, pgx.ErrNoRows) { return u, ErrNotFound } if isUnique(err) { return u, ErrEmailTaken } return u, err } func (r *Repo) SetPassword(ctx context.Context, id int64, password string) error { hash, err := bcrypt.GenerateFromPassword([]byte(password), passwordCost) if err != nil { return err } tag, err := r.pool.Exec(ctx, `UPDATE users SET password_hash=$1, updated_at=NOW() WHERE id=$2`, string(hash), id) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } func (r *Repo) Delete(ctx context.Context, id int64) error { tag, err := r.pool.Exec(ctx, `DELETE FROM users WHERE id=$1`, id) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } func (r *Repo) RecordLogin(ctx context.Context, id int64) { _, _ = r.pool.Exec(ctx, `UPDATE users SET last_login_at=NOW() WHERE id=$1`, id) } // VerifyPassword is a constant-time bcrypt compare. func VerifyPassword(hash, password string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil } func isUnique(err error) bool { if err == nil { return false } s := err.Error() return strings.Contains(s, "23505") || strings.Contains(s, "unique") }