Changeset 0.24.0 (#156)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"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/store"
|
||||
)
|
||||
@@ -21,12 +22,13 @@ import (
|
||||
func testConfig() *config.Config {
|
||||
return &config.Config{
|
||||
JWTSecret: "test-secret-key-for-unit-tests",
|
||||
AuthMode: "builtin",
|
||||
}
|
||||
}
|
||||
|
||||
// testAuthHandler creates an AuthHandler with nil stores (safe for non-DB tests).
|
||||
func testAuthHandler() *AuthHandler {
|
||||
return NewAuthHandler(testConfig(), store.Stores{}, nil)
|
||||
return NewAuthHandler(testConfig(), store.Stores{}, nil, auth.NewBuiltinProvider())
|
||||
}
|
||||
|
||||
// ── JWT Token Tests ─────────────────────────
|
||||
|
||||
@@ -1340,12 +1340,12 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try username (exact match) — v0.23.1
|
||||
// 3. Try user handle (exact match) — v0.24.0
|
||||
// Only resolves to a user if the caller is not the same user (no self-mention)
|
||||
var mentionedUserID string
|
||||
err = database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT id FROM users
|
||||
WHERE LOWER(username) = $1 AND id != $2 AND is_active = true
|
||||
WHERE LOWER(handle) = $1 AND id != $2 AND is_active = true
|
||||
LIMIT 1
|
||||
`), normalized, userID).Scan(&mentionedUserID)
|
||||
if err == nil && mentionedUserID != "" {
|
||||
@@ -1353,16 +1353,16 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content
|
||||
return "", "", nil, mentionedUserID
|
||||
}
|
||||
|
||||
// 4. Try username (prefix — unambiguous only) — v0.23.1
|
||||
// 4. Try user handle (prefix — unambiguous only) — v0.24.0
|
||||
var userCount int
|
||||
database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT COUNT(*) FROM users
|
||||
WHERE LOWER(username) LIKE $1 AND id != $2 AND is_active = true
|
||||
WHERE LOWER(handle) LIKE $1 AND id != $2 AND is_active = true
|
||||
`), normalized+"%", userID).Scan(&userCount)
|
||||
if userCount == 1 {
|
||||
database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT id FROM users
|
||||
WHERE LOWER(username) LIKE $1 AND id != $2 AND is_active = true
|
||||
WHERE LOWER(handle) LIKE $1 AND id != $2 AND is_active = true
|
||||
LIMIT 1
|
||||
`), normalized+"%", userID).Scan(&mentionedUserID)
|
||||
if mentionedUserID != "" {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
authpkg "git.gobha.me/xcaliber/chat-switchboard/auth"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
@@ -135,7 +136,7 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
api := r.Group("/api/v1")
|
||||
|
||||
// Auth (unprotected)
|
||||
auth := NewAuthHandler(cfg, stores, nil)
|
||||
auth := NewAuthHandler(cfg, stores, nil, authpkg.NewBuiltinProvider())
|
||||
authGroup := api.Group("/auth")
|
||||
authGroup.POST("/register", auth.Register)
|
||||
authGroup.POST("/login", auth.Login)
|
||||
|
||||
@@ -76,16 +76,16 @@ func SearchUsers(c *gin.Context) {
|
||||
q := strings.TrimSpace(c.Query("q"))
|
||||
|
||||
query := database.Q(`
|
||||
SELECT id, username, COALESCE(display_name, '') AS display_name
|
||||
SELECT id, username, COALESCE(display_name, '') AS display_name, COALESCE(handle, '') AS handle
|
||||
FROM users
|
||||
WHERE is_active = true AND id != $1
|
||||
`)
|
||||
args := []interface{}{userID}
|
||||
|
||||
if q != "" {
|
||||
query += database.Q(` AND (LOWER(username) LIKE $2 OR LOWER(display_name) LIKE $3)`)
|
||||
query += database.Q(` AND (LOWER(username) LIKE $2 OR LOWER(display_name) LIKE $3 OR LOWER(handle) LIKE $4)`)
|
||||
pattern := "%" + strings.ToLower(q) + "%"
|
||||
args = append(args, pattern, pattern)
|
||||
args = append(args, pattern, pattern, pattern)
|
||||
}
|
||||
|
||||
query += ` ORDER BY username LIMIT 20`
|
||||
@@ -101,12 +101,13 @@ func SearchUsers(c *gin.Context) {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Handle string `json:"handle"`
|
||||
}
|
||||
|
||||
results := []userResult{}
|
||||
for rows.Next() {
|
||||
var u userResult
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName); err != nil {
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Handle); err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, u)
|
||||
|
||||
Reference in New Issue
Block a user