Changeset 0.24.0 (#156)

This commit is contained in:
2026-03-07 11:27:24 +00:00
parent 2dc4514a57
commit a63728a481
23 changed files with 1850 additions and 385 deletions

View File

@@ -14,6 +14,7 @@ import (
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"git.gobha.me/xcaliber/chat-switchboard/auth"
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
@@ -37,77 +38,40 @@ type AuthHandler struct {
cfg *config.Config
stores store.Stores
uekCache *crypto.UEKCache
provider auth.Provider
}
func NewAuthHandler(cfg *config.Config, s store.Stores, uekCache *crypto.UEKCache) *AuthHandler {
return &AuthHandler{cfg: cfg, stores: s, uekCache: uekCache}
func NewAuthHandler(cfg *config.Config, s store.Stores, uekCache *crypto.UEKCache, provider auth.Provider) *AuthHandler {
return &AuthHandler{cfg: cfg, stores: s, uekCache: uekCache, provider: provider}
}
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()})
if !h.provider.SupportsRegistration() {
c.JSON(http.StatusMethodNotAllowed, gin.H{"error": "registration not supported for this auth mode"})
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)
result, err := h.provider.Register(c, h.stores)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
c.JSON(auth.ErrorToHTTPStatus(err), gin.H{"error": err.Error()})
return
}
// Check if user should be active by default
defaultActive, _ := h.stores.Policies.GetBool(c.Request.Context(), "default_user_active")
user := &models.User{
Username: strings.ToLower(req.Username),
Email: strings.ToLower(req.Email),
PasswordHash: string(hash),
Role: models.UserRoleUser,
IsActive: defaultActive,
if result.VaultHint != "" {
if err := h.initVault(c.Request.Context(), result.User.ID, result.VaultHint); err != nil {
log.Printf("⚠ Failed to init vault for user %s: %v", result.User.ID, err)
}
}
if err := h.stores.Users.Create(c.Request.Context(), user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
return
}
// Generate and store the User Encryption Key (vault)
if err := h.initVault(c.Request.Context(), user.ID, req.Password); err != nil {
log.Printf("⚠ Failed to init vault for user %s: %v", user.ID, err)
// Non-fatal: user can still use the app, vault will init on next login
}
if !user.IsActive {
if !result.User.IsActive {
c.JSON(http.StatusCreated, gin.H{
"message": "Account created but requires admin approval",
"user_id": user.ID,
"user_id": result.User.ID,
})
return
}
tokens, err := h.generateTokens(user)
tokens, err := h.generateTokens(result.User)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
@@ -117,37 +81,19 @@ func (h *AuthHandler) Register(c *gin.Context) {
}
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)
result, err := h.provider.Authenticate(c, h.stores)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
c.JSON(auth.ErrorToHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if !user.IsActive {
c.JSON(http.StatusForbidden, gin.H{"error": "account is inactive"})
return
if result.VaultHint != "" {
h.unlockVault(c.Request.Context(), result.User, result.VaultHint)
}
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(), result.User.ID)
// Vault: unwrap UEK into session cache (or init if first login post-migration)
h.unlockVault(c.Request.Context(), user, req.Password)
h.stores.Users.UpdateLastLogin(c.Request.Context(), user.ID)
tokens, err := h.generateTokens(user)
tokens, err := h.generateTokens(result.User)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
@@ -247,6 +193,8 @@ func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
"email": user.Email,
"display_name": user.DisplayName,
"role": user.Role,
"handle": user.Handle,
"auth_source": user.AuthSource,
},
}, nil
}
@@ -433,6 +381,11 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores) {
})
// If the actual password changed, the vault seal is stale. Probe it
// with the current password and destroy only if unwrap fails.
// Ensure handle exists (backfill for pre-v0.24.0 users)
if existing.Handle == "" {
handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(cfg.AdminUsername))
s.Users.Update(ctx, existing.ID, map[string]interface{}{"handle": handle})
}
ProbeAndRepairVault(ctx, existing.ID, cfg.AdminPassword)
log.Printf(" ✅ Admin user '%s' updated", cfg.AdminUsername)
return
@@ -443,12 +396,15 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores) {
email = cfg.AdminUsername + "@switchboard.local"
}
handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(cfg.AdminUsername))
user := &models.User{
Username: strings.ToLower(cfg.AdminUsername),
Email: strings.ToLower(email),
PasswordHash: string(hash),
Role: models.UserRoleAdmin,
IsActive: true,
AuthSource: "builtin",
Handle: handle,
}
if err := s.Users.Create(ctx, user); err != nil {
@@ -516,17 +472,24 @@ func SeedUsers(cfg *config.Config, s store.Stores) {
"is_active": true,
})
// Probe vault with current password — only destroys if seal is stale
if existing.Handle == "" {
handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(username))
s.Users.Update(ctx, existing.ID, map[string]interface{}{"handle": handle})
}
ProbeAndRepairVault(ctx, existing.ID, password)
log.Printf(" 🌱 Seed user '%s' updated (role=%s)", username, role)
continue
}
handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(username))
user := &models.User{
Username: username,
Email: username + "@switchboard.local",
PasswordHash: string(hash),
Role: role,
IsActive: true,
AuthSource: "builtin",
Handle: handle,
}
if err := s.Users.Create(ctx, user); err != nil {