329 lines
11 KiB
Go
329 lines
11 KiB
Go
package db
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"time"
|
|
)
|
|
|
|
const adminUserColumns = `id, username, password_hash, must_change_password, totp_secret, totp_enabled, is_global_admin, created_by, created_at`
|
|
|
|
func scanAdminUser(row *sql.Row) (*AdminUser, error) {
|
|
var u AdminUser
|
|
var createdAt string
|
|
var createdBy sql.NullInt64
|
|
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.MustChangePassword, &u.TOTPSecret, &u.TOTPEnabled, &u.IsGlobalAdmin, &createdBy, &createdAt); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
u.CreatedAt, _ = parseTime(createdAt)
|
|
if createdBy.Valid {
|
|
u.CreatedBy = &createdBy.Int64
|
|
}
|
|
return &u, nil
|
|
}
|
|
|
|
func (d *DB) CountAdminUsers() (int, error) {
|
|
var n int
|
|
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_admin_users`).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
// DefaultAdminUsername/Password are the seeded first-run credentials — the admin is
|
|
// forced to change both before they can use the rest of the dashboard (see
|
|
// AdminUser.MustChangePassword and the login flow).
|
|
const (
|
|
DefaultAdminUsername = "admin"
|
|
DefaultAdminPassword = "Password123!"
|
|
)
|
|
|
|
// SeedDefaultAdminIfEmpty creates the default admin account on a brand-new install
|
|
// (no admin users yet at all) with must_change_password set, so the default
|
|
// credentials can never be left in place silently.
|
|
func (d *DB) SeedDefaultAdminIfEmpty() error {
|
|
n, err := d.CountAdminUsers()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n > 0 {
|
|
return nil
|
|
}
|
|
hash, err := HashPassword(DefaultAdminPassword)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = d.CreateAdminUser(DefaultAdminUsername, hash, true)
|
|
return err
|
|
}
|
|
|
|
// CreateAdminUser inserts a new global-admin account (full access, no domain
|
|
// restriction). mustChangePassword should be true for the seeded default account so
|
|
// it can't keep running on default credentials.
|
|
func (d *DB) CreateAdminUser(username, passwordHash string, mustChangePassword bool) (int64, error) {
|
|
res, err := d.Exec(`INSERT INTO esrv_admin_users (username, password_hash, must_change_password, is_global_admin) VALUES (?, ?, ?, 1)`,
|
|
username, passwordHash, mustChangePassword)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return res.LastInsertId()
|
|
}
|
|
|
|
// CreateScopedAdminUser inserts a new domain-scoped admin (delegated access), owned by
|
|
// createdBy, and grants it access to exactly domainIDs — mirrors the delegation flow:
|
|
// a scoped admin can create other scoped admins limited to domains within their own.
|
|
func (d *DB) CreateScopedAdminUser(username, passwordHash string, createdBy int64, domainIDs []int64) (int64, error) {
|
|
tx, err := d.Begin()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
res, err := tx.Exec(`INSERT INTO esrv_admin_users (username, password_hash, must_change_password, is_global_admin, created_by) VALUES (?, ?, 1, 0, ?)`,
|
|
username, passwordHash, createdBy)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
id, err := res.LastInsertId()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
for _, domainID := range domainIDs {
|
|
if _, err := tx.Exec(`INSERT INTO esrv_admin_domain_access (admin_user_id, domain_id) VALUES (?, ?)`, id, domainID); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
return id, tx.Commit()
|
|
}
|
|
|
|
// ListAllAdminUsers returns every admin account — for a global admin's user-management
|
|
// view.
|
|
func (d *DB) ListAllAdminUsers() ([]AdminUser, error) {
|
|
rows, err := d.Query(`SELECT ` + adminUserColumns + ` FROM esrv_admin_users ORDER BY username`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanAdminUsers(rows)
|
|
}
|
|
|
|
// ListScopedAdminUsers returns every non-global admin. Combined with AccessibleDomainIDs
|
|
// per user, this lets the caller compute "which of these can I (a scoped admin)
|
|
// manage" — the subset check happens in Go since the admin counts here are always
|
|
// small (a handful of delegated accounts, not enterprise scale).
|
|
func (d *DB) ListScopedAdminUsers() ([]AdminUser, error) {
|
|
rows, err := d.Query(`SELECT ` + adminUserColumns + ` FROM esrv_admin_users WHERE is_global_admin = 0 ORDER BY username`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanAdminUsers(rows)
|
|
}
|
|
|
|
func scanAdminUsers(rows *sql.Rows) ([]AdminUser, error) {
|
|
defer rows.Close()
|
|
var out []AdminUser
|
|
for rows.Next() {
|
|
var u AdminUser
|
|
var createdAt string
|
|
var createdBy sql.NullInt64
|
|
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.MustChangePassword, &u.TOTPSecret, &u.TOTPEnabled, &u.IsGlobalAdmin, &createdBy, &createdAt); err != nil {
|
|
return nil, err
|
|
}
|
|
u.CreatedAt, _ = parseTime(createdAt)
|
|
if createdBy.Valid {
|
|
u.CreatedBy = &createdBy.Int64
|
|
}
|
|
out = append(out, u)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// AccessibleDomainIDs returns the domains a scoped admin can see/manage. Meaningless
|
|
// for a global admin (they can access everything regardless of this table).
|
|
func (d *DB) AccessibleDomainIDs(userID int64) ([]int64, error) {
|
|
rows, err := d.Query(`SELECT domain_id FROM esrv_admin_domain_access WHERE admin_user_id = ?`, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []int64
|
|
for rows.Next() {
|
|
var id int64
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, id)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// GrantDomainAccess mirrors auto-assigning a newly-created domain to the scoped admin
|
|
// who created it.
|
|
func (d *DB) GrantDomainAccess(userID, domainID int64) error {
|
|
_, err := d.Exec(`INSERT OR IGNORE INTO esrv_admin_domain_access (admin_user_id, domain_id) VALUES (?, ?)`, userID, domainID)
|
|
return err
|
|
}
|
|
|
|
// SetAdminDomainAccess replaces a scoped admin's entire domain assignment set.
|
|
func (d *DB) SetAdminDomainAccess(userID int64, domainIDs []int64) error {
|
|
tx, err := d.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err := tx.Exec(`DELETE FROM esrv_admin_domain_access WHERE admin_user_id = ?`, userID); err != nil {
|
|
return err
|
|
}
|
|
for _, id := range domainIDs {
|
|
if _, err := tx.Exec(`INSERT INTO esrv_admin_domain_access (admin_user_id, domain_id) VALUES (?, ?)`, userID, id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// DeleteAdminUser removes an admin account and everything tied to it.
|
|
func (d *DB) DeleteAdminUser(id int64) error {
|
|
tx, err := d.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
for _, stmt := range []string{
|
|
`DELETE FROM esrv_admin_domain_access WHERE admin_user_id = ?`,
|
|
`DELETE FROM esrv_admin_sessions WHERE user_id = ?`,
|
|
`DELETE FROM esrv_webauthn_credentials WHERE user_id = ?`,
|
|
`DELETE FROM esrv_admin_users WHERE id = ?`,
|
|
} {
|
|
if _, err := tx.Exec(stmt, id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (d *DB) GetAdminUserByUsername(username string) (*AdminUser, error) {
|
|
row := d.QueryRow(`SELECT `+adminUserColumns+` FROM esrv_admin_users WHERE lower(username) = lower(?)`, username)
|
|
return scanAdminUser(row)
|
|
}
|
|
|
|
func (d *DB) GetAdminUserByID(id int64) (*AdminUser, error) {
|
|
row := d.QueryRow(`SELECT `+adminUserColumns+` FROM esrv_admin_users WHERE id = ?`, id)
|
|
return scanAdminUser(row)
|
|
}
|
|
|
|
// UpdateAdminCredentials mirrors the forced first-login change: new username,
|
|
// password hash, and clears must_change_password in one step.
|
|
func (d *DB) UpdateAdminCredentials(id int64, username, passwordHash string) error {
|
|
_, err := d.Exec(`UPDATE esrv_admin_users SET username = ?, password_hash = ?, must_change_password = 0 WHERE id = ?`, username, passwordHash, id)
|
|
return err
|
|
}
|
|
|
|
func (d *DB) UpdateAdminPassword(id int64, passwordHash string) error {
|
|
_, err := d.Exec(`UPDATE esrv_admin_users SET password_hash = ? WHERE id = ?`, passwordHash, id)
|
|
return err
|
|
}
|
|
|
|
func (d *DB) SetAdminTOTPSecret(id int64, secret string, enabled bool) error {
|
|
_, err := d.Exec(`UPDATE esrv_admin_users SET totp_secret = ?, totp_enabled = ? WHERE id = ?`, secret, enabled, id)
|
|
return err
|
|
}
|
|
|
|
func (d *DB) DisableAdminTOTP(id int64) error {
|
|
_, err := d.Exec(`UPDATE esrv_admin_users SET totp_secret = '', totp_enabled = 0 WHERE id = ?`, id)
|
|
return err
|
|
}
|
|
|
|
// --- Sessions ---
|
|
|
|
func newSessionToken() string {
|
|
b := make([]byte, 32)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// CreateSession mirrors starting a new login session; mfaVerified should be true only
|
|
// when the account has no second factor enabled (nothing left to verify) or the second
|
|
// factor was just satisfied.
|
|
func (d *DB) CreateSession(userID int64, mfaVerified bool, ttl time.Duration) (string, error) {
|
|
token := newSessionToken()
|
|
_, err := d.Exec(`INSERT INTO esrv_admin_sessions (token, user_id, mfa_verified, expires_at) VALUES (?, ?, ?, ?)`,
|
|
token, userID, mfaVerified, time.Now().Add(ttl))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
func (d *DB) GetSession(token string) (*AdminSession, error) {
|
|
row := d.QueryRow(`SELECT token, user_id, mfa_verified, created_at, expires_at FROM esrv_admin_sessions WHERE token = ?`, token)
|
|
var s AdminSession
|
|
var createdAt, expiresAt string
|
|
if err := row.Scan(&s.Token, &s.UserID, &s.MFAVerified, &createdAt, &expiresAt); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
s.CreatedAt, _ = parseTime(createdAt)
|
|
s.ExpiresAt, _ = parseTime(expiresAt)
|
|
return &s, nil
|
|
}
|
|
|
|
func (d *DB) MarkSessionMFAVerified(token string) error {
|
|
_, err := d.Exec(`UPDATE esrv_admin_sessions SET mfa_verified = 1 WHERE token = ?`, token)
|
|
return err
|
|
}
|
|
|
|
func (d *DB) DeleteSession(token string) error {
|
|
_, err := d.Exec(`DELETE FROM esrv_admin_sessions WHERE token = ?`, token)
|
|
return err
|
|
}
|
|
|
|
// DeleteExpiredSessions is a lightweight best-effort sweep, called opportunistically
|
|
// rather than on a schedule — this admin UI has at most a handful of sessions ever.
|
|
func (d *DB) DeleteExpiredSessions() error {
|
|
_, err := d.Exec(`DELETE FROM esrv_admin_sessions WHERE expires_at < ?`, time.Now())
|
|
return err
|
|
}
|
|
|
|
// --- WebAuthn credentials ---
|
|
|
|
func (d *DB) ListWebAuthnCredentials(userID int64) ([]WebAuthnCredential, error) {
|
|
rows, err := d.Query(`SELECT id, user_id, name, credential_id, credential_data, created_at FROM esrv_webauthn_credentials WHERE user_id = ? ORDER BY created_at`, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []WebAuthnCredential
|
|
for rows.Next() {
|
|
var c WebAuthnCredential
|
|
var createdAt string
|
|
if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.CredentialID, &c.CredentialData, &createdAt); err != nil {
|
|
return nil, err
|
|
}
|
|
c.CreatedAt, _ = parseTime(createdAt)
|
|
out = append(out, c)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (d *DB) CreateWebAuthnCredential(userID int64, name, credentialID, credentialData string) error {
|
|
_, err := d.Exec(`INSERT INTO esrv_webauthn_credentials (user_id, name, credential_id, credential_data) VALUES (?, ?, ?, ?)`,
|
|
userID, name, credentialID, credentialData)
|
|
return err
|
|
}
|
|
|
|
func (d *DB) DeleteWebAuthnCredential(id, userID int64) error {
|
|
_, err := d.Exec(`DELETE FROM esrv_webauthn_credentials WHERE id = ? AND user_id = ?`, id, userID)
|
|
return err
|
|
}
|
|
|
|
func (d *DB) CountWebAuthnCredentials(userID int64) (int, error) {
|
|
var n int
|
|
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_webauthn_credentials WHERE user_id = ?`, userID).Scan(&n)
|
|
return n, err
|
|
}
|