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" ) // 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 := 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) }