1a69fcfd04
- Updated the installation script to clarify that production wrappers do not include fixed test secrets, improving user guidance for local testing. - Modified the jiggablend-manager and jiggablend-runner scripts to remove fixed test configurations, emphasizing the use of local test credentials via `make init-test`. - Enhanced error handling in the runner script to ensure that an API key is provided, improving security and user feedback. - Added new flags and options for the runner, including sandboxing capabilities and GPU ray tracing control, enhancing flexibility for users. - Improved README documentation to reflect changes in usage and configuration, ensuring users have clear instructions for setup and execution.
1046 lines
30 KiB
Go
1046 lines
30 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"jiggablend/internal/config"
|
|
"jiggablend/internal/database"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"golang.org/x/oauth2"
|
|
"golang.org/x/oauth2/google"
|
|
)
|
|
|
|
// Context key types to avoid collisions (typed keys are safer than string keys)
|
|
type contextKey int
|
|
|
|
const (
|
|
contextKeyUserID contextKey = iota
|
|
contextKeyUserEmail
|
|
contextKeyUserName
|
|
contextKeyIsAdmin
|
|
)
|
|
|
|
// Configuration constants
|
|
const (
|
|
SessionDuration = 24 * time.Hour
|
|
SessionCleanupInterval = 1 * time.Hour
|
|
)
|
|
|
|
// Auth handles authentication
|
|
type Auth struct {
|
|
db *database.DB
|
|
cfg *config.Config
|
|
googleConfig *oauth2.Config
|
|
discordConfig *oauth2.Config
|
|
sessionCache map[string]*Session // In-memory cache for performance
|
|
cacheMu sync.RWMutex
|
|
stopCleanup chan struct{}
|
|
// oauthStates maps state token -> expiry for CSRF protection
|
|
oauthStates map[string]time.Time
|
|
oauthMu sync.Mutex
|
|
}
|
|
|
|
// Session represents a user session
|
|
type Session struct {
|
|
UserID int64
|
|
Email string
|
|
Name string
|
|
IsAdmin bool
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// NewAuth creates a new auth instance
|
|
func NewAuth(db *database.DB, cfg *config.Config) (*Auth, error) {
|
|
auth := &Auth{
|
|
db: db,
|
|
cfg: cfg,
|
|
sessionCache: make(map[string]*Session),
|
|
stopCleanup: make(chan struct{}),
|
|
oauthStates: make(map[string]time.Time),
|
|
}
|
|
|
|
// Initialize Google OAuth from database config
|
|
googleClientID := cfg.GoogleClientID()
|
|
googleClientSecret := cfg.GoogleClientSecret()
|
|
if googleClientID != "" && googleClientSecret != "" {
|
|
auth.googleConfig = &oauth2.Config{
|
|
ClientID: googleClientID,
|
|
ClientSecret: googleClientSecret,
|
|
RedirectURL: cfg.GoogleRedirectURL(),
|
|
Scopes: []string{"openid", "profile", "email"},
|
|
Endpoint: google.Endpoint,
|
|
}
|
|
log.Printf("Google OAuth configured")
|
|
}
|
|
|
|
// Initialize Discord OAuth from database config
|
|
discordClientID := cfg.DiscordClientID()
|
|
discordClientSecret := cfg.DiscordClientSecret()
|
|
if discordClientID != "" && discordClientSecret != "" {
|
|
auth.discordConfig = &oauth2.Config{
|
|
ClientID: discordClientID,
|
|
ClientSecret: discordClientSecret,
|
|
RedirectURL: cfg.DiscordRedirectURL(),
|
|
Scopes: []string{"identify", "email"},
|
|
Endpoint: oauth2.Endpoint{
|
|
AuthURL: "https://discord.com/api/oauth2/authorize",
|
|
TokenURL: "https://discord.com/api/oauth2/token",
|
|
},
|
|
}
|
|
log.Printf("Discord OAuth configured")
|
|
}
|
|
|
|
// Load existing sessions from database into cache
|
|
if err := auth.loadSessionsFromDB(); err != nil {
|
|
log.Printf("Warning: Failed to load sessions from database: %v", err)
|
|
}
|
|
|
|
// Start background cleanup goroutine
|
|
go auth.cleanupExpiredSessions()
|
|
|
|
// Initialize admin settings on startup to ensure they persist between boots
|
|
if err := auth.initializeSettings(); err != nil {
|
|
log.Printf("Warning: Failed to initialize admin settings: %v", err)
|
|
// Don't fail startup, but log the warning
|
|
}
|
|
|
|
// Initialize test local user from environment variables (for testing only)
|
|
if err := auth.initializeTestUser(); err != nil {
|
|
log.Printf("Warning: Failed to initialize test user: %v", err)
|
|
// Don't fail startup, but log the warning
|
|
}
|
|
|
|
return auth, nil
|
|
}
|
|
|
|
// Close stops background goroutines
|
|
func (a *Auth) Close() {
|
|
close(a.stopCleanup)
|
|
}
|
|
|
|
// loadSessionsFromDB loads all valid sessions from database into cache
|
|
func (a *Auth) loadSessionsFromDB() error {
|
|
var sessions []struct {
|
|
sessionID string
|
|
session Session
|
|
}
|
|
|
|
err := a.db.With(func(conn *sql.DB) error {
|
|
rows, err := conn.Query(
|
|
`SELECT session_id, user_id, email, name, is_admin, expires_at
|
|
FROM sessions WHERE expires_at > CURRENT_TIMESTAMP`,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to query sessions: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var s struct {
|
|
sessionID string
|
|
session Session
|
|
}
|
|
err := rows.Scan(&s.sessionID, &s.session.UserID, &s.session.Email, &s.session.Name, &s.session.IsAdmin, &s.session.ExpiresAt)
|
|
if err != nil {
|
|
log.Printf("Warning: Failed to scan session row: %v", err)
|
|
continue
|
|
}
|
|
sessions = append(sessions, s)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
a.cacheMu.Lock()
|
|
defer a.cacheMu.Unlock()
|
|
|
|
for _, s := range sessions {
|
|
a.sessionCache[s.sessionID] = &s.session
|
|
}
|
|
|
|
if len(sessions) > 0 {
|
|
log.Printf("Loaded %d active sessions from database", len(sessions))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// cleanupExpiredSessions periodically removes expired sessions from database and cache
|
|
func (a *Auth) cleanupExpiredSessions() {
|
|
ticker := time.NewTicker(SessionCleanupInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
// Delete expired sessions from database
|
|
var deleted int64
|
|
err := a.db.With(func(conn *sql.DB) error {
|
|
result, err := conn.Exec(`DELETE FROM sessions WHERE expires_at < CURRENT_TIMESTAMP`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
deleted, _ = result.RowsAffected()
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
log.Printf("Warning: Failed to cleanup expired sessions: %v", err)
|
|
continue
|
|
}
|
|
|
|
// Clean up cache
|
|
a.cacheMu.Lock()
|
|
now := time.Now()
|
|
for sessionID, session := range a.sessionCache {
|
|
if now.After(session.ExpiresAt) {
|
|
delete(a.sessionCache, sessionID)
|
|
}
|
|
}
|
|
a.cacheMu.Unlock()
|
|
|
|
if deleted > 0 {
|
|
log.Printf("Cleaned up %d expired sessions", deleted)
|
|
}
|
|
case <-a.stopCleanup:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// initializeSettings ensures all admin settings are initialized with defaults if they don't exist
|
|
func (a *Auth) initializeSettings() error {
|
|
// Default registration to false (safer for internet-facing boots). Admins enable via CLI/UI.
|
|
// In non-production, still default false — make init-test / CLI create the first admin.
|
|
defaultReg := "false"
|
|
var settingCount int
|
|
err := a.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT COUNT(*) FROM settings WHERE key = ?", "registration_enabled").Scan(&settingCount)
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check registration_enabled setting: %w", err)
|
|
}
|
|
if settingCount == 0 {
|
|
err = a.db.With(func(conn *sql.DB) error {
|
|
_, err := conn.Exec(
|
|
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)`,
|
|
"registration_enabled", defaultReg,
|
|
)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize registration_enabled setting: %w", err)
|
|
}
|
|
log.Printf("Initialized admin setting: registration_enabled = %s", defaultReg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// initializeTestUser creates a test local user from environment variables (for testing only)
|
|
func (a *Auth) initializeTestUser() error {
|
|
testEmail := os.Getenv("LOCAL_TEST_EMAIL")
|
|
testPassword := os.Getenv("LOCAL_TEST_PASSWORD")
|
|
|
|
if testEmail == "" || testPassword == "" {
|
|
// No test user configured, skip
|
|
return nil
|
|
}
|
|
|
|
// Check if user already exists
|
|
var exists bool
|
|
err := a.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT EXISTS(SELECT 1 FROM users WHERE email = ? AND oauth_provider = 'local')", testEmail).Scan(&exists)
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check if test user exists: %w", err)
|
|
}
|
|
|
|
if exists {
|
|
// User already exists, skip creation
|
|
log.Printf("Test user %s already exists, skipping creation", testEmail)
|
|
return nil
|
|
}
|
|
|
|
// Hash password
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(testPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to hash test user password: %w", err)
|
|
}
|
|
|
|
// Check if this is the first user (make them admin)
|
|
var userCount int
|
|
err = a.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT COUNT(*) FROM users").Scan(&userCount)
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check user count: %w", err)
|
|
}
|
|
isAdmin := userCount == 0
|
|
|
|
// Create test user (use email as name if no name is provided)
|
|
testName := testEmail
|
|
if atIndex := strings.Index(testEmail, "@"); atIndex > 0 {
|
|
testName = testEmail[:atIndex]
|
|
}
|
|
|
|
// Create test user
|
|
err = a.db.With(func(conn *sql.DB) error {
|
|
_, err := conn.Exec(
|
|
"INSERT INTO users (email, name, oauth_provider, oauth_id, password_hash, is_admin) VALUES (?, ?, 'local', ?, ?, ?)",
|
|
testEmail, testName, testEmail, string(hashedPassword), isAdmin,
|
|
)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create test user: %w", err)
|
|
}
|
|
|
|
log.Printf("Created test user: %s (admin: %v)", testEmail, isAdmin)
|
|
return nil
|
|
}
|
|
|
|
// CreateOAuthState mints a one-time OAuth state token and stores it until used or expired.
|
|
func (a *Auth) CreateOAuthState() string {
|
|
state := uuid.New().String()
|
|
a.oauthMu.Lock()
|
|
// Opportunistic cleanup of expired states
|
|
now := time.Now()
|
|
for k, exp := range a.oauthStates {
|
|
if now.After(exp) {
|
|
delete(a.oauthStates, k)
|
|
}
|
|
}
|
|
a.oauthStates[state] = now.Add(10 * time.Minute)
|
|
a.oauthMu.Unlock()
|
|
return state
|
|
}
|
|
|
|
// ConsumeOAuthState validates and single-use-consumes an OAuth state token.
|
|
// Returns false if missing, unknown, or expired.
|
|
func (a *Auth) ConsumeOAuthState(state string) bool {
|
|
if state == "" {
|
|
return false
|
|
}
|
|
a.oauthMu.Lock()
|
|
defer a.oauthMu.Unlock()
|
|
exp, ok := a.oauthStates[state]
|
|
if !ok {
|
|
return false
|
|
}
|
|
delete(a.oauthStates, state)
|
|
return time.Now().Before(exp)
|
|
}
|
|
|
|
// GoogleLoginURL returns the Google OAuth login URL
|
|
func (a *Auth) GoogleLoginURL() (string, error) {
|
|
if a.googleConfig == nil {
|
|
return "", fmt.Errorf("Google OAuth not configured")
|
|
}
|
|
state := a.CreateOAuthState()
|
|
return a.googleConfig.AuthCodeURL(state), nil
|
|
}
|
|
|
|
// DiscordLoginURL returns the Discord OAuth login URL
|
|
func (a *Auth) DiscordLoginURL() (string, error) {
|
|
if a.discordConfig == nil {
|
|
return "", fmt.Errorf("Discord OAuth not configured")
|
|
}
|
|
state := a.CreateOAuthState()
|
|
return a.discordConfig.AuthCodeURL(state), nil
|
|
}
|
|
|
|
// GoogleCallback handles Google OAuth callback
|
|
func (a *Auth) GoogleCallback(ctx context.Context, code string) (*Session, error) {
|
|
if a.googleConfig == nil {
|
|
return nil, fmt.Errorf("Google OAuth not configured")
|
|
}
|
|
|
|
token, err := a.googleConfig.Exchange(ctx, code)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to exchange token: %w", err)
|
|
}
|
|
|
|
client := a.googleConfig.Client(ctx, token)
|
|
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get user info: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var userInfo struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
|
|
return nil, fmt.Errorf("failed to decode user info: %w", err)
|
|
}
|
|
|
|
return a.getOrCreateUser("google", userInfo.ID, userInfo.Email, userInfo.Name)
|
|
}
|
|
|
|
// DiscordCallback handles Discord OAuth callback
|
|
func (a *Auth) DiscordCallback(ctx context.Context, code string) (*Session, error) {
|
|
if a.discordConfig == nil {
|
|
return nil, fmt.Errorf("Discord OAuth not configured")
|
|
}
|
|
|
|
token, err := a.discordConfig.Exchange(ctx, code)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to exchange token: %w", err)
|
|
}
|
|
|
|
client := a.discordConfig.Client(ctx, token)
|
|
resp, err := client.Get("https://discord.com/api/users/@me")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get user info: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var userInfo struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
|
|
return nil, fmt.Errorf("failed to decode user info: %w", err)
|
|
}
|
|
|
|
return a.getOrCreateUser("discord", userInfo.ID, userInfo.Email, userInfo.Username)
|
|
}
|
|
|
|
// IsRegistrationEnabled checks if new user registration is enabled
|
|
func (a *Auth) IsRegistrationEnabled() (bool, error) {
|
|
var value string
|
|
err := a.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT value FROM settings WHERE key = ?", "registration_enabled").Scan(&value)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
// Default to disabled if setting doesn't exist (safer bootstrap)
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to check registration setting: %w", err)
|
|
}
|
|
return value == "true", nil
|
|
}
|
|
|
|
// SetRegistrationEnabled sets whether new user registration is enabled
|
|
func (a *Auth) SetRegistrationEnabled(enabled bool) error {
|
|
value := "false"
|
|
if enabled {
|
|
value = "true"
|
|
}
|
|
|
|
// Check if setting exists
|
|
var exists bool
|
|
err := a.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT EXISTS(SELECT 1 FROM settings WHERE key = ?)", "registration_enabled").Scan(&exists)
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check if setting exists: %w", err)
|
|
}
|
|
|
|
err = a.db.With(func(conn *sql.DB) error {
|
|
if exists {
|
|
// Update existing setting
|
|
_, err = conn.Exec(
|
|
"UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?",
|
|
value, "registration_enabled",
|
|
)
|
|
} else {
|
|
// Insert new setting
|
|
_, err = conn.Exec(
|
|
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
|
|
"registration_enabled", value,
|
|
)
|
|
}
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set registration_enabled: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// getOrCreateUser gets or creates a user in the database.
|
|
// Identity is (oauth_provider, oauth_id). Email collision with a different provider
|
|
// is NOT auto-linked (prevents account takeover); the user must sign in with the original method.
|
|
func (a *Auth) getOrCreateUser(provider, oauthID, email, name string) (*Session, error) {
|
|
var userID int64
|
|
var dbEmail, dbName string
|
|
var isAdmin bool
|
|
var dbProvider, dbOAuthID string
|
|
|
|
// First, try to find by provider + oauth_id
|
|
err := a.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow(
|
|
"SELECT id, email, name, is_admin, oauth_provider, oauth_id FROM users WHERE oauth_provider = ? AND oauth_id = ?",
|
|
provider, oauthID,
|
|
).Scan(&userID, &dbEmail, &dbName, &isAdmin, &dbProvider, &dbOAuthID)
|
|
})
|
|
|
|
if err == sql.ErrNoRows {
|
|
// Not found by provider+oauth_id — check email only to refuse silent takeover
|
|
err = a.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow(
|
|
"SELECT id, email, name, is_admin, oauth_provider, oauth_id FROM users WHERE email = ?",
|
|
email,
|
|
).Scan(&userID, &dbEmail, &dbName, &isAdmin, &dbProvider, &dbOAuthID)
|
|
})
|
|
|
|
if err == sql.ErrNoRows {
|
|
// User doesn't exist, check if registration is enabled
|
|
registrationEnabled, err := a.IsRegistrationEnabled()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check registration setting: %w", err)
|
|
}
|
|
if !registrationEnabled {
|
|
return nil, fmt.Errorf("registration is disabled")
|
|
}
|
|
|
|
// Check if this is the first user
|
|
var userCount int
|
|
err = a.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT COUNT(*) FROM users").Scan(&userCount)
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check user count: %w", err)
|
|
}
|
|
isAdmin = userCount == 0
|
|
|
|
// Create new user
|
|
err = a.db.With(func(conn *sql.DB) error {
|
|
result, err := conn.Exec(
|
|
"INSERT INTO users (email, name, oauth_provider, oauth_id, is_admin) VALUES (?, ?, ?, ?, ?)",
|
|
email, name, provider, oauthID, isAdmin,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
userID, err = result.LastInsertId()
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create user: %w", err)
|
|
}
|
|
} else if err != nil {
|
|
return nil, fmt.Errorf("failed to query user by email: %w", err)
|
|
} else {
|
|
// Email already belongs to another identity — do not overwrite oauth_provider/oauth_id
|
|
return nil, fmt.Errorf("an account with this email already exists; sign in with the original method")
|
|
}
|
|
} else if err != nil {
|
|
return nil, fmt.Errorf("failed to query user: %w", err)
|
|
} else {
|
|
// User found by provider+oauth_id, update info if changed
|
|
if dbEmail != email || dbName != name {
|
|
err = a.db.With(func(conn *sql.DB) error {
|
|
_, err = conn.Exec(
|
|
"UPDATE users SET email = ?, name = ? WHERE id = ?",
|
|
email, name, userID,
|
|
)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to update user: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
session := &Session{
|
|
UserID: userID,
|
|
Email: email,
|
|
Name: name,
|
|
IsAdmin: isAdmin,
|
|
ExpiresAt: time.Now().Add(SessionDuration),
|
|
}
|
|
|
|
return session, nil
|
|
}
|
|
|
|
// CreateSession creates a new session and returns a session ID
|
|
// Sessions are persisted to database and cached in memory
|
|
func (a *Auth) CreateSession(session *Session) string {
|
|
sessionID := uuid.New().String()
|
|
|
|
// Store in database first
|
|
err := a.db.With(func(conn *sql.DB) error {
|
|
_, err := conn.Exec(
|
|
`INSERT INTO sessions (session_id, user_id, email, name, is_admin, expires_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
sessionID, session.UserID, session.Email, session.Name, session.IsAdmin, session.ExpiresAt,
|
|
)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
log.Printf("Warning: Failed to persist session to database: %v", err)
|
|
// Continue anyway - session will work from cache but won't survive restart
|
|
}
|
|
|
|
// Store in cache
|
|
a.cacheMu.Lock()
|
|
a.sessionCache[sessionID] = session
|
|
a.cacheMu.Unlock()
|
|
|
|
return sessionID
|
|
}
|
|
|
|
// GetSession retrieves a session by ID
|
|
// First checks cache, then database if not found
|
|
func (a *Auth) GetSession(sessionID string) (*Session, bool) {
|
|
// Check cache first
|
|
a.cacheMu.RLock()
|
|
session, ok := a.sessionCache[sessionID]
|
|
a.cacheMu.RUnlock()
|
|
|
|
if ok {
|
|
if time.Now().After(session.ExpiresAt) {
|
|
a.DeleteSession(sessionID)
|
|
return nil, false
|
|
}
|
|
// Refresh admin status from database
|
|
var isAdmin bool
|
|
err := a.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT is_admin FROM users WHERE id = ?", session.UserID).Scan(&isAdmin)
|
|
})
|
|
if err == nil {
|
|
session.IsAdmin = isAdmin
|
|
}
|
|
return session, true
|
|
}
|
|
|
|
// Not in cache, check database
|
|
session = &Session{}
|
|
err := a.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow(
|
|
`SELECT user_id, email, name, is_admin, expires_at
|
|
FROM sessions WHERE session_id = ?`,
|
|
sessionID,
|
|
).Scan(&session.UserID, &session.Email, &session.Name, &session.IsAdmin, &session.ExpiresAt)
|
|
})
|
|
|
|
if err == sql.ErrNoRows {
|
|
return nil, false
|
|
}
|
|
if err != nil {
|
|
log.Printf("Warning: Failed to query session from database: %v", err)
|
|
return nil, false
|
|
}
|
|
|
|
if time.Now().After(session.ExpiresAt) {
|
|
a.DeleteSession(sessionID)
|
|
return nil, false
|
|
}
|
|
|
|
// Refresh admin status from database
|
|
var isAdmin bool
|
|
err = a.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT is_admin FROM users WHERE id = ?", session.UserID).Scan(&isAdmin)
|
|
})
|
|
if err == nil {
|
|
session.IsAdmin = isAdmin
|
|
}
|
|
|
|
// Add to cache
|
|
a.cacheMu.Lock()
|
|
a.sessionCache[sessionID] = session
|
|
a.cacheMu.Unlock()
|
|
|
|
return session, true
|
|
}
|
|
|
|
// DeleteSession deletes a session from both cache and database
|
|
func (a *Auth) DeleteSession(sessionID string) {
|
|
// Delete from cache
|
|
a.cacheMu.Lock()
|
|
delete(a.sessionCache, sessionID)
|
|
a.cacheMu.Unlock()
|
|
|
|
// Delete from database
|
|
err := a.db.With(func(conn *sql.DB) error {
|
|
_, err := conn.Exec("DELETE FROM sessions WHERE session_id = ?", sessionID)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
log.Printf("Warning: Failed to delete session from database: %v", err)
|
|
}
|
|
}
|
|
|
|
// IsProductionMode returns true if running in production mode.
|
|
// Prefer Config.IsProductionMode() / Auth.IsProductionModeFromConfig() for app logic.
|
|
// This package-level helper still honors PRODUCTION=true for legacy callers.
|
|
func IsProductionMode() bool {
|
|
return os.Getenv("PRODUCTION") == "true"
|
|
}
|
|
|
|
// IsProductionModeFromConfig returns true if production mode is enabled in config
|
|
// or via PRODUCTION=true environment variable (OR of both sources).
|
|
func (a *Auth) IsProductionModeFromConfig() bool {
|
|
if a.cfg != nil && a.cfg.IsProductionMode() {
|
|
return true
|
|
}
|
|
return IsProductionMode()
|
|
}
|
|
|
|
func (a *Auth) writeUnauthorized(w http.ResponseWriter, r *http.Request) {
|
|
// Keep API behavior unchanged for programmatic clients.
|
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Unauthorized"})
|
|
return
|
|
}
|
|
|
|
// For HTMX UI fragment requests, trigger a full-page redirect to login.
|
|
if strings.EqualFold(r.Header.Get("HX-Request"), "true") {
|
|
w.Header().Set("HX-Redirect", "/login")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Unauthorized"})
|
|
return
|
|
}
|
|
|
|
// For normal browser page requests, redirect to login page.
|
|
http.Redirect(w, r, "/login", http.StatusFound)
|
|
}
|
|
|
|
// Middleware creates an authentication middleware
|
|
func (a *Auth) Middleware(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
cookie, err := r.Cookie("session_id")
|
|
if err != nil {
|
|
log.Printf("Authentication failed: missing session cookie for %s %s", r.Method, r.URL.Path)
|
|
a.writeUnauthorized(w, r)
|
|
return
|
|
}
|
|
|
|
session, ok := a.GetSession(cookie.Value)
|
|
if !ok {
|
|
log.Printf("Authentication failed: invalid session cookie for %s %s", r.Method, r.URL.Path)
|
|
a.writeUnauthorized(w, r)
|
|
return
|
|
}
|
|
|
|
// Add user info to request context using typed keys
|
|
ctx := context.WithValue(r.Context(), contextKeyUserID, session.UserID)
|
|
ctx = context.WithValue(ctx, contextKeyUserEmail, session.Email)
|
|
ctx = context.WithValue(ctx, contextKeyUserName, session.Name)
|
|
ctx = context.WithValue(ctx, contextKeyIsAdmin, session.IsAdmin)
|
|
next(w, r.WithContext(ctx))
|
|
}
|
|
}
|
|
|
|
// GetUserID gets the user ID from context
|
|
func GetUserID(ctx context.Context) (int64, bool) {
|
|
userID, ok := ctx.Value(contextKeyUserID).(int64)
|
|
return userID, ok
|
|
}
|
|
|
|
// IsAdmin checks if the user in context is an admin
|
|
func IsAdmin(ctx context.Context) bool {
|
|
isAdmin, ok := ctx.Value(contextKeyIsAdmin).(bool)
|
|
return ok && isAdmin
|
|
}
|
|
|
|
// AdminMiddleware creates an admin-only middleware
|
|
func (a *Auth) AdminMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// First check authentication
|
|
cookie, err := r.Cookie("session_id")
|
|
if err != nil {
|
|
log.Printf("Admin authentication failed: missing session cookie for %s %s", r.Method, r.URL.Path)
|
|
a.writeUnauthorized(w, r)
|
|
return
|
|
}
|
|
|
|
session, ok := a.GetSession(cookie.Value)
|
|
if !ok {
|
|
log.Printf("Admin authentication failed: invalid session cookie for %s %s", r.Method, r.URL.Path)
|
|
a.writeUnauthorized(w, r)
|
|
return
|
|
}
|
|
|
|
// Then check admin status
|
|
if !session.IsAdmin {
|
|
log.Printf("Admin access denied: user %d (email: %s) attempted to access admin endpoint %s %s", session.UserID, session.Email, r.Method, r.URL.Path)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusForbidden)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": "Forbidden: Admin access required"})
|
|
return
|
|
}
|
|
|
|
// Add user info to request context using typed keys
|
|
ctx := context.WithValue(r.Context(), contextKeyUserID, session.UserID)
|
|
ctx = context.WithValue(ctx, contextKeyUserEmail, session.Email)
|
|
ctx = context.WithValue(ctx, contextKeyUserName, session.Name)
|
|
ctx = context.WithValue(ctx, contextKeyIsAdmin, session.IsAdmin)
|
|
next(w, r.WithContext(ctx))
|
|
}
|
|
}
|
|
|
|
// IsLocalLoginEnabled returns whether local login is enabled
|
|
// Checks database config first, falls back to environment variable
|
|
func (a *Auth) IsLocalLoginEnabled() bool {
|
|
return a.cfg.IsLocalAuthEnabled()
|
|
}
|
|
|
|
// IsGoogleOAuthConfigured returns whether Google OAuth is configured
|
|
func (a *Auth) IsGoogleOAuthConfigured() bool {
|
|
return a.googleConfig != nil
|
|
}
|
|
|
|
// IsDiscordOAuthConfigured returns whether Discord OAuth is configured
|
|
func (a *Auth) IsDiscordOAuthConfigured() bool {
|
|
return a.discordConfig != nil
|
|
}
|
|
|
|
// LocalLogin handles local username/password authentication
|
|
func (a *Auth) LocalLogin(username, password string) (*Session, error) {
|
|
// Find user by email (local users use email as username)
|
|
email := username
|
|
var userID int64
|
|
var dbEmail, dbName, passwordHash string
|
|
var isAdmin bool
|
|
|
|
err := a.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow(
|
|
"SELECT id, email, name, password_hash, is_admin FROM users WHERE email = ? AND oauth_provider = 'local'",
|
|
email,
|
|
).Scan(&userID, &dbEmail, &dbName, &passwordHash, &isAdmin)
|
|
})
|
|
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("invalid credentials")
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query user: %w", err)
|
|
}
|
|
|
|
// Verify password
|
|
if passwordHash == "" {
|
|
return nil, fmt.Errorf("invalid credentials")
|
|
}
|
|
|
|
err = bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid credentials")
|
|
}
|
|
|
|
// Create session
|
|
session := &Session{
|
|
UserID: userID,
|
|
Email: dbEmail,
|
|
Name: dbName,
|
|
IsAdmin: isAdmin,
|
|
ExpiresAt: time.Now().Add(SessionDuration),
|
|
}
|
|
|
|
return session, nil
|
|
}
|
|
|
|
// RegisterLocalUser creates a new local user account
|
|
func (a *Auth) RegisterLocalUser(email, name, password string) (*Session, error) {
|
|
// Check if registration is enabled
|
|
registrationEnabled, err := a.IsRegistrationEnabled()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check registration setting: %w", err)
|
|
}
|
|
if !registrationEnabled {
|
|
return nil, fmt.Errorf("registration is disabled")
|
|
}
|
|
|
|
// Check if user already exists
|
|
var exists bool
|
|
err = a.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT EXISTS(SELECT 1 FROM users WHERE email = ?)", email).Scan(&exists)
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check if user exists: %w", err)
|
|
}
|
|
if exists {
|
|
return nil, fmt.Errorf("user with this email already exists")
|
|
}
|
|
|
|
// Hash password
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to hash password: %w", err)
|
|
}
|
|
|
|
// Check if this is the first user (make them admin)
|
|
var userCount int
|
|
err = a.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT COUNT(*) FROM users").Scan(&userCount)
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check user count: %w", err)
|
|
}
|
|
isAdmin := userCount == 0
|
|
|
|
// Create user
|
|
var userID int64
|
|
err = a.db.With(func(conn *sql.DB) error {
|
|
result, err := conn.Exec(
|
|
"INSERT INTO users (email, name, oauth_provider, oauth_id, password_hash, is_admin) VALUES (?, ?, 'local', ?, ?, ?)",
|
|
email, name, email, string(hashedPassword), isAdmin,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
userID, err = result.LastInsertId()
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create user: %w", err)
|
|
}
|
|
|
|
// Create session
|
|
session := &Session{
|
|
UserID: userID,
|
|
Email: email,
|
|
Name: name,
|
|
IsAdmin: isAdmin,
|
|
ExpiresAt: time.Now().Add(SessionDuration),
|
|
}
|
|
|
|
return session, nil
|
|
}
|
|
|
|
// ChangePassword allows a user to change their own password
|
|
func (a *Auth) ChangePassword(userID int64, oldPassword, newPassword string) error {
|
|
// Get current password hash
|
|
var passwordHash string
|
|
err := a.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT password_hash FROM users WHERE id = ? AND oauth_provider = 'local'", userID).Scan(&passwordHash)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
return fmt.Errorf("user not found or not a local user")
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("failed to query user: %w", err)
|
|
}
|
|
|
|
// Verify old password
|
|
if passwordHash == "" {
|
|
return fmt.Errorf("user has no password set")
|
|
}
|
|
|
|
err = bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(oldPassword))
|
|
if err != nil {
|
|
return fmt.Errorf("incorrect old password")
|
|
}
|
|
|
|
// Hash new password
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to hash password: %w", err)
|
|
}
|
|
|
|
// Update password
|
|
err = a.db.With(func(conn *sql.DB) error {
|
|
_, err := conn.Exec("UPDATE users SET password_hash = ? WHERE id = ?", string(hashedPassword), userID)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update password: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// AdminChangePassword allows an admin to change any user's password without knowing the old password
|
|
func (a *Auth) AdminChangePassword(targetUserID int64, newPassword string) error {
|
|
// Verify user exists and is a local user
|
|
var exists bool
|
|
err := a.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT EXISTS(SELECT 1 FROM users WHERE id = ? AND oauth_provider = 'local')", targetUserID).Scan(&exists)
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check if user exists: %w", err)
|
|
}
|
|
if !exists {
|
|
return fmt.Errorf("user not found or not a local user")
|
|
}
|
|
|
|
// Hash new password
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to hash password: %w", err)
|
|
}
|
|
|
|
// Update password
|
|
err = a.db.With(func(conn *sql.DB) error {
|
|
_, err := conn.Exec("UPDATE users SET password_hash = ? WHERE id = ?", string(hashedPassword), targetUserID)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update password: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetFirstUserID returns the ID of the first user (user with the lowest ID)
|
|
func (a *Auth) GetFirstUserID() (int64, error) {
|
|
var firstUserID int64
|
|
err := a.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT id FROM users ORDER BY id ASC LIMIT 1").Scan(&firstUserID)
|
|
})
|
|
if err == sql.ErrNoRows {
|
|
return 0, fmt.Errorf("no users found")
|
|
}
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to get first user ID: %w", err)
|
|
}
|
|
return firstUserID, nil
|
|
}
|
|
|
|
// SetUserAdminStatus allows an admin to change a user's admin status
|
|
func (a *Auth) SetUserAdminStatus(targetUserID int64, isAdmin bool) error {
|
|
// Verify user exists
|
|
var exists bool
|
|
err := a.db.With(func(conn *sql.DB) error {
|
|
return conn.QueryRow("SELECT EXISTS(SELECT 1 FROM users WHERE id = ?)", targetUserID).Scan(&exists)
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check if user exists: %w", err)
|
|
}
|
|
if !exists {
|
|
return fmt.Errorf("user not found")
|
|
}
|
|
|
|
// Prevent removing admin status from the first user
|
|
firstUserID, err := a.GetFirstUserID()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check first user: %w", err)
|
|
}
|
|
if targetUserID == firstUserID && !isAdmin {
|
|
return fmt.Errorf("cannot remove admin status from the first user")
|
|
}
|
|
|
|
// Update admin status
|
|
err = a.db.With(func(conn *sql.DB) error {
|
|
_, err := conn.Exec("UPDATE users SET is_admin = ? WHERE id = ?", isAdmin, targetUserID)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update admin status: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|