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>
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -27,22 +28,30 @@ type User struct {
|
||||
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, last_login_at, created_at, updated_at`
|
||||
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,
|
||||
err := row.Scan(&u.ID, &u.Email, &u.Role, &u.Active, &u.TOTPEnabled,
|
||||
&u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt)
|
||||
return u, err
|
||||
}
|
||||
@@ -71,7 +80,7 @@ func (r *Repo) FindByEmail(ctx context.Context, email string) (User, string, err
|
||||
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,
|
||||
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
|
||||
@@ -79,6 +88,70 @@ func (r *Repo) FindByEmail(ctx context.Context, email string) (User, string, err
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user