291 lines
7.7 KiB
Go
291 lines
7.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"log"
|
|
"net/http"
|
|
"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/models"
|
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
|
)
|
|
|
|
// Claims represents the JWT payload.
|
|
//
|
|
// bcryptCost is shared across auth.go, settings.go, admin.go
|
|
const bcryptCost = 12
|
|
|
|
type Claims struct {
|
|
UserID string `json:"user_id"`
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
type AuthHandler struct {
|
|
cfg *config.Config
|
|
stores store.Stores
|
|
}
|
|
|
|
func NewAuthHandler(cfg *config.Config, s store.Stores) *AuthHandler {
|
|
return &AuthHandler{cfg: cfg, stores: s}
|
|
}
|
|
|
|
func (h *AuthHandler) Register(c *gin.Context) {
|
|
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
|
|
}
|
|
|
|
// 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, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
|
return
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
|
|
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 !user.IsActive {
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"message": "Account created but requires admin approval",
|
|
"user_id": user.ID,
|
|
})
|
|
return
|
|
}
|
|
|
|
tokens, err := h.generateTokens(user)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, tokens)
|
|
}
|
|
|
|
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"`
|
|
}
|
|
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 {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
|
return
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func (h *AuthHandler) Refresh(c *gin.Context) {
|
|
var req struct {
|
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
ctx := context.Background()
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.AdminPassword), bcryptCost)
|
|
if err != nil {
|
|
log.Printf("⚠ Failed to hash admin password: %v", err)
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// IsRegistrationEnabled checks the platform policy.
|
|
func IsRegistrationEnabled(s store.Stores) bool {
|
|
val, _ := s.Policies.GetBool(context.Background(), "allow_registration")
|
|
return val
|
|
}
|