Changeset 0.9.0 (#50)
This commit is contained in:
@@ -1,32 +1,28 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
const (
|
||||
accessTokenDuration = 15 * time.Minute
|
||||
refreshTokenDuration = 7 * 24 * time.Hour
|
||||
bcryptCost = 12
|
||||
)
|
||||
// Claims represents the JWT payload.
|
||||
//
|
||||
// bcryptCost is shared across auth.go, settings.go, admin.go
|
||||
const bcryptCost = 12
|
||||
|
||||
// Claims is the JWT access token payload.
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
@@ -34,424 +30,261 @@ type Claims struct {
|
||||
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
|
||||
cfg *config.Config
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new auth handler.
|
||||
func NewAuthHandler(cfg *config.Config) *AuthHandler {
|
||||
return &AuthHandler{cfg: cfg}
|
||||
func NewAuthHandler(cfg *config.Config, s store.Stores) *AuthHandler {
|
||||
return &AuthHandler{cfg: cfg, stores: s}
|
||||
}
|
||||
|
||||
// ── Register ────────────────────────────────
|
||||
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
var req registerRequest
|
||||
var req struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Email string `json:"email" binding:"required"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
}
|
||||
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
|
||||
}
|
||||
// Check registration policy
|
||||
allowed, _ := h.stores.Policies.GetBool(c.Request.Context(), "allow_registration")
|
||||
if !allowed {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "registration is disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check duplicate
|
||||
if existing, _ := h.stores.Users.GetByUsername(c.Request.Context(), req.Username); existing != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "username already taken"})
|
||||
return
|
||||
}
|
||||
if existing, _ := h.stores.Users.GetByEmail(c.Request.Context(), req.Email); existing != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "email already registered"})
|
||||
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
|
||||
}
|
||||
// Check if user should be active by default
|
||||
defaultActive, _ := h.stores.Policies.GetBool(c.Request.Context(), "default_user_active")
|
||||
|
||||
user := &models.User{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
PasswordHash: string(hash),
|
||||
Role: models.UserRoleUser,
|
||||
IsActive: defaultActive,
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
if err := h.stores.Users.Create(c.Request.Context(), user); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
|
||||
return
|
||||
}
|
||||
|
||||
// If account is pending, don't generate tokens
|
||||
if !isActive {
|
||||
if !user.IsActive {
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"message": "Account created and pending admin approval",
|
||||
"pending": true,
|
||||
})
|
||||
AuditLogWithActor(user.ID, c, "user.register", "user", user.ID, map[string]interface{}{
|
||||
"username": req.Username, "pending": true,
|
||||
"message": "Account created but requires admin approval",
|
||||
"user_id": user.ID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
resp, err := h.generateTokenPair(user)
|
||||
tokens, err := h.generateTokens(user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
AuditLogWithActor(user.ID, c, "user.register", "user", user.ID, map[string]interface{}{
|
||||
"username": req.Username, "pending": false,
|
||||
})
|
||||
c.JSON(http.StatusCreated, tokens)
|
||||
}
|
||||
|
||||
// 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
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req struct {
|
||||
Login string `json:"login" binding:"required"` // username or email
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
var enabled bool
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT COALESCE((value->>'value')::boolean, true)
|
||||
FROM global_settings WHERE key = 'registration_enabled'
|
||||
`).Scan(&enabled)
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.stores.Users.GetByLogin(c.Request.Context(), req.Login)
|
||||
if err != nil {
|
||||
return true // Default to open if setting missing
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
return enabled
|
||||
|
||||
if !user.IsActive {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "account is inactive"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
h.stores.Users.UpdateLastLogin(c.Request.Context(), user.ID)
|
||||
|
||||
tokens, err := h.generateTokens(user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tokens)
|
||||
}
|
||||
|
||||
// GetRegistrationDefaultState returns "active" or "pending".
|
||||
func GetRegistrationDefaultState() string {
|
||||
if database.DB == nil {
|
||||
return "active"
|
||||
func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||
var req struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
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"
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
return state
|
||||
|
||||
tokenHash := hashToken(req.RefreshToken)
|
||||
userID, err := h.stores.Users.GetRefreshToken(c.Request.Context(), tokenHash)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Revoke the used token (rotate)
|
||||
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
|
||||
|
||||
user, err := h.stores.Users.GetByID(c.Request.Context(), userID)
|
||||
if err != nil || !user.IsActive {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found or inactive"})
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := h.generateTokens(user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tokens)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
var req struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
c.ShouldBindJSON(&req)
|
||||
|
||||
if req.RefreshToken != "" {
|
||||
tokenHash := hashToken(req.RefreshToken)
|
||||
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
|
||||
// Access token (15 min)
|
||||
accessClaims := Claims{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Role: user.Role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ID: uuid.New().String(),
|
||||
},
|
||||
}
|
||||
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
|
||||
accessString, err := accessToken.SignedString([]byte(h.cfg.JWTSecret))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Refresh token (7 days)
|
||||
refreshRaw := uuid.New().String()
|
||||
refreshHash := hashToken(refreshRaw)
|
||||
expiresAt := time.Now().Add(7 * 24 * time.Hour)
|
||||
|
||||
if err := h.stores.Users.CreateRefreshToken(context.Background(), user.ID, refreshHash, expiresAt); err != nil {
|
||||
log.Printf("warn: failed to store refresh token: %v", err)
|
||||
}
|
||||
|
||||
return gin.H{
|
||||
"access_token": accessString,
|
||||
"refresh_token": refreshRaw,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 900,
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"display_name": user.DisplayName,
|
||||
"role": user.Role,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// BootstrapAdmin creates/updates the admin user from env vars (K8s secret).
|
||||
func BootstrapAdmin(cfg *config.Config, s store.Stores) {
|
||||
if cfg.AdminUsername == "" || cfg.AdminPassword == "" {
|
||||
return
|
||||
}
|
||||
if database.DB == nil {
|
||||
return
|
||||
}
|
||||
|
||||
email := cfg.AdminEmail
|
||||
if email == "" {
|
||||
email = cfg.AdminUsername + "@localhost"
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
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))
|
||||
existing, _ := s.Users.GetByUsername(ctx, cfg.AdminUsername)
|
||||
if existing != nil {
|
||||
// Update password and ensure admin role
|
||||
s.Users.Update(ctx, existing.ID, map[string]interface{}{
|
||||
"password_hash": string(hash),
|
||||
"role": models.UserRoleAdmin,
|
||||
"is_active": true,
|
||||
})
|
||||
log.Printf(" ✅ Admin user '%s' updated", cfg.AdminUsername)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("⚠ Admin bootstrap failed: %v", err)
|
||||
} else {
|
||||
log.Printf("✅ Admin user '%s' bootstrapped from environment", cfg.AdminUsername)
|
||||
email := cfg.AdminEmail
|
||||
if email == "" {
|
||||
email = cfg.AdminUsername + "@switchboard.local"
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
Username: cfg.AdminUsername,
|
||||
Email: email,
|
||||
PasswordHash: string(hash),
|
||||
Role: models.UserRoleAdmin,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := s.Users.Create(ctx, user); err != nil {
|
||||
log.Printf("⚠ Failed to create admin user: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf(" ✅ Admin user '%s' created", 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)
|
||||
AuditLogWithActor(user.ID, c, "user.login", "user", user.ID, nil)
|
||||
}
|
||||
|
||||
// ── 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[:])
|
||||
// IsRegistrationEnabled checks the platform policy.
|
||||
func IsRegistrationEnabled(s store.Stores) bool {
|
||||
val, _ := s.Policies.GetBool(context.Background(), "allow_registration")
|
||||
return val
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user