diff --git a/cabana/auth.go b/cabana/auth.go index 0bf3ecb..436f978 100644 --- a/cabana/auth.go +++ b/cabana/auth.go @@ -2,8 +2,11 @@ package cabana import ( "context" + "database/sql" "encoding/json" "errors" + "log/slog" + "net" "net/http" "strings" "time" @@ -14,6 +17,10 @@ import ( ) const ( + // backendJWTBlacklistTable matches the framework migration in lagoon. + // It is not the frontend jwt_blacklist table. + backendJWTBlacklistTable = "backend_jwt_blacklist" + msgInvalidCredentials = "Invalid credentials" msgUnauthenticated = "Unauthenticated" msgForbidden = "Forbidden" @@ -45,11 +52,15 @@ func (p BackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal } func principalFrom(user BackendUser) *bouncer.Principal { - return &bouncer.Principal{ + principal := &bouncer.Principal{ ID: user.ID, IsSuperuser: user.IsSuperuser, PermissionGrants: parseGrants(user.Role.Permissions), } + if user.TokensValidAfter != nil { + principal.TokensValidAfter = *user.TokensValidAfter + } + return principal } func parseGrants(raw string) map[string]bool { @@ -119,25 +130,180 @@ func (s *service) login(w http.ResponseWriter, r *http.Request) { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } - hash := user.Password - if !found { - hash = dummyPasswordHash + hash := dummyPasswordHash + if found && user.Password != "" { + hash = user.Password } - if !found || !user.IsActivated || !bouncer.CheckPassword(hash, body.Password) { + ok := bouncer.CheckPassword(hash, body.Password) + if !found || !ok || !user.IsActivated { + id := uint(0) + if found { + id = user.ID + } + s.logAuth(r, "failed", id) WriteError(w, http.StatusUnauthorized, "unauthenticated", msgInvalidCredentials) return } + now := time.Now().UTC() + if err := db.WithContext(r.Context()).Model(&BackendUser{}).Where("id = ?", user.ID).Update("last_login", now).Error; err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + if bouncer.NeedsRehash(user.Password, s.bcryptCost) { + if next, err := bouncer.HashPassword(s.bcryptCost, body.Password); err == nil { + _ = db.WithContext(r.Context()).Model(&BackendUser{}).Where("id = ?", user.ID).Update("password", next).Error + } + } token, _, err := bouncer.MintAudience(s.secret, uitoa(user.ID), s.issuer, s.ttl, bouncer.AudienceBackend) if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } + s.logAuth(r, "success", user.ID) WriteData(w, http.StatusOK, map[string]string{ "access_token": token, "token_type": "bearer", }, map[string]any{}) } +func (s *service) refresh(w http.ResponseWriter, r *http.Request) { + raw := bearerToken(r) + if raw == "" { + s.logAuth(r, "failed", 0) + WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated) + return + } + next, err := bouncer.RefreshAudience(s.secret, raw, bouncer.AudienceBackend, s.refreshTTL, s.bl, s.grace, s.issuer) + if err != nil { + s.logAuth(r, "failed", 0) + WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated) + return + } + s.logAuth(r, "success", 0) + WriteData(w, http.StatusOK, map[string]string{ + "access_token": next, + "token_type": "bearer", + }, map[string]any{}) +} + +func (s *service) logout(w http.ResponseWriter, r *http.Request) { + raw := bearerToken(r) + _, iat, exp, jti, err := bouncer.VerifyClaimsAudience(raw, s.secret, bouncer.AudienceBackend) + if err != nil || jti == "" { + WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated) + return + } + if s.bl != nil { + expiresAt := iat.Add(s.refreshTTL).Add(time.Minute) + if until := exp.Add(time.Minute); until.After(expiresAt) { + expiresAt = until + } + if err := s.bl.Add(r.Context(), jti, expiresAt, time.Now()); err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + } + id := uint(0) + if principal, ok := bouncer.User(r.Context()); ok { + id = principal.ID + } + s.logAuth(r, "success", id) + WriteData(w, http.StatusOK, map[string]any{"status": "logged_out"}, map[string]any{}) +} + +func (s *service) me(w http.ResponseWriter, r *http.Request) { + principal, ok := bouncer.User(r.Context()) + if !ok || principal == nil { + WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated) + return + } + db, err := s.db() + if err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + var user BackendUser + err = db.WithContext(r.Context()).Preload("Role").First(&user, principal.ID).Error + if errors.Is(err, gorm.ErrRecordNotFound) || (err == nil && !user.IsActivated) { + WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated) + return + } + if err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + WriteData(w, http.StatusOK, profileOf(user), map[string]any{}) +} + +func profileOf(user BackendUser) map[string]any { + data := map[string]any{ + "id": user.ID, + "login": user.Login, + "email": user.Email, + "first_name": user.FirstName, + "last_name": user.LastName, + "is_superuser": user.IsSuperuser, + } + if user.Role.ID != 0 { + data["role"] = map[string]any{ + "id": user.Role.ID, + "code": user.Role.Code, + "name": user.Role.Name, + } + } + return data +} + +func bearerToken(r *http.Request) string { + if r == nil { + return "" + } + value := strings.TrimSpace(r.Header.Get("Authorization")) + token, ok := strings.CutPrefix(value, "Bearer ") + if !ok { + return "" + } + return strings.TrimSpace(token) +} + +func (s *service) logAuth(r *http.Request, outcome string, adminID uint) { + remote := "" + method := "" + path := "" + if r != nil { + method = r.Method + if r.URL != nil { + path = r.URL.Path + } + remote = r.RemoteAddr + if host, _, err := net.SplitHostPort(remote); err == nil { + remote = host + } + } + args := []any{"outcome", outcome, "method", method, "path", path, "remote", remote} + if adminID != 0 { + args = append(args, "admin_id", adminID) + } + slog.Default().Info("admin.auth", args...) +} + +func adminBlacklist(app *backpack.App) bouncer.BlacklistStore { + var sqlDB *sql.DB + if app != nil { + if db, ok := app.Lookup[*sql.DB](); ok { + sqlDB = db + } else if gdb, ok := app.Lookup[*gorm.DB](); ok && gdb != nil { + if db, err := gdb.DB(); err == nil { + sqlDB = db + } + } + } + if sqlDB == nil { + return nil + } + return bouncer.NewPostgresBlacklist(sqlDB, backendJWTBlacklistTable) +} + func findBackendLogin(db *gorm.DB, identifier string) (BackendUser, bool, error) { email := strings.ToLower(identifier) var user BackendUser @@ -181,6 +347,49 @@ func adminTTL(app *backpack.App) time.Duration { return time.Duration(minutes) * time.Minute } +func adminRefreshTTL(app *backpack.App) time.Duration { + minutes := 20160 + if app != nil && app.Config != nil && app.Config.Int("admin.jwt.refresh_ttl") > 0 { + minutes = app.Config.Int("admin.jwt.refresh_ttl") + } + return time.Duration(minutes) * time.Minute +} + +func adminGrace(app *backpack.App) time.Duration { + seconds := 0 + if app != nil && app.Config != nil && app.Config.Has("admin.jwt.blacklist_grace") { + seconds = app.Config.Int("admin.jwt.blacklist_grace") + } + if seconds < 0 { + seconds = 0 + } + return time.Duration(seconds) * time.Second +} + +func adminBcryptCost(app *backpack.App) int { + cost := 10 + if app != nil && app.Config != nil && app.Config.Int("admin.password.bcrypt_cost") > 0 { + cost = app.Config.Int("admin.password.bcrypt_cost") + } + if cost < 4 || cost > 31 { + return 10 + } + return cost +} + +func adminLoginWindow(app *backpack.App) (int, int) { + maxAttempts, decayMinutes := 5, 1 + if app != nil && app.Config != nil { + if n := app.Config.Int("admin.login.max_attempts"); n > 0 { + maxAttempts = n + } + if n := app.Config.Int("admin.login.decay_minutes"); n > 0 { + decayMinutes = n + } + } + return maxAttempts, decayMinutes +} + func adminIssuer(app *backpack.App) string { base := "" if app != nil && app.Config != nil { diff --git a/cabana/http.go b/cabana/http.go index 06da993..800a883 100644 --- a/cabana/http.go +++ b/cabana/http.go @@ -3,6 +3,7 @@ package cabana import ( "context" "errors" + "fmt" "net/http" "reflect" "strconv" @@ -23,11 +24,17 @@ type Routes struct { } type service struct { - app *backpack.App - reg *Registry - secret string - ttl time.Duration - issuer string + app *backpack.App + reg *Registry + secret string + ttl time.Duration + refreshTTL time.Duration + grace time.Duration + bcryptCost int + loginMax int + loginDecay int + issuer string + bl bouncer.BlacklistStore } // Activate compiles admin controllers and, when any exist, requires @@ -58,10 +65,7 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) { return nil, err } } - var bl bouncer.BlacklistStore - if store, ok := app.Lookup[bouncer.BlacklistStore](); ok { - bl = store - } + bl := adminBlacklist(app) guard := bouncer.NewBackendJWTGuard(secret, lazyBackendUsers{app: app}, bl, writeUnauthenticated) if _, err := guards.Middleware("backend"); err != nil { if err := guards.Register("summercms.cabana", "backend", guard); err != nil { @@ -72,12 +76,19 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) { if err != nil { return nil, err } + loginMax, loginDecay := adminLoginWindow(app) svc := &service{ - app: app, - reg: reg, - secret: secret, - ttl: adminTTL(app), - issuer: adminIssuer(app), + app: app, + reg: reg, + secret: secret, + ttl: adminTTL(app), + refreshTTL: adminRefreshTTL(app), + grace: adminGrace(app), + bcryptCost: adminBcryptCost(app), + loginMax: loginMax, + loginDecay: loginDecay, + issuer: adminIssuer(app), + bl: bl, } return &Routes{Middleware: mw, Mount: svc.mount}, nil } @@ -102,10 +113,14 @@ func (p lazyBackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Princ } func (s *service) mount(r pact.Router) { + throttle := fmt.Sprintf("throttle:%d,%d", s.loginMax, s.loginDecay) r.GroupRaw("/_admin/api/v1/auth", nil, func(g pact.Router) { - g.Post("/login", s.login) + g.Post("/login", s.login, throttle) + g.Post("/refresh", s.refresh) }) r.GroupRaw("/_admin/api/v1", []string{"backend"}, func(g pact.Router) { + g.Post("/auth/logout", s.logout) + g.Get("/auth/me", s.me) g.Get("/{vendor}/{plugin}/{controller}/schema/list", s.listSchema) constrainController(g) g.Get("/{vendor}/{plugin}/{controller}", s.list) @@ -182,6 +197,7 @@ func (s *service) protect(w http.ResponseWriter, r *http.Request, fn func(*Compi return } if !Allows(principal, requiredOf(cc.Controller)) { + s.logAuth(r, "denied", principal.ID) WriteError(w, http.StatusForbidden, "forbidden", msgForbidden) return }