All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
639 lines
20 KiB
Go
639 lines
20 KiB
Go
package handlers
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/golang-jwt/jwt/v5"
|
||
"github.com/google/uuid"
|
||
"golang.org/x/crypto/bcrypt"
|
||
|
||
"armature/auth"
|
||
"armature/config"
|
||
"armature/crypto"
|
||
"armature/models"
|
||
"armature/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"`
|
||
jwt.RegisteredClaims
|
||
}
|
||
|
||
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, provider auth.Provider) *AuthHandler {
|
||
return &AuthHandler{cfg: cfg, stores: s, uekCache: uekCache, provider: provider}
|
||
}
|
||
|
||
func (h *AuthHandler) Register(c *gin.Context) {
|
||
if !h.provider.SupportsRegistration() {
|
||
c.JSON(http.StatusMethodNotAllowed, gin.H{"error": "registration not supported for this auth mode"})
|
||
return
|
||
}
|
||
|
||
result, err := h.provider.Register(c, h.stores)
|
||
if err != nil {
|
||
c.JSON(auth.ErrorToHTTPStatus(err), gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
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 !result.User.IsActive {
|
||
c.JSON(http.StatusCreated, gin.H{
|
||
"message": "Account created but requires admin approval",
|
||
"user_id": result.User.ID,
|
||
})
|
||
return
|
||
}
|
||
|
||
tokens, err := h.generateTokens(result.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) {
|
||
result, err := h.provider.Authenticate(c, h.stores)
|
||
if err != nil {
|
||
c.JSON(auth.ErrorToHTTPStatus(err), gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
if result.VaultHint != "" {
|
||
h.unlockVault(c.Request.Context(), result.User, result.VaultHint)
|
||
}
|
||
|
||
h.stores.Users.UpdateLastLogin(c.Request.Context(), result.User.ID)
|
||
|
||
tokens, err := h.generateTokens(result.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)
|
||
}
|
||
|
||
// Evict UEK from session cache
|
||
if userID, exists := c.Get("user_id"); exists {
|
||
h.uekCache.Evict(userID.(string))
|
||
}
|
||
|
||
// Clear the arm_token cookie so SSR middleware doesn't trust a stale token
|
||
c.SetCookie("arm_token", "", -1, "/", "", false, false)
|
||
|
||
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||
}
|
||
|
||
// ── OIDC Flow ──────────────────────────────
|
||
|
||
// OIDCLogin initiates the authorization code flow by redirecting to the IdP.
|
||
// GET /api/v1/auth/oidc/login
|
||
func (h *AuthHandler) OIDCLogin(c *gin.Context) {
|
||
oidcProv, ok := h.provider.(*auth.OIDCProvider)
|
||
if !ok {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "OIDC not configured"})
|
||
return
|
||
}
|
||
|
||
state := uuid.New().String()
|
||
nonce := uuid.New().String()
|
||
|
||
// Determine redirect URI
|
||
redirectURI := h.cfg.OIDCRedirectURL
|
||
if redirectURI == "" {
|
||
// Auto-derive from request
|
||
scheme := "https"
|
||
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
|
||
scheme = "http"
|
||
}
|
||
redirectURI = scheme + "://" + c.Request.Host + h.cfg.BasePath + "/api/v1/auth/oidc/callback"
|
||
}
|
||
|
||
// Store state for callback verification
|
||
if err := h.stores.GlobalConfig.SaveOIDCState(c.Request.Context(), state, nonce, c.Query("redirect")); err != nil {
|
||
log.Printf("[auth/oidc] warn: could not store state: %v", err)
|
||
}
|
||
|
||
authURL := oidcProv.AuthorizationURL(state, nonce, redirectURI)
|
||
c.Redirect(http.StatusFound, authURL)
|
||
}
|
||
|
||
// OIDCCallback handles the IdP redirect after successful authentication.
|
||
// GET /api/v1/auth/oidc/callback?code=...&state=...
|
||
func (h *AuthHandler) OIDCCallback(c *gin.Context) {
|
||
oidcProv, ok := h.provider.(*auth.OIDCProvider)
|
||
if !ok {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "OIDC not configured"})
|
||
return
|
||
}
|
||
|
||
code := c.Query("code")
|
||
state := c.Query("state")
|
||
|
||
// Check for IdP error response (Keycloak returns ?error=...&error_description=...)
|
||
if errCode := c.Query("error"); errCode != "" {
|
||
errDesc := c.Query("error_description")
|
||
log.Printf("[auth/oidc] IdP returned error: %s — %s", errCode, errDesc)
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": "IdP error: " + errCode, "description": errDesc})
|
||
return
|
||
}
|
||
|
||
if code == "" || state == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing code or state"})
|
||
return
|
||
}
|
||
|
||
// Verify state and retrieve nonce for ID token validation
|
||
storedNonce, _, err := h.stores.GlobalConfig.ConsumeOIDCState(c.Request.Context(), state)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid or expired state"})
|
||
return
|
||
}
|
||
|
||
// Clean up stale states (> 10 minutes old)
|
||
_ = h.stores.GlobalConfig.CleanupOIDCState(c.Request.Context())
|
||
|
||
// Determine redirect URI (must match what was sent in the login request)
|
||
redirectURI := h.cfg.OIDCRedirectURL
|
||
if redirectURI == "" {
|
||
scheme := "https"
|
||
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
|
||
scheme = "http"
|
||
}
|
||
redirectURI = scheme + "://" + c.Request.Host + h.cfg.BasePath + "/api/v1/auth/oidc/callback"
|
||
}
|
||
|
||
// Exchange code for tokens
|
||
tokenResp, err := oidcProv.ExchangeCode(c.Request.Context(), code, redirectURI)
|
||
if err != nil {
|
||
log.Printf("[auth/oidc] code exchange failed: %v", err)
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"})
|
||
return
|
||
}
|
||
|
||
// Validate nonce in the ID token to prevent token replay/substitution
|
||
if tokenResp.IDToken != "" && storedNonce != "" {
|
||
if err := oidcProv.ValidateIDTokenNonce(tokenResp.IDToken, storedNonce); err != nil {
|
||
log.Printf("[auth/oidc] nonce validation failed: %v", err)
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": "nonce validation failed"})
|
||
return
|
||
}
|
||
}
|
||
|
||
// Use the ID token (or access token) to authenticate via the provider
|
||
// Temporarily set the Authorization header so Authenticate() can read it
|
||
tokenToValidate := tokenResp.IDToken
|
||
if tokenToValidate == "" {
|
||
tokenToValidate = tokenResp.AccessToken
|
||
}
|
||
c.Request.Header.Set("Authorization", "Bearer "+tokenToValidate)
|
||
|
||
result, err := oidcProv.Authenticate(c, h.stores)
|
||
if err != nil {
|
||
log.Printf("[auth/oidc] authenticate after code exchange failed: %v", err)
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"})
|
||
return
|
||
}
|
||
|
||
// Issue internal JWT
|
||
tokens, err := h.generateTokens(result.User)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
||
return
|
||
}
|
||
|
||
h.stores.Users.UpdateLastLogin(c.Request.Context(), result.User.ID)
|
||
|
||
// For browser flow: encode tokens into a fragment that the login page JS
|
||
// can pick up, store in localStorage, and redirect to the app.
|
||
// This avoids httpOnly cookie issues — JS needs the token in localStorage.
|
||
accessToken, _ := tokens["access_token"].(string)
|
||
refreshToken, _ := tokens["refresh_token"].(string)
|
||
userJSON, _ := json.Marshal(tokens["user"])
|
||
|
||
// Set page-auth cookie too (for SSR middleware)
|
||
c.SetCookie("arm_token", accessToken, 900, "/", "", false, false)
|
||
|
||
// Base64-encode the token payload for the fragment
|
||
payload := fmt.Sprintf(`{"access_token":"%s","refresh_token":"%s","user":%s}`,
|
||
accessToken, refreshToken, string(userJSON))
|
||
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
|
||
|
||
dest := h.cfg.BasePath + "/login#oidc_result=" + encoded
|
||
c.Redirect(http.StatusFound, dest)
|
||
}
|
||
|
||
func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
|
||
// Access token (15 min)
|
||
accessClaims := Claims{
|
||
UserID: user.ID,
|
||
Email: user.Email,
|
||
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,
|
||
"handle": user.Handle,
|
||
"auth_source": user.AuthSource,
|
||
},
|
||
}, nil
|
||
}
|
||
|
||
func hashToken(token string) string {
|
||
h := sha256.Sum256([]byte(token))
|
||
return hex.EncodeToString(h[:])
|
||
}
|
||
|
||
// ── Vault Lifecycle ─────────────────────────
|
||
|
||
// DestroyVaultDB nullifies a user's vault columns and deletes personal
|
||
// provider configs. This is the shared DB-level operation used by:
|
||
// - unlockVault (stale seal recovery)
|
||
// - BootstrapAdmin / SeedUsers (password rotation at startup)
|
||
// - AdminHandler.destroyVault (admin-initiated reset)
|
||
//
|
||
// Does NOT evict from UEK cache or write audit logs — callers handle that.
|
||
func DestroyVaultDB(ctx context.Context, stores store.Stores, userID string) (deleted int64) {
|
||
if err := stores.Users.ClearVaultKeys(ctx, userID); err != nil {
|
||
log.Printf("⚠ DestroyVaultDB: failed to clear vault columns for user %s: %v", userID, err)
|
||
}
|
||
|
||
return 0
|
||
}
|
||
|
||
// ProbeAndRepairVault checks whether a user's vault can be unlocked with
|
||
// the given password. If the vault doesn't exist (vault_set=false), this is
|
||
// a no-op. If the vault exists but the seal is stale (encrypted_uek was
|
||
// wrapped with a different password), the vault and personal providers are
|
||
// destroyed so initVault fires cleanly on next login.
|
||
//
|
||
// When uekCache is non-nil and the vault unlocks successfully, the UEK is
|
||
// cached so that BYOK operations work immediately without requiring a fresh
|
||
// login. This is important for bootstrapped/seeded users whose browser
|
||
// sessions survive server restarts.
|
||
//
|
||
// Used by BootstrapAdmin and SeedUsers where the password is known at startup.
|
||
func ProbeAndRepairVault(ctx context.Context, stores store.Stores, userID, password string, uekCache ...*crypto.UEKCache) {
|
||
vaultSet, encryptedUEK, salt, nonce, err := stores.Users.GetVaultKeys(ctx, userID)
|
||
if err != nil || !vaultSet {
|
||
return // no vault to probe
|
||
}
|
||
|
||
pdk := crypto.DeriveKeyFromPassword(password, salt)
|
||
uek, err := crypto.UnwrapUEK(encryptedUEK, nonce, pdk)
|
||
if err == nil {
|
||
// Vault seal matches current password — cache UEK if cache provided
|
||
if len(uekCache) > 0 && uekCache[0] != nil {
|
||
uekCache[0].Store(userID, uek)
|
||
}
|
||
return
|
||
}
|
||
|
||
// Stale seal: password has actually changed since the vault was sealed
|
||
deleted := DestroyVaultDB(ctx, stores, userID)
|
||
if deleted > 0 {
|
||
log.Printf(" 🔐 Vault stale-seal repair for user %s: cleared %d personal provider(s)", userID, deleted)
|
||
} else {
|
||
log.Printf(" 🔐 Vault stale-seal repair for user %s: vault columns cleared", userID)
|
||
}
|
||
}
|
||
|
||
// initVault generates a UEK, wraps it with the user's password, and stores
|
||
// the encrypted UEK + salt + nonce on the user record. Called on registration
|
||
// and on first login for pre-migration users.
|
||
func (h *AuthHandler) initVault(ctx context.Context, userID, password string) error {
|
||
if h.uekCache == nil {
|
||
return nil // Vault not configured (e.g. tests)
|
||
}
|
||
|
||
uek, err := crypto.GenerateUEK()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
salt, err := crypto.GenerateSalt()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
pdk := crypto.DeriveKeyFromPassword(password, salt)
|
||
encryptedUEK, nonce, err := crypto.WrapUEK(uek, pdk)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if err := h.stores.Users.InitVaultKeys(ctx, userID, encryptedUEK, salt, nonce); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Cache the UEK for the session
|
||
h.uekCache.Store(userID, uek)
|
||
log.Printf(" 🔐 Vault initialized for user %s", userID)
|
||
return nil
|
||
}
|
||
|
||
// unlockVault unwraps the UEK on login and caches it. If the user doesn't
|
||
// have a vault yet (pre-migration account), initializes one.
|
||
func (h *AuthHandler) unlockVault(ctx context.Context, user *models.User, password string) {
|
||
if h.uekCache == nil {
|
||
return
|
||
}
|
||
|
||
vaultSet, encryptedUEK, salt, nonce, err := h.stores.Users.GetVaultKeys(ctx, user.ID)
|
||
if err != nil {
|
||
log.Printf("⚠ Vault query failed for user %s: %v", user.ID, err)
|
||
return
|
||
}
|
||
|
||
if !vaultSet {
|
||
// Pre-migration user: initialize vault on first login
|
||
if err := h.initVault(ctx, user.ID, password); err != nil {
|
||
log.Printf("⚠ Vault init on login failed for user %s: %v", user.ID, err)
|
||
}
|
||
return
|
||
}
|
||
|
||
// Derive PDK from password and unwrap UEK
|
||
pdk := crypto.DeriveKeyFromPassword(password, salt)
|
||
uek, err := crypto.UnwrapUEK(encryptedUEK, nonce, pdk)
|
||
if err != nil {
|
||
// Stale seal: encrypted_uek was wrapped with a different password
|
||
deleted := DestroyVaultDB(ctx, h.stores, user.ID)
|
||
if deleted > 0 {
|
||
log.Printf("⚠ Vault stale-seal recovery for user %s: cleared %d personal provider(s)", user.ID, deleted)
|
||
} else {
|
||
log.Printf("⚠ Vault stale-seal recovery for user %s: no personal providers to clear", user.ID)
|
||
}
|
||
|
||
if err := h.initVault(ctx, user.ID, password); err != nil {
|
||
log.Printf("⚠ Vault re-init failed for user %s: %v", user.ID, err)
|
||
}
|
||
return
|
||
}
|
||
|
||
h.uekCache.Store(user.ID, uek)
|
||
}
|
||
|
||
// BootstrapAdmin creates/updates the admin user from env vars (K8s secret).
|
||
// When uekCache is provided, the admin's vault is pre-warmed so BYOK
|
||
// operations work immediately without requiring a fresh login.
|
||
func BootstrapAdmin(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache) {
|
||
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
|
||
s.Users.Update(ctx, existing.ID, map[string]interface{}{
|
||
"password_hash": string(hash),
|
||
"is_active": true,
|
||
})
|
||
// 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 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})
|
||
}
|
||
var cache *crypto.UEKCache
|
||
if len(uekCache) > 0 {
|
||
cache = uekCache[0]
|
||
}
|
||
ProbeAndRepairVault(ctx, s, existing.ID, cfg.AdminPassword, cache)
|
||
auth.EnsureEveryoneGroup(ctx, s, existing.ID)
|
||
auth.AddToAdminsGroup(ctx, s, existing.ID)
|
||
log.Printf(" ✅ Admin user '%s' updated", cfg.AdminUsername)
|
||
return
|
||
}
|
||
|
||
email := cfg.AdminEmail
|
||
if email == "" {
|
||
email = cfg.AdminUsername + "@armature.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),
|
||
IsActive: true,
|
||
AuthSource: "builtin",
|
||
Handle: handle,
|
||
}
|
||
|
||
if err := s.Users.Create(ctx, user); err != nil {
|
||
log.Printf("⚠ Failed to create admin user: %v", err)
|
||
return
|
||
}
|
||
auth.EnsureEveryoneGroup(ctx, s, user.ID)
|
||
auth.AddToAdminsGroup(ctx, s, user.ID)
|
||
log.Printf(" ✅ Admin user '%s' created", cfg.AdminUsername)
|
||
}
|
||
|
||
// SeedUsers creates or updates users from the SEED_USERS env var.
|
||
// Format: "user:pass[:admin],user2:pass2" — third field "admin" adds to Admins group.
|
||
// Upsert: existing users get their password refreshed on every restart.
|
||
// Gated to non-production environments.
|
||
func SeedUsers(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKCache) {
|
||
if cfg.SeedUsers == "" {
|
||
log.Printf(" ℹ SEED_USERS not set, skipping")
|
||
return
|
||
}
|
||
|
||
if cfg.Environment == "production" {
|
||
log.Printf("⚠ SEED_USERS ignored in production environment")
|
||
return
|
||
}
|
||
|
||
ctx := context.Background()
|
||
entries := strings.Split(cfg.SeedUsers, ",")
|
||
log.Printf(" 🌱 SEED_USERS: %d entries to process", len(entries))
|
||
|
||
for _, entry := range entries {
|
||
entry = strings.TrimSpace(entry)
|
||
if entry == "" {
|
||
continue
|
||
}
|
||
|
||
parts := strings.SplitN(entry, ":", 3)
|
||
if len(parts) < 2 {
|
||
log.Printf("⚠ Seed user skipped (bad format, want user:pass[:admin]): %q", entry)
|
||
continue
|
||
}
|
||
|
||
username := strings.ToLower(strings.TrimSpace(parts[0]))
|
||
password := strings.TrimSpace(parts[1])
|
||
isAdmin := len(parts) >= 3 && strings.TrimSpace(parts[2]) == "admin"
|
||
|
||
if username == "" || password == "" {
|
||
log.Printf("⚠ Seed user skipped (empty username or password)")
|
||
continue
|
||
}
|
||
|
||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||
if err != nil {
|
||
log.Printf("⚠ Seed user '%s' skipped (hash error): %v", username, err)
|
||
continue
|
||
}
|
||
|
||
// Upsert: update password if user exists, create if not
|
||
existing, _ := s.Users.GetByUsername(ctx, username)
|
||
if existing != nil {
|
||
s.Users.Update(ctx, existing.ID, map[string]interface{}{
|
||
"password_hash": string(hash),
|
||
"is_active": true,
|
||
})
|
||
if existing.Handle == "" {
|
||
handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(username))
|
||
s.Users.Update(ctx, existing.ID, map[string]interface{}{"handle": handle})
|
||
}
|
||
var cache *crypto.UEKCache
|
||
if len(uekCache) > 0 {
|
||
cache = uekCache[0]
|
||
}
|
||
ProbeAndRepairVault(ctx, s, existing.ID, password, cache)
|
||
auth.EnsureEveryoneGroup(ctx, s, existing.ID)
|
||
if isAdmin {
|
||
auth.AddToAdminsGroup(ctx, s, existing.ID)
|
||
}
|
||
log.Printf(" 🌱 Seed user '%s' updated (admin=%v)", username, isAdmin)
|
||
continue
|
||
}
|
||
|
||
handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(username))
|
||
user := &models.User{
|
||
Username: username,
|
||
Email: username + "@armature.local",
|
||
PasswordHash: string(hash),
|
||
IsActive: true,
|
||
AuthSource: "builtin",
|
||
Handle: handle,
|
||
}
|
||
|
||
if err := s.Users.Create(ctx, user); err != nil {
|
||
log.Printf("⚠ Seed user '%s' failed: %v", username, err)
|
||
continue
|
||
}
|
||
auth.EnsureEveryoneGroup(ctx, s, user.ID)
|
||
if isAdmin {
|
||
auth.AddToAdminsGroup(ctx, s, user.ID)
|
||
}
|
||
log.Printf(" 🌱 Seed user '%s' created (admin=%v, active=true)", username, isAdmin)
|
||
}
|
||
}
|
||
|
||
// IsRegistrationEnabled checks the platform policy.
|
||
func IsRegistrationEnabled(s store.Stores) bool {
|
||
val, _ := s.Policies.GetBool(context.Background(), "allow_registration")
|
||
return val
|
||
}
|