Files
summercms/modules/cabana/auth.go
Jakub Zych 044e0450ef feat(12.2-02): add the fileupload field with deferred uploads committed on save
- type: fileupload compiles the D-08 keys and binds to the model's attach.Relation at boot
- X-Session-Key (cabana.SessionKeyHeader) carries the form session key; RecordInput.SessionKey
- GET and POST .../{id}/files/{field}: list with pending uploads, multipart upload into attach.Store
- the create and update save attaches the session's pending files in its transaction
- swagger2openapi folds formData parameters into a multipart requestBody
- admin OpenAPI, TS types, conformance cases, README and forms docs
2026-10-02 18:04:34 +02:00

568 lines
17 KiB
Go

package cabana
import (
"context"
"database/sql"
"encoding/json"
"errors"
"log/slog"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/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"
msgPayloadTooLarge = "Payload too large"
)
// 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
}
// The administrator's own permissions are applied last, over the role's
// and the code-declared role grants, as Winter's getMergedPermissions does.
principal.PermissionGrants = applyUserPermissions(principal.PermissionGrants, user.Permissions)
return principal, nil
}
// applyUserPermissions overlays an administrator's own backend_users.permissions
// onto the grants that come from the role, the way Winter's
// User::getMergedPermissions does: the user's value for a code replaces the
// role's, and only a value of 1 grants. A user-level -1 (or 0) therefore removes
// a permission the role grants, and a user-level 1 adds one the role does not.
// Winter compares codes exactly while merging, so a deny of one code never
// removes a wildcard grant such as "acme.*"; it removes that exact code.
func applyUserPermissions(grants map[string]bool, raw string) map[string]bool {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "{}" || raw == "null" {
return grants
}
var decoded map[string]any
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
return grants
}
for code, value := range decoded {
if truthyGrant(value) {
if grants == nil {
grants = map[string]bool{}
}
grants[code] = true
continue
}
delete(grants, code)
}
if len(grants) == 0 {
return nil
}
return grants
}
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 := s.missingUserHash()
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.
//
// Logout is mounted outside the backend guard, which rejects an expired access
// token before any handler runs. The token is verified here instead, with
// exp unchecked, so a token whose access lifetime has passed but whose refresh
// window is still open (and which /auth/refresh would still accept) can be
// revoked. The cookie is expired on every outcome, including a refusal.
func (s *service) logout(w http.ResponseWriter, r *http.Request) {
s.expireSessionCookie(w)
raw, _ := sessionToken(r)
sub, iat, exp, jti, err := bouncer.VerifyRefreshableClaimsAudience(raw, s.secret, bouncer.AudienceBackend, s.refreshTTL)
if err != nil {
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 n, err := strconv.ParseUint(sub, 10, 64); err == nil {
id = uint(n)
}
s.logAuth(r, "success", id)
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)
}
// findBackendLogin resolves a login identifier (a login or an email) to one
// administrator. An identifier that matches two rows, such as one admin's login
// equal to another's email, resolves to nobody: it is reported as not found, so
// the caller answers it like any wrong credential instead of letting the lowest
// id win and lock the other admin out.
func findBackendLogin(db *gorm.DB, identifier string) (BackendUser, bool, error) {
email := strings.ToLower(identifier)
var users []BackendUser
err := db.Preload("Role").Where("login = ? OR lower(email) = ?", identifier, email).Order("id").Limit(2).Find(&users).Error
if err != nil {
return BackendUser{}, false, err
}
if len(users) != 1 {
return BackendUser{}, false, nil
}
return users[0], 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"
}
// missingHashes caches the unknown-login hash per bcrypt cost.
var missingHashes sync.Map
// missingUserHash is the hash a login for an unknown (or ambiguous) identifier
// is checked against, so it costs the same bcrypt work as a real admin's. It is
// built once per cost at the service's configured cost, the cost stored hashes
// are rehashed to on login.
func (s *service) missingUserHash() string {
cost := s.bcryptCost
if cached, ok := missingHashes.Load(cost); ok {
return cached.(string)
}
hash, err := bouncer.HashPassword(cost, "cabana-invalid-credentials")
if err != nil {
// An unusable cost: fall back to the default, still on the bcrypt path.
hash, _ = bouncer.HashPassword(10, "cabana-invalid-credentials")
}
actual, _ := missingHashes.LoadOrStore(cost, hash)
return actual.(string)
}