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[:]) }