This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/auth.go
2026-02-21 21:59:38 +00:00

451 lines
13 KiB
Go

package handlers
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
const (
accessTokenDuration = 15 * time.Minute
refreshTokenDuration = 7 * 24 * time.Hour
bcryptCost = 12
)
// Claims is the JWT access token payload.
type Claims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// ── Request / Response types ────────────────
type registerRequest struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8,max=128"`
}
type loginRequest struct {
Login string `json:"login" binding:"required"` // email or username
Password string `json:"password" binding:"required"`
}
type refreshRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
type authResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"` // seconds
User userResponse `json:"user"`
}
type userResponse struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
DisplayName *string `json:"display_name"`
Role string `json:"role"`
Avatar *string `json:"avatar,omitempty"`
}
// AuthHandler holds dependencies for auth endpoints.
type AuthHandler struct {
cfg *config.Config
}
// NewAuthHandler creates a new auth handler.
func NewAuthHandler(cfg *config.Config) *AuthHandler {
return &AuthHandler{cfg: cfg}
}
// ── Register ────────────────────────────────
func (h *AuthHandler) Register(c *gin.Context) {
var req registerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
req.Username = strings.TrimSpace(req.Username)
// Check if this is the first user (will become admin)
var userCount int
_ = database.DB.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount)
isFirstUser := userCount == 0
// First-user-becomes-admin only when no env admin is configured
envAdminSet := os.Getenv("SWITCHBOARD_ADMIN_USERNAME") != ""
promoteFirst := isFirstUser && !envAdminSet
// If not first user (or env admin handles bootstrap), check registration
if !promoteFirst {
if !IsRegistrationEnabled() {
c.JSON(http.StatusForbidden, gin.H{"error": "registration is disabled"})
return
}
}
// Hash password
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
return
}
// Determine role and active state
role := "user"
isActive := true
if promoteFirst {
role = "admin"
} else {
// Apply registration default state
if GetRegistrationDefaultState() == "pending" {
isActive = false
}
}
// Insert user
var user userResponse
err = database.DB.QueryRow(`
INSERT INTO users (username, email, password_hash, role, is_active)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, email, display_name, role, avatar_url
`, req.Username, req.Email, string(hash), role, isActive).Scan(
&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role, &user.Avatar,
)
if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
field := "email"
if strings.Contains(err.Error(), "username") {
field = "username"
}
c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("%s already taken", field)})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
return
}
// If account is pending, don't generate tokens
if !isActive {
c.JSON(http.StatusCreated, gin.H{
"message": "Account created and pending admin approval",
"pending": true,
})
return
}
// Generate tokens
resp, err := h.generateTokenPair(user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
}
c.JSON(http.StatusCreated, resp)
}
// IsRegistrationEnabled checks the global_settings table.
// Returns true if the table doesn't exist (pre-migration), DB is nil, or setting is enabled.
func IsRegistrationEnabled() bool {
if database.DB == nil {
return true
}
var enabled bool
err := database.DB.QueryRow(`
SELECT COALESCE((value->>'value')::boolean, true)
FROM global_settings WHERE key = 'registration_enabled'
`).Scan(&enabled)
if err != nil {
return true // Default to open if setting missing
}
return enabled
}
// GetRegistrationDefaultState returns "active" or "pending".
func GetRegistrationDefaultState() string {
if database.DB == nil {
return "active"
}
var state string
err := database.DB.QueryRow(`
SELECT COALESCE(value->>'value', 'active')
FROM global_settings WHERE key = 'registration_default_state'
`).Scan(&state)
if err != nil || state == "" {
return "active"
}
return state
}
// BootstrapAdmin creates or updates the admin user from environment variables.
// This runs on every startup, so changing the K8s secret + restarting resets the password.
// Handles both username and email conflicts (e.g. admin username changed between deploys).
func BootstrapAdmin(cfg *config.Config) {
if cfg.AdminUsername == "" || cfg.AdminPassword == "" {
return
}
if database.DB == nil {
return
}
email := cfg.AdminEmail
if email == "" {
email = cfg.AdminUsername + "@localhost"
}
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.AdminPassword), bcryptCost)
if err != nil {
log.Printf("⚠ Failed to hash admin password: %v", err)
return
}
// Try upsert by username (common case: same username, new password)
_, err = database.DB.Exec(`
INSERT INTO users (username, email, password_hash, role, is_active)
VALUES ($1, $2, $3, 'admin', true)
ON CONFLICT (username) DO UPDATE SET
password_hash = EXCLUDED.password_hash,
email = EXCLUDED.email,
role = 'admin',
is_active = true
`, cfg.AdminUsername, email, string(hash))
if err != nil && strings.Contains(err.Error(), "duplicate key") {
// Email conflict — admin username was changed in config but email
// already belongs to old admin row. Update that row instead.
_, err = database.DB.Exec(`
UPDATE users SET
username = $1,
password_hash = $3,
role = 'admin',
is_active = true
WHERE email = $2
`, cfg.AdminUsername, email, string(hash))
}
if err != nil {
log.Printf("⚠ Admin bootstrap failed: %v", err)
} else {
log.Printf("✅ Admin user '%s' bootstrapped from environment", cfg.AdminUsername)
}
}
// ── Login ───────────────────────────────────
func (h *AuthHandler) Login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Login = strings.TrimSpace(req.Login)
// Look up user by email or username
var user userResponse
var passwordHash string
var isActive bool
err := database.DB.QueryRow(`
SELECT id, username, email, display_name, role, avatar_url, password_hash, is_active
FROM users
WHERE email = $1 OR username = $1
`, req.Login).Scan(
&user.ID, &user.Username, &user.Email, &user.DisplayName,
&user.Role, &user.Avatar, &passwordHash, &isActive,
)
if err == sql.ErrNoRows {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "authentication failed"})
return
}
if !isActive {
c.JSON(http.StatusForbidden, gin.H{"error": "account is pending admin approval"})
return
}
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
// Update last_login_at
_, _ = database.DB.Exec(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, user.ID)
// Generate tokens
resp, err := h.generateTokenPair(user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
}
c.JSON(http.StatusOK, resp)
}
// ── Refresh ─────────────────────────────────
func (h *AuthHandler) Refresh(c *gin.Context) {
var req refreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tokenHash := hashToken(req.RefreshToken)
// Find and validate the refresh token
var tokenID, userID string
var expiresAt time.Time
err := database.DB.QueryRow(`
SELECT rt.id, rt.user_id, rt.expires_at
FROM refresh_tokens rt
WHERE rt.token_hash = $1 AND rt.revoked_at IS NULL
`, tokenHash).Scan(&tokenID, &userID, &expiresAt)
if err == sql.ErrNoRows {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token validation failed"})
return
}
if time.Now().After(expiresAt) {
// Revoke expired token
_, _ = database.DB.Exec(`UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = $1`, tokenID)
c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token expired"})
return
}
// Revoke the old token (rotation)
_, _ = database.DB.Exec(`UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = $1`, tokenID)
// Look up user
var user userResponse
var isActive bool
err = database.DB.QueryRow(`
SELECT id, username, email, display_name, role, avatar_url, is_active
FROM users WHERE id = $1
`, userID).Scan(
&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role, &user.Avatar, &isActive,
)
if err != nil || !isActive {
c.JSON(http.StatusUnauthorized, gin.H{"error": "account unavailable"})
return
}
// Issue new pair
resp, err := h.generateTokenPair(user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
}
c.JSON(http.StatusOK, resp)
}
// ── Logout ──────────────────────────────────
func (h *AuthHandler) Logout(c *gin.Context) {
var req refreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
// No refresh token provided — just acknowledge
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
return
}
tokenHash := hashToken(req.RefreshToken)
// Revoke the refresh token
_, _ = database.DB.Exec(`
UPDATE refresh_tokens SET revoked_at = NOW()
WHERE token_hash = $1 AND revoked_at IS NULL
`, tokenHash)
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
}
// ── Token Generation ────────────────────────
func (h *AuthHandler) generateTokenPair(user userResponse) (*authResponse, error) {
now := time.Now()
// Access token (JWT)
claims := Claims{
UserID: user.ID,
Email: user.Email,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(accessTokenDuration)),
Issuer: "chat-switchboard",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
accessToken, err := token.SignedString([]byte(h.cfg.JWTSecret))
if err != nil {
return nil, fmt.Errorf("sign access token: %w", err)
}
// Refresh token (opaque random string, stored hashed)
refreshBytes := make([]byte, 32)
if _, err := rand.Read(refreshBytes); err != nil {
return nil, fmt.Errorf("generate refresh token: %w", err)
}
refreshToken := hex.EncodeToString(refreshBytes)
refreshHash := hashToken(refreshToken)
// Store refresh token
_, err = database.DB.Exec(`
INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)
`, user.ID, refreshHash, now.Add(refreshTokenDuration))
if err != nil {
return nil, fmt.Errorf("store refresh token: %w", err)
}
return &authResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int(accessTokenDuration.Seconds()),
User: user,
}, nil
}
// hashToken returns a SHA-256 hex digest of a token string.
// Refresh tokens are stored hashed so a DB leak doesn't
// compromise active sessions.
func hashToken(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}