Files
edgeguard-native/internal/services/users/users.go
Debian 1d06b28064 feat: HA-Cluster v1.2.x — Split-Brain, TOTP, Enterprise-FW, Drift-Fix, VIP-Recovery
- keepalived: pg_role='standby' hat Vorrang vor role für BACKUP-Bestimmung
- keepalived-master.sh: gecrasht Dienste beim MASTER-Übergang starten (nicht nur reload)
- confighash: ip_addresses per Interface-Name hashen statt per FK (Cross-Node-Drift-Fix)
- TOTP/2FA: RFC 6238 — Setup-Flow, QR-Code, Admin-Disable; two-step Login
- Firewall-UI: Enterprise-Design — auto-Beschreibung, icon-only Actions, zero-hit Indikator
- fe80-Filter: Link-local IPv6 aus NTP/DNS Listen-Dropdowns entfernen
- VIP-Dashboard, Dual-Path VRRP, GW-Tracking (Migrations 0033/0034)
- Forward Proxy + DNS erweiterte Einstellungen (Migrations 0031/0032)
- unbound-control: edgeguard in unbound-Gruppe via postinst

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 18:18:31 +02:00

255 lines
7.2 KiB
Go

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