Release prep: 54 engines, self-hosted signatures, i18n, dashboard updates

This commit is contained in:
DmitrL-dev 2026-03-23 16:45:40 +10:00
parent 694e32be26
commit 41cbfd6e0a
178 changed files with 36008 additions and 399 deletions

View file

@ -0,0 +1,485 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
// Standard user errors.
var (
ErrUserNotFound = errors.New("auth: user not found")
ErrUserExists = errors.New("auth: user already exists")
ErrInvalidPassword = errors.New("auth: invalid password")
ErrUserDisabled = errors.New("auth: account disabled")
ErrEmailNotVerified = errors.New("auth: email not verified")
ErrInvalidVerifyCode = errors.New("auth: invalid or expired verification code")
)
// User represents an authenticated user in the system.
type User struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"` // admin, analyst, viewer
Active bool `json:"active"`
EmailVerified bool `json:"email_verified"`
PasswordHash string `json:"-"` // never serialized
VerifyToken string `json:"-"` // never serialized
VerifyExpiry *time.Time `json:"-"` // never serialized
CreatedAt time.Time `json:"created_at"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
}
// UserStore manages user credentials backed by SQLite.
// Falls back to in-memory store if no DB is provided.
type UserStore struct {
mu sync.RWMutex
db *sql.DB
users map[string]*User // email -> User (in-memory cache / fallback)
}
// NewUserStore creates a user store. If db is nil, uses in-memory only.
func NewUserStore(db ...*sql.DB) *UserStore {
s := &UserStore{
users: make(map[string]*User),
}
if len(db) > 0 && db[0] != nil {
s.db = db[0]
if err := s.migrate(); err != nil {
slog.Error("user store: migration failed", "error", err)
} else {
s.loadFromDB()
}
}
// Ensure default admin exists
if _, err := s.GetByEmail("admin@xn--80akacl3adqr.xn--p1acf"); err != nil {
hash, _ := bcrypt.GenerateFromPassword([]byte("syntrex-admin-2026"), bcrypt.DefaultCost)
admin := &User{
ID: generateID("usr"),
Email: "admin@xn--80akacl3adqr.xn--p1acf",
DisplayName: "Administrator",
Role: "admin",
Active: true,
EmailVerified: true, // default admin is pre-verified
PasswordHash: string(hash),
CreatedAt: time.Now(),
}
s.mu.Lock()
s.users[admin.Email] = admin
s.mu.Unlock()
if s.db != nil {
s.persistUser(admin)
}
slog.Info("default admin created", "email", admin.Email)
}
return s
}
// migrate creates the users table if not exists.
func (s *UserStore) migrate() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'viewer',
active INTEGER NOT NULL DEFAULT 1,
email_verified INTEGER NOT NULL DEFAULT 0,
password_hash TEXT NOT NULL,
verify_token TEXT DEFAULT '',
verify_expiry TEXT DEFAULT '',
created_at TEXT NOT NULL,
last_login_at TEXT
);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
key_hash TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'viewer',
created_at TEXT NOT NULL,
last_used TEXT
);
`)
if err != nil {
return err
}
// Add columns if upgrading from older schema
s.db.Exec(`ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0`)
s.db.Exec(`ALTER TABLE users ADD COLUMN verify_token TEXT DEFAULT ''`)
s.db.Exec(`ALTER TABLE users ADD COLUMN verify_expiry TEXT DEFAULT ''`)
return nil
}
// loadFromDB loads all users from SQLite into memory cache.
func (s *UserStore) loadFromDB() {
rows, err := s.db.Query(`SELECT id, email, display_name, role, active, password_hash, created_at, last_login_at FROM users`)
if err != nil {
slog.Error("load users from DB", "error", err)
return
}
defer rows.Close()
s.mu.Lock()
defer s.mu.Unlock()
for rows.Next() {
var u User
var createdAt string
var lastLogin sql.NullString
if err := rows.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &u.Active, &u.PasswordHash, &createdAt, &lastLogin); err != nil {
continue
}
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
if lastLogin.Valid {
t, _ := time.Parse(time.RFC3339, lastLogin.String)
u.LastLoginAt = &t
}
s.users[u.Email] = &u
}
slog.Info("users loaded from DB", "count", len(s.users))
}
// persistUser writes a user to SQLite.
func (s *UserStore) persistUser(u *User) {
if s.db == nil {
return
}
var lastLogin *string
if u.LastLoginAt != nil {
t := u.LastLoginAt.Format(time.RFC3339)
lastLogin = &t
}
var verifyExpiry string
if u.VerifyExpiry != nil {
verifyExpiry = u.VerifyExpiry.Format(time.RFC3339)
}
verified := 0
if u.EmailVerified {
verified = 1
}
_, err := s.db.Exec(`
INSERT OR REPLACE INTO users (id, email, display_name, role, active, email_verified, password_hash, verify_token, verify_expiry, created_at, last_login_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
u.ID, u.Email, u.DisplayName, u.Role, u.Active, verified, u.PasswordHash, u.VerifyToken, verifyExpiry, u.CreatedAt.Format(time.RFC3339), lastLogin,
)
if err != nil {
slog.Error("persist user", "email", u.Email, "error", err)
}
}
// --- CRUD Operations ---
// CreateUser creates a new user with a hashed password.
func (s *UserStore) CreateUser(email, displayName, password, role string) (*User, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.users[email]; exists {
return nil, ErrUserExists
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("auth: hash password: %w", err)
}
u := &User{
ID: generateID("usr"),
Email: email,
DisplayName: displayName,
Role: role,
Active: true,
PasswordHash: string(hash),
CreatedAt: time.Now(),
}
s.users[email] = u
go s.persistUser(u)
return u, nil
}
// Authenticate validates email/password and returns the user.
func (s *UserStore) Authenticate(email, password string) (*User, error) {
s.mu.RLock()
user, ok := s.users[email]
s.mu.RUnlock()
if !ok {
return nil, ErrUserNotFound
}
if !user.Active {
return nil, ErrUserDisabled
}
if !user.EmailVerified {
return nil, ErrEmailNotVerified
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return nil, ErrInvalidPassword
}
// Update last login
now := time.Now()
s.mu.Lock()
user.LastLoginAt = &now
s.mu.Unlock()
go s.persistUser(user)
return user, nil
}
// SetVerifyToken generates a 6-digit verification code for a user.
func (s *UserStore) SetVerifyToken(email string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
user, ok := s.users[email]
if !ok {
return "", ErrUserNotFound
}
// Generate 6-digit code
b := make([]byte, 3)
rand.Read(b)
code := fmt.Sprintf("%06d", int(b[0])<<16|int(b[1])<<8|int(b[2])%1000000)
if len(code) > 6 {
code = code[:6]
}
expiry := time.Now().Add(24 * time.Hour)
user.VerifyToken = code
user.VerifyExpiry = &expiry
go s.persistUser(user)
return code, nil
}
// VerifyEmail checks the verification code and marks email as verified.
func (s *UserStore) VerifyEmail(email, code string) error {
s.mu.Lock()
defer s.mu.Unlock()
user, ok := s.users[email]
if !ok {
return ErrUserNotFound
}
if user.VerifyToken == "" || user.VerifyToken != code {
return ErrInvalidVerifyCode
}
if user.VerifyExpiry != nil && time.Now().After(*user.VerifyExpiry) {
return ErrInvalidVerifyCode
}
user.EmailVerified = true
user.VerifyToken = ""
user.VerifyExpiry = nil
go s.persistUser(user)
return nil
}
// GetByEmail returns a user by email.
func (s *UserStore) GetByEmail(email string) (*User, error) {
s.mu.RLock()
defer s.mu.RUnlock()
user, ok := s.users[email]
if !ok {
return nil, ErrUserNotFound
}
return user, nil
}
// GetByID returns a user by ID.
func (s *UserStore) GetByID(id string) (*User, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, u := range s.users {
if u.ID == id {
return u, nil
}
}
return nil, ErrUserNotFound
}
// ListUsers returns all users.
func (s *UserStore) ListUsers() []*User {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]*User, 0, len(s.users))
for _, u := range s.users {
result = append(result, u)
}
return result
}
// UpdateUser updates a user's display name, role, and active status.
func (s *UserStore) UpdateUser(id, displayName, role string, active bool) error {
s.mu.Lock()
defer s.mu.Unlock()
for _, u := range s.users {
if u.ID == id {
if displayName != "" {
u.DisplayName = displayName
}
if role != "" {
u.Role = role
}
u.Active = active
go s.persistUser(u)
return nil
}
}
return ErrUserNotFound
}
// ChangePassword updates a user's password.
func (s *UserStore) ChangePassword(id, newPassword string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("auth: hash password: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
for _, u := range s.users {
if u.ID == id {
u.PasswordHash = string(hash)
go s.persistUser(u)
return nil
}
}
return ErrUserNotFound
}
// DeleteUser permanently removes a user.
func (s *UserStore) DeleteUser(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
for email, u := range s.users {
if u.ID == id {
delete(s.users, email)
if s.db != nil {
go s.db.Exec(`DELETE FROM users WHERE id = ?`, id)
}
return nil
}
}
return ErrUserNotFound
}
// --- API Key Management ---
// APIKey represents an API key for programmatic access.
type APIKey struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Role string `json:"role"`
KeyPrefix string `json:"key_prefix"` // first 8 chars for display
CreatedAt time.Time `json:"created_at"`
LastUsed *time.Time `json:"last_used,omitempty"`
}
// CreateAPIKey generates a new API key for a user. Returns the full key (only shown once).
func (s *UserStore) CreateAPIKey(userID, name, role string) (string, *APIKey, error) {
rawKey := make([]byte, 32)
if _, err := rand.Read(rawKey); err != nil {
return "", nil, err
}
fullKey := "stx_" + hex.EncodeToString(rawKey)
keyHash := hashKey(fullKey)
ak := &APIKey{
ID: generateID("key"),
UserID: userID,
Name: name,
Role: role,
KeyPrefix: fullKey[:12],
CreatedAt: time.Now(),
}
if s.db != nil {
_, err := s.db.Exec(`INSERT INTO api_keys (id, user_id, key_hash, name, role, created_at) VALUES (?,?,?,?,?,?)`,
ak.ID, ak.UserID, keyHash, ak.Name, ak.Role, ak.CreatedAt.Format(time.RFC3339))
if err != nil {
return "", nil, err
}
}
return fullKey, ak, nil
}
// ValidateAPIKey checks an API key and returns the associated role.
func (s *UserStore) ValidateAPIKey(key string) (string, string, error) {
if s.db == nil {
return "", "", fmt.Errorf("no database for API keys")
}
keyHash := hashKey(key)
var userID, role string
err := s.db.QueryRow(`SELECT user_id, role FROM api_keys WHERE key_hash = ?`, keyHash).Scan(&userID, &role)
if err != nil {
return "", "", ErrInvalidToken
}
// Update last_used
go s.db.Exec(`UPDATE api_keys SET last_used = ? WHERE key_hash = ?`, time.Now().Format(time.RFC3339), keyHash)
return userID, role, nil
}
// ListAPIKeys returns all API keys for a user.
func (s *UserStore) ListAPIKeys(userID string) ([]APIKey, error) {
if s.db == nil {
return nil, nil
}
rows, err := s.db.Query(`SELECT id, user_id, name, role, created_at, last_used FROM api_keys WHERE user_id = ?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []APIKey
for rows.Next() {
var ak APIKey
var createdAt string
var lastUsed sql.NullString
if err := rows.Scan(&ak.ID, &ak.UserID, &ak.Name, &ak.Role, &createdAt, &lastUsed); err != nil {
continue
}
ak.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
if lastUsed.Valid {
t, _ := time.Parse(time.RFC3339, lastUsed.String)
ak.LastUsed = &t
}
keys = append(keys, ak)
}
return keys, nil
}
// DeleteAPIKey revokes an API key.
func (s *UserStore) DeleteAPIKey(keyID, userID string) error {
if s.db == nil {
return nil
}
_, err := s.db.Exec(`DELETE FROM api_keys WHERE id = ? AND user_id = ?`, keyID, userID)
return err
}
// --- Helpers ---
func generateID(prefix string) string {
b := make([]byte, 8)
rand.Read(b)
return fmt.Sprintf("%s-%s", prefix, hex.EncodeToString(b))
}
func hashKey(key string) string {
h := sha256.Sum256([]byte(key))
return hex.EncodeToString(h[:])
}