Files
ihasvpn/internal/store/users.go
T
jcoffey 6c006e1d4d WireGuard server with an embedded admin console
Go backend that drives kernel WireGuard over netlink (wireguard-go as the
fallback), nftables NAT with MSS clamping, forwarding and buffer sysctls,
SQLite for peers, users, sessions, traffic history and the audit log.

React console: dashboard with live rates and usage history, peer management
with QR codes and .conf downloads, disconnect, session reset, key rotation,
expiry, client-supplied keys, settings, users with admin and viewer roles,
two-factor authentication with recovery codes, audit log.

Docker image on Alpine with compose files for bridged and host networking,
CI and GHCR publish workflows, performance notes.
2026-09-12 19:56:08 -07:00

240 lines
7.6 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"time"
)
// ErrNotFound is returned when a row does not exist.
var ErrNotFound = errors.New("not found")
// User is an administrator account.
type User struct {
ID int64
Username string
PasswordHash string
Role string
TOTPSecret string
TOTPEnabled bool
CreatedAt time.Time
LastLoginAt time.Time
}
func scanUser(row interface{ Scan(...any) error }) (*User, error) {
var u User
var secret sql.NullString
var totp int
var created int64
var last sql.NullInt64
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &secret, &totp, &created, &last); err != nil {
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
return nil, err
}
u.TOTPSecret = secret.String
u.TOTPEnabled = totp == 1
u.CreatedAt = time.Unix(created, 0)
if last.Valid {
u.LastLoginAt = time.Unix(last.Int64, 0)
}
return &u, nil
}
const userCols = `id, username, password_hash, role, totp_secret, totp_enabled, created_at, last_login_at`
// CountUsers returns how many users exist; zero means first-run setup is due.
func (s *Store) CountUsers(ctx context.Context) (int, error) {
var n int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n)
return n, err
}
// CreateUser inserts a user and returns it.
func (s *Store) CreateUser(ctx context.Context, username, passwordHash, role string) (*User, error) {
now := time.Now().Unix()
res, err := s.db.ExecContext(ctx, `INSERT INTO users(username, password_hash, role, created_at) VALUES(?, ?, ?, ?)`, username, passwordHash, role, now)
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
return s.UserByID(ctx, id)
}
// UserByID looks a user up by id.
func (s *Store) UserByID(ctx context.Context, id int64) (*User, error) {
return scanUser(s.db.QueryRowContext(ctx, `SELECT `+userCols+` FROM users WHERE id = ?`, id))
}
// UserByName looks a user up by username (case-insensitive).
func (s *Store) UserByName(ctx context.Context, name string) (*User, error) {
return scanUser(s.db.QueryRowContext(ctx, `SELECT `+userCols+` FROM users WHERE username = ?`, name))
}
// ListUsers returns every user ordered by username.
func (s *Store) ListUsers(ctx context.Context) ([]*User, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+userCols+` FROM users ORDER BY username`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*User
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
// SetPassword replaces a user's password hash.
func (s *Store) SetPassword(ctx context.Context, id int64, hash string) error {
_, err := s.db.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, hash, id)
return err
}
// SetRole changes a user's role.
func (s *Store) SetRole(ctx context.Context, id int64, role string) error {
_, err := s.db.ExecContext(ctx, `UPDATE users SET role = ? WHERE id = ?`, role, id)
return err
}
// SetTOTP stores a secret and whether it is active. An empty secret clears it.
func (s *Store) SetTOTP(ctx context.Context, id int64, secret string, enabled bool) error {
var sec any
if secret != "" {
sec = secret
}
en := 0
if enabled {
en = 1
}
_, err := s.db.ExecContext(ctx, `UPDATE users SET totp_secret = ?, totp_enabled = ? WHERE id = ?`, sec, en, id)
return err
}
// TouchLogin records a successful login.
func (s *Store) TouchLogin(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `UPDATE users SET last_login_at = ? WHERE id = ?`, time.Now().Unix(), id)
return err
}
// DeleteUser removes a user and, through cascades, their sessions and codes.
func (s *Store) DeleteUser(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
return err
}
// ReplaceRecoveryCodes replaces a user's recovery codes with the given hashes.
func (s *Store) ReplaceRecoveryCodes(ctx context.Context, id int64, hashes []string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM recovery_codes WHERE user_id = ?`, id); err != nil {
return err
}
for _, h := range hashes {
if _, err := tx.ExecContext(ctx, `INSERT INTO recovery_codes(user_id, code_hash) VALUES(?, ?)`, id, h); err != nil {
return err
}
}
return tx.Commit()
}
// UseRecoveryCode marks a code used if it exists and is unused; it reports
// whether it did.
func (s *Store) UseRecoveryCode(ctx context.Context, id int64, hash string) (bool, error) {
res, err := s.db.ExecContext(ctx, `UPDATE recovery_codes SET used_at = ? WHERE user_id = ? AND code_hash = ? AND used_at IS NULL`, time.Now().Unix(), id, hash)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n == 1, nil
}
// RecoveryCodesLeft counts a user's unused recovery codes.
func (s *Store) RecoveryCodesLeft(ctx context.Context, id int64) (int, error) {
var n int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM recovery_codes WHERE user_id = ? AND used_at IS NULL`, id).Scan(&n)
return n, err
}
// Session is one logged-in browser.
type Session struct {
TokenHash string
UserID int64
CreatedAt time.Time
LastSeenAt time.Time
ExpiresAt time.Time
IP string
UserAgent string
TOTPPending bool
}
// CreateSession stores a session.
func (s *Store) CreateSession(ctx context.Context, sess Session) error {
pending := 0
if sess.TOTPPending {
pending = 1
}
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions(token_hash, user_id, created_at, last_seen_at, expires_at, ip, user_agent, totp_pending) VALUES(?, ?, ?, ?, ?, ?, ?, ?)`,
sess.TokenHash, sess.UserID, sess.CreatedAt.Unix(), sess.LastSeenAt.Unix(), sess.ExpiresAt.Unix(), sess.IP, sess.UserAgent, pending)
return err
}
// SessionByHash loads a session.
func (s *Store) SessionByHash(ctx context.Context, hash string) (*Session, error) {
var sess Session
var created, seen, exp int64
var pending int
err := s.db.QueryRowContext(ctx, `SELECT token_hash, user_id, created_at, last_seen_at, expires_at, ip, user_agent, totp_pending FROM sessions WHERE token_hash = ?`, hash).
Scan(&sess.TokenHash, &sess.UserID, &created, &seen, &exp, &sess.IP, &sess.UserAgent, &pending)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
sess.CreatedAt = time.Unix(created, 0)
sess.LastSeenAt = time.Unix(seen, 0)
sess.ExpiresAt = time.Unix(exp, 0)
sess.TOTPPending = pending == 1
return &sess, nil
}
// TouchSession bumps last_seen and the sliding expiry.
func (s *Store) TouchSession(ctx context.Context, hash string, expires time.Time) error {
_, err := s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE token_hash = ?`, time.Now().Unix(), expires.Unix(), hash)
return err
}
// ClearTOTPPending marks a session fully authenticated.
func (s *Store) ClearTOTPPending(ctx context.Context, hash string) error {
_, err := s.db.ExecContext(ctx, `UPDATE sessions SET totp_pending = 0 WHERE token_hash = ?`, hash)
return err
}
// DeleteSession logs one browser out.
func (s *Store) DeleteSession(ctx context.Context, hash string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, hash)
return err
}
// DeleteUserSessions logs a user out everywhere.
func (s *Store) DeleteUserSessions(ctx context.Context, userID int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID)
return err
}
// PruneSessions drops expired sessions.
func (s *Store) PruneSessions(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < ?`, time.Now().Unix())
return err
}