feat(09-02): implement backend JWT lifecycle, throttle, and auth logs
- Refresh, logout, and me use a separate PostgreSQL jti blacklist and safe profile - Login stamps last_login only after a successful check and throttles repeated attempts
This commit is contained in:
219
cabana/auth.go
219
cabana/auth.go
@@ -2,8 +2,11 @@ package cabana
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -14,6 +17,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
// backendJWTBlacklistTable matches the framework migration in lagoon.
|
||||||
|
// It is not the frontend jwt_blacklist table.
|
||||||
|
backendJWTBlacklistTable = "backend_jwt_blacklist"
|
||||||
|
|
||||||
msgInvalidCredentials = "Invalid credentials"
|
msgInvalidCredentials = "Invalid credentials"
|
||||||
msgUnauthenticated = "Unauthenticated"
|
msgUnauthenticated = "Unauthenticated"
|
||||||
msgForbidden = "Forbidden"
|
msgForbidden = "Forbidden"
|
||||||
@@ -45,11 +52,15 @@ func (p BackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal
|
|||||||
}
|
}
|
||||||
|
|
||||||
func principalFrom(user BackendUser) *bouncer.Principal {
|
func principalFrom(user BackendUser) *bouncer.Principal {
|
||||||
return &bouncer.Principal{
|
principal := &bouncer.Principal{
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
IsSuperuser: user.IsSuperuser,
|
IsSuperuser: user.IsSuperuser,
|
||||||
PermissionGrants: parseGrants(user.Role.Permissions),
|
PermissionGrants: parseGrants(user.Role.Permissions),
|
||||||
}
|
}
|
||||||
|
if user.TokensValidAfter != nil {
|
||||||
|
principal.TokensValidAfter = *user.TokensValidAfter
|
||||||
|
}
|
||||||
|
return principal
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseGrants(raw string) map[string]bool {
|
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)
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hash := user.Password
|
hash := dummyPasswordHash
|
||||||
if !found {
|
if found && user.Password != "" {
|
||||||
hash = dummyPasswordHash
|
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)
|
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgInvalidCredentials)
|
||||||
return
|
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)
|
token, _, err := bouncer.MintAudience(s.secret, uitoa(user.ID), s.issuer, s.ttl, bouncer.AudienceBackend)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.logAuth(r, "success", user.ID)
|
||||||
WriteData(w, http.StatusOK, map[string]string{
|
WriteData(w, http.StatusOK, map[string]string{
|
||||||
"access_token": token,
|
"access_token": token,
|
||||||
"token_type": "bearer",
|
"token_type": "bearer",
|
||||||
}, map[string]any{})
|
}, 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) {
|
func findBackendLogin(db *gorm.DB, identifier string) (BackendUser, bool, error) {
|
||||||
email := strings.ToLower(identifier)
|
email := strings.ToLower(identifier)
|
||||||
var user BackendUser
|
var user BackendUser
|
||||||
@@ -181,6 +347,49 @@ func adminTTL(app *backpack.App) time.Duration {
|
|||||||
return time.Duration(minutes) * time.Minute
|
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 {
|
func adminIssuer(app *backpack.App) string {
|
||||||
base := ""
|
base := ""
|
||||||
if app != nil && app.Config != nil {
|
if app != nil && app.Config != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package cabana
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -23,11 +24,17 @@ type Routes struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type service struct {
|
type service struct {
|
||||||
app *backpack.App
|
app *backpack.App
|
||||||
reg *Registry
|
reg *Registry
|
||||||
secret string
|
secret string
|
||||||
ttl time.Duration
|
ttl time.Duration
|
||||||
issuer string
|
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
|
// 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
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var bl bouncer.BlacklistStore
|
bl := adminBlacklist(app)
|
||||||
if store, ok := app.Lookup[bouncer.BlacklistStore](); ok {
|
|
||||||
bl = store
|
|
||||||
}
|
|
||||||
guard := bouncer.NewBackendJWTGuard(secret, lazyBackendUsers{app: app}, bl, writeUnauthenticated)
|
guard := bouncer.NewBackendJWTGuard(secret, lazyBackendUsers{app: app}, bl, writeUnauthenticated)
|
||||||
if _, err := guards.Middleware("backend"); err != nil {
|
if _, err := guards.Middleware("backend"); err != nil {
|
||||||
if err := guards.Register("summercms.cabana", "backend", guard); 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
loginMax, loginDecay := adminLoginWindow(app)
|
||||||
svc := &service{
|
svc := &service{
|
||||||
app: app,
|
app: app,
|
||||||
reg: reg,
|
reg: reg,
|
||||||
secret: secret,
|
secret: secret,
|
||||||
ttl: adminTTL(app),
|
ttl: adminTTL(app),
|
||||||
issuer: adminIssuer(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
|
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) {
|
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) {
|
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) {
|
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)
|
g.Get("/{vendor}/{plugin}/{controller}/schema/list", s.listSchema)
|
||||||
constrainController(g)
|
constrainController(g)
|
||||||
g.Get("/{vendor}/{plugin}/{controller}", s.list)
|
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
|
return
|
||||||
}
|
}
|
||||||
if !Allows(principal, requiredOf(cc.Controller)) {
|
if !Allows(principal, requiredOf(cc.Controller)) {
|
||||||
|
s.logAuth(r, "denied", principal.ID)
|
||||||
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user