mirror of
https://github.com/mudler/LocalAI.git
synced 2026-04-01 05:36:49 -04:00
* feat: add distributed mode (experimental) Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix data races, mutexes, transactions Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactorings Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fixups Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix events and tool stream in agent chat Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * use ginkgo Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactoring and consolidation Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactoring and consolidation Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactoring and consolidation Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactoring and consolidation Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactoring and consolidation Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactoring and consolidation Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactoring and consolidation Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactoring and consolidation Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(cron): compute correctly time boundaries avoiding re-triggering Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * enhancements, refactorings Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * do not flood of healthy checks Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * do not list obvious backends as text backends Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * tests fixups Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactoring and consolidation Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Drop redundant healthcheck Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * enhancements, refactorings Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1203 lines
38 KiB
Go
1203 lines
38 KiB
Go
package routes
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"net/mail"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/mudler/LocalAI/core/application"
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/core/http/auth"
|
|
"github.com/mudler/LocalAI/core/services/galleryop"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// rateLimiter implements a simple per-IP rate limiter for auth endpoints.
|
|
type rateLimiter struct {
|
|
mu sync.Mutex
|
|
attempts map[string][]time.Time
|
|
window time.Duration
|
|
max int
|
|
}
|
|
|
|
func newRateLimiter(window time.Duration, max int) *rateLimiter {
|
|
return &rateLimiter{
|
|
attempts: make(map[string][]time.Time),
|
|
window: window,
|
|
max: max,
|
|
}
|
|
}
|
|
|
|
func (rl *rateLimiter) allow(key string) bool {
|
|
rl.mu.Lock()
|
|
defer rl.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
cutoff := now.Add(-rl.window)
|
|
|
|
// Prune old entries
|
|
recent := rl.attempts[key][:0]
|
|
for _, t := range rl.attempts[key] {
|
|
if t.After(cutoff) {
|
|
recent = append(recent, t)
|
|
}
|
|
}
|
|
|
|
if len(recent) >= rl.max {
|
|
rl.attempts[key] = recent
|
|
return false
|
|
}
|
|
|
|
rl.attempts[key] = append(recent, now)
|
|
return true
|
|
}
|
|
|
|
// cleanup removes stale IP entries that have no recent attempts.
|
|
func (rl *rateLimiter) cleanup() {
|
|
rl.mu.Lock()
|
|
defer rl.mu.Unlock()
|
|
|
|
cutoff := time.Now().Add(-rl.window)
|
|
for ip, attempts := range rl.attempts {
|
|
recent := attempts[:0]
|
|
for _, t := range attempts {
|
|
if t.After(cutoff) {
|
|
recent = append(recent, t)
|
|
}
|
|
}
|
|
if len(recent) == 0 {
|
|
delete(rl.attempts, ip)
|
|
} else {
|
|
rl.attempts[ip] = recent
|
|
}
|
|
}
|
|
}
|
|
|
|
func rateLimitMiddleware(rl *rateLimiter) echo.MiddlewareFunc {
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
if !rl.allow(c.RealIP()) {
|
|
return c.JSON(http.StatusTooManyRequests, map[string]string{
|
|
"error": "too many requests, please try again later",
|
|
})
|
|
}
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// parseDuration parses a duration string like "30d", "90d", "1y", "24h".
|
|
func parseDuration(s string) (time.Duration, error) {
|
|
if strings.HasSuffix(s, "d") {
|
|
s = strings.TrimSuffix(s, "d")
|
|
var days int
|
|
for _, c := range s {
|
|
if c < '0' || c > '9' {
|
|
return 0, &time.ParseError{}
|
|
}
|
|
days = days*10 + int(c-'0')
|
|
}
|
|
return time.Duration(days) * 24 * time.Hour, nil
|
|
}
|
|
if strings.HasSuffix(s, "y") {
|
|
s = strings.TrimSuffix(s, "y")
|
|
var years int
|
|
for _, c := range s {
|
|
if c < '0' || c > '9' {
|
|
return 0, &time.ParseError{}
|
|
}
|
|
years = years*10 + int(c-'0')
|
|
}
|
|
return time.Duration(years) * 365 * 24 * time.Hour, nil
|
|
}
|
|
return time.ParseDuration(s)
|
|
}
|
|
|
|
// RegisterAuthRoutes registers authentication-related API routes.
|
|
func RegisterAuthRoutes(e *echo.Echo, app *application.Application) {
|
|
appConfig := app.ApplicationConfig()
|
|
db := app.AuthDB()
|
|
|
|
// GET /api/auth/status - public, returns auth state
|
|
e.GET("/api/auth/status", func(c echo.Context) error {
|
|
authEnabled := db != nil
|
|
providers := []string{}
|
|
hasUsers := false
|
|
|
|
if authEnabled {
|
|
var count int64
|
|
db.Model(&auth.User{}).Count(&count)
|
|
hasUsers = count > 0
|
|
|
|
if !appConfig.Auth.DisableLocalAuth {
|
|
providers = append(providers, auth.ProviderLocal)
|
|
}
|
|
if appConfig.Auth.GitHubClientID != "" {
|
|
providers = append(providers, auth.ProviderGitHub)
|
|
}
|
|
if appConfig.Auth.OIDCClientID != "" {
|
|
providers = append(providers, auth.ProviderOIDC)
|
|
}
|
|
}
|
|
|
|
registrationMode := ""
|
|
if authEnabled {
|
|
registrationMode = appConfig.Auth.RegistrationMode
|
|
if registrationMode == "" {
|
|
registrationMode = "approval"
|
|
}
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"authEnabled": authEnabled,
|
|
"providers": providers,
|
|
"hasUsers": hasUsers,
|
|
"registrationMode": registrationMode,
|
|
}
|
|
|
|
// Include current user if authenticated
|
|
user := auth.GetUser(c)
|
|
if user != nil {
|
|
userResp := map[string]any{
|
|
"id": user.ID,
|
|
"email": user.Email,
|
|
"name": user.Name,
|
|
"avatarUrl": user.AvatarURL,
|
|
"role": user.Role,
|
|
"provider": user.Provider,
|
|
}
|
|
if authEnabled {
|
|
userResp["permissions"] = auth.GetPermissionMapForUser(db, user)
|
|
}
|
|
resp["user"] = userResp
|
|
} else {
|
|
resp["user"] = nil
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, resp)
|
|
})
|
|
|
|
// OAuth routes - only registered when auth is enabled
|
|
if db == nil {
|
|
return
|
|
}
|
|
|
|
// Set up OAuth manager when any OAuth/OIDC provider is configured
|
|
if appConfig.Auth.GitHubClientID != "" || appConfig.Auth.OIDCClientID != "" {
|
|
oauthMgr, err := auth.NewOAuthManager(
|
|
appConfig.Auth.BaseURL,
|
|
auth.OAuthParams{
|
|
GitHubClientID: appConfig.Auth.GitHubClientID,
|
|
GitHubClientSecret: appConfig.Auth.GitHubClientSecret,
|
|
OIDCIssuer: appConfig.Auth.OIDCIssuer,
|
|
OIDCClientID: appConfig.Auth.OIDCClientID,
|
|
OIDCClientSecret: appConfig.Auth.OIDCClientSecret,
|
|
},
|
|
)
|
|
if err == nil {
|
|
if appConfig.Auth.GitHubClientID != "" {
|
|
e.GET("/api/auth/github/login", oauthMgr.LoginHandler(auth.ProviderGitHub))
|
|
e.GET("/api/auth/github/callback", oauthMgr.CallbackHandler(
|
|
auth.ProviderGitHub, db, appConfig.Auth.AdminEmail, appConfig.Auth.RegistrationMode, appConfig.Auth.APIKeyHMACSecret,
|
|
))
|
|
}
|
|
if appConfig.Auth.OIDCClientID != "" {
|
|
e.GET("/api/auth/oidc/login", oauthMgr.LoginHandler(auth.ProviderOIDC))
|
|
e.GET("/api/auth/oidc/callback", oauthMgr.CallbackHandler(
|
|
auth.ProviderOIDC, db, appConfig.Auth.AdminEmail, appConfig.Auth.RegistrationMode, appConfig.Auth.APIKeyHMACSecret,
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Rate limiter for auth endpoints: 5 attempts per minute per IP
|
|
authRL := newRateLimiter(1*time.Minute, 5)
|
|
authRateLimitMw := rateLimitMiddleware(authRL)
|
|
|
|
// Start background goroutine to periodically prune stale IP entries (#12)
|
|
go func() {
|
|
ticker := time.NewTicker(10 * time.Minute)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-appConfig.Context.Done():
|
|
return
|
|
case <-ticker.C:
|
|
authRL.cleanup()
|
|
}
|
|
}
|
|
}()
|
|
|
|
// POST /api/auth/register - public, email/password registration
|
|
e.POST("/api/auth/register", func(c echo.Context) error {
|
|
if appConfig.Auth.DisableLocalAuth {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "local registration is disabled"})
|
|
}
|
|
|
|
var body struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
Name string `json:"name"`
|
|
InviteCode string `json:"inviteCode"`
|
|
}
|
|
if err := c.Bind(&body); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
|
|
body.Email = strings.ToLower(strings.TrimSpace(body.Email))
|
|
body.Name = strings.TrimSpace(body.Name)
|
|
body.InviteCode = strings.TrimSpace(body.InviteCode)
|
|
|
|
if body.Email == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "email is required"})
|
|
}
|
|
if _, err := mail.ParseAddress(body.Email); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid email address"})
|
|
}
|
|
if len(body.Password) < 8 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "password must be at least 8 characters"})
|
|
}
|
|
|
|
hash, err := auth.HashPassword(body.Password)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
|
|
}
|
|
|
|
name := body.Name
|
|
if name == "" {
|
|
name = body.Email
|
|
}
|
|
|
|
// Wrap user creation in a transaction to prevent admin bootstrap race (#2)
|
|
var user *auth.User
|
|
var validInvite *auth.InviteCode
|
|
var status string
|
|
|
|
txErr := db.Transaction(func(tx *gorm.DB) error {
|
|
// Check for duplicate email with local provider (#6: return generic response)
|
|
var existing auth.User
|
|
if err := tx.Where("email = ? AND provider = ?", body.Email, auth.ProviderLocal).First(&existing).Error; err == nil {
|
|
// Account exists — return nil to signal generic success
|
|
user = nil
|
|
return nil
|
|
}
|
|
|
|
role := auth.AssignRole(tx, body.Email, appConfig.Auth.AdminEmail)
|
|
|
|
// Determine status based on registration mode and invite code
|
|
status = auth.StatusActive
|
|
|
|
if auth.NeedsInviteOrApproval(tx, body.Email, appConfig.Auth.AdminEmail, appConfig.Auth.RegistrationMode) {
|
|
if appConfig.Auth.RegistrationMode == "invite" && body.InviteCode == "" {
|
|
return fmt.Errorf("invite_required")
|
|
}
|
|
|
|
if body.InviteCode != "" {
|
|
invite, err := auth.ValidateInvite(tx, body.InviteCode, appConfig.Auth.APIKeyHMACSecret)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid_invite")
|
|
}
|
|
validInvite = invite
|
|
status = auth.StatusActive
|
|
} else {
|
|
status = auth.StatusPending
|
|
}
|
|
}
|
|
|
|
user = &auth.User{
|
|
ID: uuid.New().String(),
|
|
Email: body.Email,
|
|
Name: name,
|
|
Provider: auth.ProviderLocal,
|
|
Subject: body.Email,
|
|
PasswordHash: hash,
|
|
Role: role,
|
|
Status: status,
|
|
}
|
|
if err := tx.Create(user).Error; err != nil {
|
|
return fmt.Errorf("failed to create user: %w", err)
|
|
}
|
|
|
|
if validInvite != nil {
|
|
auth.ConsumeInvite(tx, validInvite, user.ID)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if txErr != nil {
|
|
msg := txErr.Error()
|
|
if msg == "invite_required" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "an invite code is required to register"})
|
|
}
|
|
if msg == "invalid_invite" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid or expired invite code"})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create user"})
|
|
}
|
|
|
|
// user == nil means duplicate email — return generic success (#6)
|
|
if user == nil {
|
|
return c.JSON(http.StatusCreated, map[string]any{
|
|
"message": "registration processed",
|
|
})
|
|
}
|
|
|
|
if status == auth.StatusPending {
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"message": "registration successful, awaiting admin approval",
|
|
"pending": true,
|
|
})
|
|
}
|
|
|
|
sessionID, err := auth.CreateSession(db, user.ID, appConfig.Auth.APIKeyHMACSecret)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create session"})
|
|
}
|
|
auth.SetSessionCookie(c, sessionID)
|
|
|
|
return c.JSON(http.StatusCreated, map[string]any{
|
|
"user": map[string]any{
|
|
"id": user.ID,
|
|
"email": user.Email,
|
|
"name": user.Name,
|
|
"role": user.Role,
|
|
},
|
|
})
|
|
}, authRateLimitMw)
|
|
|
|
// POST /api/auth/login - public, email/password login
|
|
e.POST("/api/auth/login", func(c echo.Context) error {
|
|
if appConfig.Auth.DisableLocalAuth {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "local login is disabled, please use OAuth"})
|
|
}
|
|
|
|
var body struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := c.Bind(&body); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
|
|
body.Email = strings.ToLower(strings.TrimSpace(body.Email))
|
|
|
|
if body.Email == "" || body.Password == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "email and password are required"})
|
|
}
|
|
|
|
var user auth.User
|
|
if err := db.Where("email = ? AND provider = ?", body.Email, auth.ProviderLocal).First(&user).Error; err != nil {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid email or password"})
|
|
}
|
|
|
|
if !auth.CheckPassword(user.PasswordHash, body.Password) {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid email or password"})
|
|
}
|
|
|
|
if user.Status == auth.StatusPending {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "account pending admin approval"})
|
|
}
|
|
|
|
// Maybe promote on login
|
|
auth.MaybePromote(db, &user, appConfig.Auth.AdminEmail)
|
|
|
|
sessionID, err := auth.CreateSession(db, user.ID, appConfig.Auth.APIKeyHMACSecret)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create session"})
|
|
}
|
|
auth.SetSessionCookie(c, sessionID)
|
|
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"user": map[string]any{
|
|
"id": user.ID,
|
|
"email": user.Email,
|
|
"name": user.Name,
|
|
"role": user.Role,
|
|
},
|
|
})
|
|
}, authRateLimitMw)
|
|
|
|
// POST /api/auth/token-login - public, authenticate with API key/token (#3)
|
|
e.POST("/api/auth/token-login", func(c echo.Context) error {
|
|
var body struct {
|
|
Token string `json:"token"`
|
|
}
|
|
if err := c.Bind(&body); err != nil || strings.TrimSpace(body.Token) == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "token is required"})
|
|
}
|
|
|
|
token := strings.TrimSpace(body.Token)
|
|
hmacSecret := appConfig.Auth.APIKeyHMACSecret
|
|
|
|
// Try as user API key
|
|
if apiKey, err := auth.ValidateAPIKey(db, token, hmacSecret); err == nil {
|
|
sessionID, err := auth.CreateSession(db, apiKey.User.ID, appConfig.Auth.APIKeyHMACSecret)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create session"})
|
|
}
|
|
auth.SetSessionCookie(c, sessionID)
|
|
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"user": map[string]any{
|
|
"id": apiKey.User.ID,
|
|
"email": apiKey.User.Email,
|
|
"name": apiKey.User.Name,
|
|
"role": apiKey.User.Role,
|
|
},
|
|
})
|
|
}
|
|
|
|
// Try as legacy API key
|
|
if len(appConfig.ApiKeys) > 0 && isValidLegacyKey(token, appConfig) {
|
|
// Create a synthetic session cookie with the token for legacy mode
|
|
auth.SetTokenCookie(c, token)
|
|
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"user": map[string]any{
|
|
"id": "legacy-api-key",
|
|
"name": "API Key User",
|
|
"role": auth.RoleAdmin,
|
|
},
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid token"})
|
|
}, authRateLimitMw)
|
|
|
|
// POST /api/auth/logout - requires auth
|
|
e.POST("/api/auth/logout", func(c echo.Context) error {
|
|
user := auth.GetUser(c)
|
|
if user == nil {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
|
}
|
|
|
|
// Delete session from cookie
|
|
if cookie, err := c.Cookie("session"); err == nil && cookie.Value != "" {
|
|
auth.DeleteSession(db, cookie.Value, appConfig.Auth.APIKeyHMACSecret)
|
|
}
|
|
auth.ClearSessionCookie(c)
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{"message": "logged out"})
|
|
})
|
|
|
|
// GET /api/auth/me - requires auth
|
|
e.GET("/api/auth/me", func(c echo.Context) error {
|
|
user := auth.GetUser(c)
|
|
if user == nil {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"id": user.ID,
|
|
"email": user.Email,
|
|
"name": user.Name,
|
|
"avatarUrl": user.AvatarURL,
|
|
"role": user.Role,
|
|
"provider": user.Provider,
|
|
"permissions": auth.GetPermissionMapForUser(db, user),
|
|
}
|
|
if quotas, err := auth.GetQuotaStatuses(db, user.ID); err == nil {
|
|
resp["quotas"] = quotas
|
|
}
|
|
return c.JSON(http.StatusOK, resp)
|
|
})
|
|
|
|
// GET /api/auth/quota - view own quota status
|
|
e.GET("/api/auth/quota", func(c echo.Context) error {
|
|
user := auth.GetUser(c)
|
|
if user == nil {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
|
}
|
|
quotas, err := auth.GetQuotaStatuses(db, user.ID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get quota status"})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]any{"quotas": quotas})
|
|
})
|
|
|
|
// PUT /api/auth/profile - update user profile (name, avatar_url)
|
|
e.PUT("/api/auth/profile", func(c echo.Context) error {
|
|
user := auth.GetUser(c)
|
|
if user == nil {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
|
}
|
|
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
}
|
|
if err := c.Bind(&body); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
|
|
name := strings.TrimSpace(body.Name)
|
|
if name == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "name is required"})
|
|
}
|
|
|
|
avatarURL := strings.TrimSpace(body.AvatarURL)
|
|
if len(avatarURL) > 512 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "avatar URL must be at most 512 characters"})
|
|
}
|
|
|
|
updates := map[string]any{
|
|
"name": name,
|
|
"avatar_url": avatarURL,
|
|
}
|
|
|
|
if err := db.Model(&auth.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update profile"})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"message": "profile updated",
|
|
"name": name,
|
|
"avatarUrl": avatarURL,
|
|
})
|
|
})
|
|
|
|
// PUT /api/auth/password - change password (local users only) (#4: add rate limiting)
|
|
e.PUT("/api/auth/password", func(c echo.Context) error {
|
|
user := auth.GetUser(c)
|
|
if user == nil {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
|
}
|
|
|
|
if user.Provider != auth.ProviderLocal {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "password change is only available for local accounts"})
|
|
}
|
|
|
|
var body struct {
|
|
CurrentPassword string `json:"current_password"`
|
|
NewPassword string `json:"new_password"`
|
|
}
|
|
if err := c.Bind(&body); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
|
|
if body.CurrentPassword == "" || body.NewPassword == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "current and new passwords are required"})
|
|
}
|
|
|
|
if len(body.NewPassword) < 8 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "new password must be at least 8 characters"})
|
|
}
|
|
|
|
// Verify current password
|
|
if !auth.CheckPassword(user.PasswordHash, body.CurrentPassword) {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "current password is incorrect"})
|
|
}
|
|
|
|
hash, err := auth.HashPassword(body.NewPassword)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
|
|
}
|
|
|
|
if err := db.Model(&auth.User{}).Where("id = ?", user.ID).Update("password_hash", hash).Error; err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update password"})
|
|
}
|
|
|
|
// Invalidate all existing sessions for this user
|
|
auth.DeleteUserSessions(db, user.ID)
|
|
|
|
// Create a fresh session for the current request
|
|
newSessionID, err := auth.CreateSession(db, user.ID, appConfig.Auth.APIKeyHMACSecret)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create session"})
|
|
}
|
|
auth.SetSessionCookie(c, newSessionID)
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{"message": "password updated"})
|
|
}, authRateLimitMw)
|
|
|
|
// DELETE /api/auth/sessions - revoke all sessions for the current user
|
|
e.DELETE("/api/auth/sessions", func(c echo.Context) error {
|
|
user := auth.GetUser(c)
|
|
if user == nil {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
|
}
|
|
|
|
// Delete all sessions
|
|
auth.DeleteUserSessions(db, user.ID)
|
|
|
|
// Create a fresh session for the current request
|
|
newSessionID, err := auth.CreateSession(db, user.ID, appConfig.Auth.APIKeyHMACSecret)
|
|
if err != nil {
|
|
auth.ClearSessionCookie(c)
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create session"})
|
|
}
|
|
auth.SetSessionCookie(c, newSessionID)
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{"message": "all other sessions revoked"})
|
|
})
|
|
|
|
// POST /api/auth/api-keys - create API key (#8: expiration support)
|
|
e.POST("/api/auth/api-keys", func(c echo.Context) error {
|
|
user := auth.GetUser(c)
|
|
if user == nil {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
|
}
|
|
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
ExpiresIn string `json:"expiresIn"` // duration like "30d", "90d", "1y"
|
|
ExpiresAt string `json:"expiresAt"` // ISO timestamp
|
|
}
|
|
if err := c.Bind(&body); err != nil || body.Name == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "name is required"})
|
|
}
|
|
|
|
// Determine expiration
|
|
var expiresAt *time.Time
|
|
if body.ExpiresAt != "" {
|
|
t, err := time.Parse(time.RFC3339, body.ExpiresAt)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid expiresAt format, use RFC3339"})
|
|
}
|
|
expiresAt = &t
|
|
} else if body.ExpiresIn != "" {
|
|
dur, err := parseDuration(body.ExpiresIn)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid expiresIn format"})
|
|
}
|
|
t := time.Now().Add(dur)
|
|
expiresAt = &t
|
|
} else if appConfig.Auth.DefaultAPIKeyExpiry != "" {
|
|
dur, err := parseDuration(appConfig.Auth.DefaultAPIKeyExpiry)
|
|
if err == nil {
|
|
t := time.Now().Add(dur)
|
|
expiresAt = &t
|
|
}
|
|
}
|
|
|
|
plaintext, record, err := auth.CreateAPIKey(db, user.ID, body.Name, user.Role, appConfig.Auth.APIKeyHMACSecret, expiresAt)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create API key"})
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"key": plaintext, // shown once
|
|
"id": record.ID,
|
|
"name": record.Name,
|
|
"keyPrefix": record.KeyPrefix,
|
|
"role": record.Role,
|
|
"createdAt": record.CreatedAt,
|
|
}
|
|
if record.ExpiresAt != nil {
|
|
resp["expiresAt"] = record.ExpiresAt
|
|
}
|
|
|
|
return c.JSON(http.StatusCreated, resp)
|
|
})
|
|
|
|
// GET /api/auth/api-keys - list user's API keys
|
|
e.GET("/api/auth/api-keys", func(c echo.Context) error {
|
|
user := auth.GetUser(c)
|
|
if user == nil {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
|
}
|
|
|
|
keys, err := auth.ListAPIKeys(db, user.ID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to list API keys"})
|
|
}
|
|
|
|
result := make([]map[string]any, 0, len(keys))
|
|
for _, k := range keys {
|
|
entry := map[string]any{
|
|
"id": k.ID,
|
|
"name": k.Name,
|
|
"keyPrefix": k.KeyPrefix,
|
|
"role": k.Role,
|
|
"createdAt": k.CreatedAt,
|
|
"lastUsed": k.LastUsed,
|
|
}
|
|
if k.ExpiresAt != nil {
|
|
entry["expiresAt"] = k.ExpiresAt
|
|
}
|
|
result = append(result, entry)
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]any{"keys": result})
|
|
})
|
|
|
|
// DELETE /api/auth/api-keys/:id - revoke API key
|
|
e.DELETE("/api/auth/api-keys/:id", func(c echo.Context) error {
|
|
user := auth.GetUser(c)
|
|
if user == nil {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
|
}
|
|
|
|
keyID := c.Param("id")
|
|
if err := auth.RevokeAPIKey(db, keyID, user.ID); err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "API key not found"})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{"message": "API key revoked"})
|
|
})
|
|
|
|
// Usage endpoints
|
|
// GET /api/auth/usage - user's own usage
|
|
e.GET("/api/auth/usage", func(c echo.Context) error {
|
|
user := auth.GetUser(c)
|
|
if user == nil {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
|
}
|
|
|
|
period := c.QueryParam("period")
|
|
if period == "" {
|
|
period = "month"
|
|
}
|
|
|
|
buckets, err := auth.GetUserUsage(db, user.ID, period)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get usage"})
|
|
}
|
|
|
|
totals := auth.UsageTotals{}
|
|
for _, b := range buckets {
|
|
totals.PromptTokens += b.PromptTokens
|
|
totals.CompletionTokens += b.CompletionTokens
|
|
totals.TotalTokens += b.TotalTokens
|
|
totals.RequestCount += b.RequestCount
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"usage": buckets,
|
|
"totals": totals,
|
|
})
|
|
})
|
|
|
|
// Admin endpoints
|
|
adminMw := auth.RequireAdmin()
|
|
|
|
// GET /api/auth/admin/features - returns feature metadata and available models
|
|
e.GET("/api/auth/admin/features", func(c echo.Context) error {
|
|
// Get available models
|
|
modelNames := []string{}
|
|
if app.ModelConfigLoader() != nil && app.ModelLoader() != nil {
|
|
names, err := galleryop.ListModels(
|
|
app.ModelConfigLoader(), app.ModelLoader(), nil, galleryop.SKIP_IF_CONFIGURED,
|
|
)
|
|
if err == nil {
|
|
modelNames = names
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"agent_features": auth.AgentFeatureMetas(),
|
|
"general_features": auth.GeneralFeatureMetas(),
|
|
"api_features": auth.APIFeatureMetas(),
|
|
"models": modelNames,
|
|
})
|
|
}, adminMw)
|
|
|
|
// GET /api/auth/admin/users - list all users
|
|
e.GET("/api/auth/admin/users", func(c echo.Context) error {
|
|
var users []auth.User
|
|
if err := db.Order("created_at ASC").Find(&users).Error; err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to list users"})
|
|
}
|
|
|
|
result := make([]map[string]any, 0, len(users))
|
|
for _, u := range users {
|
|
entry := map[string]any{
|
|
"id": u.ID,
|
|
"email": u.Email,
|
|
"name": u.Name,
|
|
"avatarUrl": u.AvatarURL,
|
|
"role": u.Role,
|
|
"status": u.Status,
|
|
"provider": u.Provider,
|
|
"createdAt": u.CreatedAt,
|
|
}
|
|
entry["permissions"] = auth.GetPermissionMapForUser(db, &u)
|
|
entry["allowed_models"] = auth.GetModelAllowlist(db, u.ID)
|
|
if quotas, err := auth.GetQuotaStatuses(db, u.ID); err == nil && len(quotas) > 0 {
|
|
entry["quotas"] = quotas
|
|
}
|
|
result = append(result, entry)
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]any{"users": result})
|
|
}, adminMw)
|
|
|
|
// PUT /api/auth/admin/users/:id/role - change user role
|
|
e.PUT("/api/auth/admin/users/:id/role", func(c echo.Context) error {
|
|
currentUser := auth.GetUser(c)
|
|
targetID := c.Param("id")
|
|
|
|
if currentUser.ID == targetID {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot change your own role"})
|
|
}
|
|
|
|
var body struct {
|
|
Role string `json:"role"`
|
|
}
|
|
if err := c.Bind(&body); err != nil || (body.Role != auth.RoleAdmin && body.Role != auth.RoleUser) {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "role must be 'admin' or 'user'"})
|
|
}
|
|
|
|
result := db.Model(&auth.User{}).Where("id = ?", targetID).Update("role", body.Role)
|
|
if result.RowsAffected == 0 {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{"message": "role updated"})
|
|
}, adminMw)
|
|
|
|
// PUT /api/auth/admin/users/:id/status - change user status (approve/disable)
|
|
e.PUT("/api/auth/admin/users/:id/status", func(c echo.Context) error {
|
|
currentUser := auth.GetUser(c)
|
|
targetID := c.Param("id")
|
|
|
|
if currentUser.ID == targetID {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot change your own status"})
|
|
}
|
|
|
|
var body struct {
|
|
Status string `json:"status"`
|
|
}
|
|
if err := c.Bind(&body); err != nil || (body.Status != auth.StatusActive && body.Status != auth.StatusDisabled) {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "status must be 'active' or 'disabled'"})
|
|
}
|
|
|
|
result := db.Model(&auth.User{}).Where("id = ?", targetID).Update("status", body.Status)
|
|
if result.RowsAffected == 0 {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{"message": "status updated"})
|
|
}, adminMw)
|
|
|
|
// PUT /api/auth/admin/users/:id/password - admin reset user password
|
|
e.PUT("/api/auth/admin/users/:id/password", func(c echo.Context) error {
|
|
currentUser := auth.GetUser(c)
|
|
targetID := c.Param("id")
|
|
|
|
if currentUser.ID == targetID {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot reset your own password via this endpoint, use self-service password change"})
|
|
}
|
|
|
|
var target auth.User
|
|
if err := db.First(&target, "id = ?", targetID).Error; err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
|
}
|
|
|
|
if target.Provider != auth.ProviderLocal {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "password reset is only available for local accounts"})
|
|
}
|
|
|
|
var body struct {
|
|
Password string `json:"password"`
|
|
}
|
|
if err := c.Bind(&body); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
|
|
if len(body.Password) < 8 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "password must be at least 8 characters"})
|
|
}
|
|
|
|
hash, err := auth.HashPassword(body.Password)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
|
|
}
|
|
|
|
if err := db.Model(&auth.User{}).Where("id = ?", targetID).Update("password_hash", hash).Error; err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update password"})
|
|
}
|
|
|
|
auth.DeleteUserSessions(db, targetID)
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{"message": "password reset successfully"})
|
|
}, adminMw)
|
|
|
|
// DELETE /api/auth/admin/users/:id - delete user
|
|
e.DELETE("/api/auth/admin/users/:id", func(c echo.Context) error {
|
|
currentUser := auth.GetUser(c)
|
|
targetID := c.Param("id")
|
|
|
|
if currentUser.ID == targetID {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot delete yourself"})
|
|
}
|
|
|
|
// Cascade: delete sessions and API keys
|
|
db.Where("user_id = ?", targetID).Delete(&auth.Session{})
|
|
db.Where("user_id = ?", targetID).Delete(&auth.UserAPIKey{})
|
|
|
|
result := db.Where("id = ?", targetID).Delete(&auth.User{})
|
|
if result.RowsAffected == 0 {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{"message": "user deleted"})
|
|
}, adminMw)
|
|
|
|
// GET /api/auth/admin/users/:id/permissions - get user permissions
|
|
e.GET("/api/auth/admin/users/:id/permissions", func(c echo.Context) error {
|
|
targetID := c.Param("id")
|
|
var target auth.User
|
|
if err := db.First(&target, "id = ?", targetID).Error; err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
|
}
|
|
perms := auth.GetPermissionMapForUser(db, &target)
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"user_id": targetID,
|
|
"permissions": perms,
|
|
})
|
|
}, adminMw)
|
|
|
|
// PUT /api/auth/admin/users/:id/permissions - update user permissions
|
|
e.PUT("/api/auth/admin/users/:id/permissions", func(c echo.Context) error {
|
|
targetID := c.Param("id")
|
|
var target auth.User
|
|
if err := db.First(&target, "id = ?", targetID).Error; err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
|
}
|
|
|
|
var perms auth.PermissionMap
|
|
if err := c.Bind(&perms); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
|
}
|
|
|
|
if err := auth.UpdateUserPermissions(db, targetID, perms); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update permissions"})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"message": "permissions updated",
|
|
"user_id": targetID,
|
|
"permissions": perms,
|
|
})
|
|
}, adminMw)
|
|
|
|
// PUT /api/auth/admin/users/:id/models - update user model allowlist
|
|
e.PUT("/api/auth/admin/users/:id/models", func(c echo.Context) error {
|
|
targetID := c.Param("id")
|
|
var target auth.User
|
|
if err := db.First(&target, "id = ?", targetID).Error; err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
|
}
|
|
|
|
var allowlist auth.ModelAllowlist
|
|
if err := c.Bind(&allowlist); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
|
}
|
|
|
|
if err := auth.UpdateModelAllowlist(db, targetID, allowlist); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update model allowlist"})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"message": "model allowlist updated",
|
|
"user_id": targetID,
|
|
"allowed_models": allowlist,
|
|
})
|
|
}, adminMw)
|
|
|
|
// GET /api/auth/admin/users/:id/quotas - list user's quota rules
|
|
e.GET("/api/auth/admin/users/:id/quotas", func(c echo.Context) error {
|
|
targetID := c.Param("id")
|
|
var target auth.User
|
|
if err := db.First(&target, "id = ?", targetID).Error; err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
|
}
|
|
quotas, err := auth.GetQuotaStatuses(db, targetID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get quotas"})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]any{"quotas": quotas})
|
|
}, adminMw)
|
|
|
|
// PUT /api/auth/admin/users/:id/quotas - upsert quota rule (by user+model)
|
|
e.PUT("/api/auth/admin/users/:id/quotas", func(c echo.Context) error {
|
|
targetID := c.Param("id")
|
|
var target auth.User
|
|
if err := db.First(&target, "id = ?", targetID).Error; err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
|
}
|
|
|
|
var body struct {
|
|
Model string `json:"model"`
|
|
MaxRequests *int64 `json:"max_requests"`
|
|
MaxTotalTokens *int64 `json:"max_total_tokens"`
|
|
Window string `json:"window"`
|
|
}
|
|
if err := c.Bind(&body); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
|
}
|
|
if body.Window == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "window is required"})
|
|
}
|
|
|
|
windowSecs, err := auth.ParseWindowDuration(body.Window)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
rule, err := auth.CreateOrUpdateQuotaRule(db, targetID, body.Model, body.MaxRequests, body.MaxTotalTokens, windowSecs)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save quota rule"})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"message": "quota rule saved",
|
|
"quota": rule,
|
|
})
|
|
}, adminMw)
|
|
|
|
// DELETE /api/auth/admin/users/:id/quotas/:quota_id - delete a quota rule
|
|
e.DELETE("/api/auth/admin/users/:id/quotas/:quota_id", func(c echo.Context) error {
|
|
targetID := c.Param("id")
|
|
quotaID := c.Param("quota_id")
|
|
if err := auth.DeleteQuotaRule(db, quotaID, targetID); err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "quota rule not found"})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]string{"message": "quota rule deleted"})
|
|
}, adminMw)
|
|
|
|
// GET /api/auth/admin/usage - all users' usage (admin only)
|
|
e.GET("/api/auth/admin/usage", func(c echo.Context) error {
|
|
period := c.QueryParam("period")
|
|
if period == "" {
|
|
period = "month"
|
|
}
|
|
userID := c.QueryParam("user_id")
|
|
|
|
buckets, err := auth.GetAllUsage(db, period, userID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get usage"})
|
|
}
|
|
|
|
totals := auth.UsageTotals{}
|
|
for _, b := range buckets {
|
|
totals.PromptTokens += b.PromptTokens
|
|
totals.CompletionTokens += b.CompletionTokens
|
|
totals.TotalTokens += b.TotalTokens
|
|
totals.RequestCount += b.RequestCount
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"usage": buckets,
|
|
"totals": totals,
|
|
})
|
|
}, adminMw)
|
|
|
|
// --- Invite management endpoints ---
|
|
|
|
// POST /api/auth/admin/invites - create invite (admin only)
|
|
e.POST("/api/auth/admin/invites", func(c echo.Context) error {
|
|
admin := auth.GetUser(c)
|
|
if admin == nil {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
|
}
|
|
|
|
var body struct {
|
|
ExpiresInHours int `json:"expiresInHours"`
|
|
}
|
|
_ = c.Bind(&body)
|
|
if body.ExpiresInHours <= 0 {
|
|
body.ExpiresInHours = 168 // 7 days default
|
|
}
|
|
|
|
codeBytes := make([]byte, 32)
|
|
if _, err := rand.Read(codeBytes); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate invite code"})
|
|
}
|
|
plaintext := hex.EncodeToString(codeBytes)
|
|
codeHash := auth.HashAPIKey(plaintext, appConfig.Auth.APIKeyHMACSecret)
|
|
|
|
invite := &auth.InviteCode{
|
|
ID: uuid.New().String(),
|
|
Code: codeHash,
|
|
CodePrefix: plaintext[:8],
|
|
CreatedBy: admin.ID,
|
|
ExpiresAt: time.Now().Add(time.Duration(body.ExpiresInHours) * time.Hour),
|
|
}
|
|
if err := db.Create(invite).Error; err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create invite"})
|
|
}
|
|
|
|
return c.JSON(http.StatusCreated, map[string]any{
|
|
"id": invite.ID,
|
|
"code": plaintext,
|
|
"expiresAt": invite.ExpiresAt,
|
|
"createdAt": invite.CreatedAt,
|
|
})
|
|
}, adminMw)
|
|
|
|
// GET /api/auth/admin/invites - list all invites (admin only)
|
|
e.GET("/api/auth/admin/invites", func(c echo.Context) error {
|
|
var invites []auth.InviteCode
|
|
if err := db.Preload("Creator").Preload("Consumer").Order("created_at DESC").Find(&invites).Error; err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to list invites"})
|
|
}
|
|
|
|
result := make([]map[string]any, 0, len(invites))
|
|
for _, inv := range invites {
|
|
entry := map[string]any{
|
|
"id": inv.ID,
|
|
"codePrefix": inv.CodePrefix,
|
|
"expiresAt": inv.ExpiresAt,
|
|
"createdAt": inv.CreatedAt,
|
|
"usedAt": inv.UsedAt,
|
|
"createdBy": map[string]any{
|
|
"id": inv.Creator.ID,
|
|
"name": inv.Creator.Name,
|
|
},
|
|
}
|
|
if inv.UsedBy != nil && inv.Consumer != nil {
|
|
entry["usedBy"] = map[string]any{
|
|
"id": inv.Consumer.ID,
|
|
"name": inv.Consumer.Name,
|
|
}
|
|
} else {
|
|
entry["usedBy"] = nil
|
|
}
|
|
result = append(result, entry)
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]any{"invites": result})
|
|
}, adminMw)
|
|
|
|
// DELETE /api/auth/admin/invites/:id - revoke unused invite (admin only)
|
|
e.DELETE("/api/auth/admin/invites/:id", func(c echo.Context) error {
|
|
inviteID := c.Param("id")
|
|
|
|
var invite auth.InviteCode
|
|
if err := db.First(&invite, "id = ?", inviteID).Error; err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "invite not found"})
|
|
}
|
|
if invite.UsedBy != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot revoke a used invite"})
|
|
}
|
|
|
|
db.Delete(&invite)
|
|
return c.JSON(http.StatusOK, map[string]string{"message": "invite revoked"})
|
|
}, adminMw)
|
|
|
|
// Note: GET /api/auth/invite/:code/check endpoint removed (#5) —
|
|
// invite codes are validated only during registration.
|
|
}
|
|
|
|
// isValidLegacyKey checks if the key matches any configured API key
|
|
// using constant-time comparison.
|
|
func isValidLegacyKey(token string, appConfig *config.ApplicationConfig) bool {
|
|
for _, validKey := range appConfig.ApiKeys {
|
|
if subtle.ConstantTimeCompare([]byte(token), []byte(validKey)) == 1 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|