Fixes review finding CR-01 (quick 260927-q23): POST {prefix}/api/v1/auth/refresh
minted a new token without loading the admin, so a session kept alive by the
SPA's refresh-on-401 survived admin:reset-password, deactivation and deletion.
This broke Phase 9 truth T-09-04.
- bouncer: extract the JWT guard's subject lookup into subjectPrincipal and
issuedBeforeCutoff (same order and messages), add ErrSubjectRejected
- bouncer: add RefreshAudienceFor, which runs the guard's subject checks
after the token-only checks and before minting; Refresh and
RefreshAudience are unchanged (nil hook)
- cabana: share one lazyBackendUsers provider between the backend guard and
refresh; a cookie refresh refused for its subject expires summer_admin
- test: TestAdminRefreshRevocation (Postgres, real admin:reset-password)
507 lines
14 KiB
Go
507 lines
14 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/backpack"
|
|
"git.golem15.com/golem15/summercms/bouncer"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
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"
|
|
msgNotFound = "Not found"
|
|
msgServerError = "Server error"
|
|
)
|
|
|
|
// BackendUsers loads activated backend principals. It never reads frontend users.
|
|
type BackendUsers struct {
|
|
DB *gorm.DB
|
|
Registry *Registry
|
|
}
|
|
|
|
func (p BackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) {
|
|
if p.DB == nil || id == 0 {
|
|
return nil, nil
|
|
}
|
|
var user BackendUser
|
|
err := p.DB.WithContext(ctx).Preload("Role").First(&user, id).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !user.IsActivated {
|
|
return nil, nil
|
|
}
|
|
principal := principalFrom(user)
|
|
for code, allowed := range p.Registry.rolePermissions(user.Role.Code) {
|
|
if !allowed {
|
|
continue
|
|
}
|
|
if principal.PermissionGrants == nil {
|
|
principal.PermissionGrants = map[string]bool{}
|
|
}
|
|
principal.PermissionGrants[code] = true
|
|
}
|
|
return principal, nil
|
|
}
|
|
|
|
func principalFrom(user BackendUser) *bouncer.Principal {
|
|
principal := &bouncer.Principal{
|
|
ID: user.ID,
|
|
Backend: true,
|
|
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 {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" || raw == "{}" || raw == "null" {
|
|
return nil
|
|
}
|
|
var decoded map[string]any
|
|
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
|
|
return nil
|
|
}
|
|
out := make(map[string]bool, len(decoded))
|
|
for code, value := range decoded {
|
|
if truthyGrant(value) {
|
|
out[code] = true
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func truthyGrant(value any) bool {
|
|
switch v := value.(type) {
|
|
case bool:
|
|
return v
|
|
case float64:
|
|
return v == 1
|
|
case string:
|
|
return v == "1" || strings.EqualFold(v, "true")
|
|
case json.Number:
|
|
return v.String() == "1"
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
type loginBody struct {
|
|
Login string `json:"login"`
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
func (s *service) login(w http.ResponseWriter, r *http.Request) {
|
|
var body loginBody
|
|
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
|
|
if err := dec.Decode(&body); err != nil {
|
|
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgInvalidCredentials)
|
|
return
|
|
}
|
|
identifier := strings.TrimSpace(body.Login)
|
|
if identifier == "" {
|
|
identifier = strings.TrimSpace(body.Email)
|
|
}
|
|
if identifier == "" || body.Password == "" {
|
|
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgInvalidCredentials)
|
|
return
|
|
}
|
|
db, err := s.db()
|
|
if err != nil {
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
user, found, err := findBackendLogin(db.WithContext(r.Context()), identifier)
|
|
if err != nil {
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
hash := dummyPasswordHash
|
|
if found && user.Password != "" {
|
|
hash = user.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)
|
|
if isAjax(r) {
|
|
// Cookie transport (D-19): the SPA never sees the token.
|
|
s.writeSessionCookie(w, token)
|
|
WriteData(w, http.StatusOK, cookieLoginData(s.ttl), map[string]any{})
|
|
return
|
|
}
|
|
WriteData(w, http.StatusOK, map[string]string{
|
|
"access_token": token,
|
|
"token_type": "bearer",
|
|
}, map[string]any{})
|
|
}
|
|
|
|
// cookieLoginData is the login/refresh body under cookie transport: no token,
|
|
// only its type and the access lifetime in seconds.
|
|
func cookieLoginData(ttl time.Duration) AdminLoginData {
|
|
return AdminLoginData{TokenType: "cookie", ExpiresIn: int(ttl / time.Second)}
|
|
}
|
|
|
|
// writeSessionCookie sets the admin JWT cookie scoped to the admin prefix.
|
|
// Max-Age is the refresh window, because refresh accepts an expired access
|
|
// token until iat plus refresh_ttl.
|
|
func (s *service) writeSessionCookie(w http.ResponseWriter, token string) {
|
|
http.SetCookie(w, s.sessionCookie(token, int(s.refreshTTL/time.Second)))
|
|
}
|
|
|
|
// expireSessionCookie tells the browser to drop the admin cookie.
|
|
func (s *service) expireSessionCookie(w http.ResponseWriter) {
|
|
http.SetCookie(w, s.sessionCookie("", -1))
|
|
}
|
|
|
|
func (s *service) sessionCookie(value string, maxAge int) *http.Cookie {
|
|
return &http.Cookie{
|
|
Name: AdminCookieName,
|
|
Value: value,
|
|
Path: s.adminPrefix(),
|
|
MaxAge: maxAge,
|
|
HttpOnly: true,
|
|
Secure: !s.insecureCookie,
|
|
SameSite: http.SameSiteStrictMode,
|
|
}
|
|
}
|
|
|
|
func (s *service) refresh(w http.ResponseWriter, r *http.Request) {
|
|
raw, fromCookie := sessionToken(r)
|
|
if raw == "" {
|
|
s.logAuth(r, "failed", 0)
|
|
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
|
return
|
|
}
|
|
next, err := bouncer.RefreshAudienceFor(r.Context(), s.users, s.secret, raw, bouncer.AudienceBackend, s.refreshTTL, s.bl, s.grace, s.issuer)
|
|
if err != nil {
|
|
// A subject the guard would refuse (deactivated, deleted, or cut off
|
|
// by tokens_valid_after) ends the browser session. Other failures,
|
|
// including a provider error, leave the cookie alone.
|
|
if fromCookie && errors.Is(err, bouncer.ErrSubjectRejected) {
|
|
s.expireSessionCookie(w)
|
|
}
|
|
s.logAuth(r, "failed", 0)
|
|
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
|
return
|
|
}
|
|
s.logAuth(r, "success", 0)
|
|
if fromCookie {
|
|
// A cookie-authenticated request never receives a token in its body.
|
|
s.writeSessionCookie(w, next)
|
|
WriteData(w, http.StatusOK, cookieLoginData(s.ttl), map[string]any{})
|
|
return
|
|
}
|
|
WriteData(w, http.StatusOK, map[string]string{
|
|
"access_token": next,
|
|
"token_type": "bearer",
|
|
}, map[string]any{})
|
|
}
|
|
|
|
// logout blacklists the presented token's jti and always expires the admin
|
|
// cookie, so a browser session ends even when only the Bearer was revoked.
|
|
func (s *service) logout(w http.ResponseWriter, r *http.Request) {
|
|
raw, _ := sessionToken(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)
|
|
s.expireSessionCookie(w)
|
|
WriteData(w, http.StatusOK, AdminLogoutData{Status: "logged_out"}, map[string]any{})
|
|
}
|
|
|
|
// sessionToken returns the admin JWT the same way the backend guard reads it:
|
|
// the Authorization Bearer header first, then the summer_admin cookie.
|
|
func sessionToken(r *http.Request) (token string, fromCookie bool) {
|
|
if raw := bearerToken(r); raw != "" {
|
|
return raw, false
|
|
}
|
|
if r == nil {
|
|
return "", false
|
|
}
|
|
if c, err := r.Cookie(AdminCookieName); err == nil {
|
|
if raw := strings.TrimSpace(c.Value); raw != "" {
|
|
return raw, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
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) AdminProfile {
|
|
profile := AdminProfile{
|
|
ID: user.ID,
|
|
Login: user.Login,
|
|
Email: user.Email,
|
|
FirstName: user.FirstName,
|
|
LastName: user.LastName,
|
|
IsSuperuser: user.IsSuperuser,
|
|
}
|
|
if user.Role.ID != 0 {
|
|
profile.Role = &AdminRoleSummary{ID: user.Role.ID, Code: user.Role.Code, Name: user.Role.Name}
|
|
}
|
|
return profile
|
|
}
|
|
|
|
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
|
|
err := db.Preload("Role").Where("login = ? OR lower(email) = ?", identifier, email).First(&user).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return BackendUser{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return BackendUser{}, false, err
|
|
}
|
|
return user, true, nil
|
|
}
|
|
|
|
func (s *service) db() (*gorm.DB, error) {
|
|
if s == nil || s.app == nil {
|
|
return nil, errors.New("cabana: database is not configured")
|
|
}
|
|
db, ok := s.app.Lookup[*gorm.DB]()
|
|
if !ok || db == nil {
|
|
return nil, errors.New("cabana: database is not configured")
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
func adminSecret(app *backpack.App) (string, error) {
|
|
secret := ""
|
|
if app != nil && app.Config != nil {
|
|
secret = strings.TrimSpace(app.Config.String("admin.jwt.secret"))
|
|
}
|
|
if secret == "" {
|
|
return "", errors.New("cabana: admin.jwt.secret is empty (set SUMMER_ADMIN__JWT__SECRET)")
|
|
}
|
|
return secret, nil
|
|
}
|
|
|
|
func adminTTL(app *backpack.App) time.Duration {
|
|
minutes := 60
|
|
if app != nil && app.Config != nil && app.Config.Int("admin.jwt.ttl") > 0 {
|
|
minutes = app.Config.Int("admin.jwt.ttl")
|
|
}
|
|
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
|
|
}
|
|
|
|
// adminCookieSecure reads backend.cookie_secure (default true). false drops
|
|
// the Secure attribute for plain-http development and is refused in the
|
|
// production environment.
|
|
func adminCookieSecure(app *backpack.App) (bool, error) {
|
|
if app == nil || app.Config == nil || !app.Config.Has("backend.cookie_secure") {
|
|
return true, nil
|
|
}
|
|
if app.Config.Bool("backend.cookie_secure") {
|
|
return true, nil
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(app.Config.Environment()), "production") {
|
|
return false, errors.New("cabana: backend.cookie_secure: false is not allowed in the production environment")
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// adminIssuer is app.url plus the admin API login path. JWT verification does
|
|
// not check iss, so tokens minted under an earlier prefix stay valid until
|
|
// they expire.
|
|
func adminIssuer(app *backpack.App, prefix string) string {
|
|
base := ""
|
|
if app != nil && app.Config != nil {
|
|
base = strings.TrimRight(strings.TrimSpace(app.Config.String("app.url")), "/")
|
|
}
|
|
if prefix == "" {
|
|
prefix = DefaultAdminPrefix
|
|
}
|
|
return base + prefix + adminAPIVersion + "/auth/login"
|
|
}
|
|
|
|
// dummyPasswordHash keeps a missing-user login on the bcrypt path.
|
|
var dummyPasswordHash = func() string {
|
|
hash, err := bouncer.HashPassword(10, "cabana-invalid-credentials")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return hash
|
|
}()
|