mirror of
https://github.com/syntrex-lab/gomcp.git
synced 2026-04-25 20:36:21 +02:00
482 lines
13 KiB
Go
482 lines
13 KiB
Go
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
|
|
TenantID string `json:"tenant_id,omitempty"`
|
|
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@syntrex.pro"); err != nil {
|
|
hash, _ := bcrypt.GenerateFromPassword([]byte("syntrex-admin-2026"), bcrypt.DefaultCost)
|
|
admin := &User{
|
|
ID: generateID("usr"),
|
|
Email: "admin@syntrex.pro",
|
|
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 BOOLEAN NOT NULL DEFAULT true,
|
|
email_verified BOOLEAN NOT NULL DEFAULT false,
|
|
password_hash TEXT NOT NULL,
|
|
verify_token TEXT DEFAULT '',
|
|
verify_expiry TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
last_login_at TIMESTAMPTZ
|
|
);
|
|
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 TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
last_used TIMESTAMPTZ
|
|
);
|
|
`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Add columns if upgrading from older schema (ignore errors if column exists)
|
|
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS display_name TEXT NOT NULL DEFAULT ''`)
|
|
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT false`)
|
|
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS verify_token TEXT DEFAULT ''`)
|
|
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS verify_expiry TIMESTAMPTZ`)
|
|
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS tenant_id TEXT DEFAULT ''`)
|
|
return nil
|
|
}
|
|
|
|
// loadFromDB loads all users from DB into memory cache.
|
|
func (s *UserStore) loadFromDB() {
|
|
rows, err := s.db.Query(`SELECT id, email, display_name, role, active, email_verified, password_hash, created_at, last_login_at, COALESCE(tenant_id, '') 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 lastLogin sql.NullTime
|
|
if err := rows.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &u.Active, &u.EmailVerified, &u.PasswordHash, &u.CreatedAt, &lastLogin, &u.TenantID); err != nil {
|
|
slog.Warn("load user row scan", "error", err)
|
|
continue
|
|
}
|
|
if lastLogin.Valid {
|
|
u.LastLoginAt = &lastLogin.Time
|
|
}
|
|
s.users[u.Email] = &u
|
|
}
|
|
slog.Info("users loaded from DB", "count", len(s.users))
|
|
}
|
|
|
|
// persistUser writes a user to DB (PostgreSQL-compatible upsert).
|
|
func (s *UserStore) persistUser(u *User) {
|
|
if s.db == nil {
|
|
return
|
|
}
|
|
_, err := s.db.Exec(`
|
|
INSERT INTO users (id, email, display_name, role, active, email_verified, password_hash, verify_token, verify_expiry, created_at, last_login_at, tenant_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
|
ON CONFLICT (id) DO UPDATE SET
|
|
email = EXCLUDED.email,
|
|
display_name = EXCLUDED.display_name,
|
|
role = EXCLUDED.role,
|
|
active = EXCLUDED.active,
|
|
email_verified = EXCLUDED.email_verified,
|
|
password_hash = EXCLUDED.password_hash,
|
|
verify_token = EXCLUDED.verify_token,
|
|
verify_expiry = EXCLUDED.verify_expiry,
|
|
last_login_at = EXCLUDED.last_login_at,
|
|
tenant_id = CASE WHEN EXCLUDED.tenant_id = '' OR EXCLUDED.tenant_id IS NULL
|
|
THEN users.tenant_id ELSE EXCLUDED.tenant_id END`,
|
|
u.ID, u.Email, u.DisplayName, u.Role, u.Active, u.EmailVerified, u.PasswordHash, u.VerifyToken, u.VerifyExpiry, u.CreatedAt, u.LastLoginAt, u.TenantID,
|
|
)
|
|
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 = $1`, 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 ($1,$2,$3,$4,$5,$6)`,
|
|
ak.ID, ak.UserID, keyHash, ak.Name, ak.Role, ak.CreatedAt)
|
|
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 = $1`, keyHash).Scan(&userID, &role)
|
|
if err != nil {
|
|
return "", "", ErrInvalidToken
|
|
}
|
|
|
|
// Update last_used
|
|
go s.db.Exec(`UPDATE api_keys SET last_used = $1 WHERE key_hash = $2`, time.Now(), 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 = $1`, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var keys []APIKey
|
|
for rows.Next() {
|
|
var ak APIKey
|
|
var lastUsed sql.NullTime
|
|
if err := rows.Scan(&ak.ID, &ak.UserID, &ak.Name, &ak.Role, &ak.CreatedAt, &lastUsed); err != nil {
|
|
continue
|
|
}
|
|
if lastUsed.Valid {
|
|
ak.LastUsed = &lastUsed.Time
|
|
}
|
|
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 = $1 AND user_id = $2`, 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[:])
|
|
}
|