feat(10-01): harden the admin cookie session and prefix boot guards
- refresh and logout read the Bearer header first, then the summer_admin cookie; a cookie refresh rotates the cookie without a token in the body and logout always expires the cookie - backend.cookie_secure (default true) may drop Secure outside production only - activation rejects controller vendor segments api, assets, login, settings - BuildRouter rejects non-cabana routes at or under the admin prefix - SPA single-flights refresh on 401, replays once, and refreshes proactively at 80 percent of expires_in; dist rebuilt - scripts/check-admin-dist.sh rebuilds the SPA and fails on dist drift - tests: TestPhase10CookieAuth, TestPhase10CSRF, TestPhase10Prefix, TestPhase10AdminPrefixCollision, boardwalk serving and header tests
This commit is contained in:
@@ -194,19 +194,28 @@ func cookieLoginData(ttl time.Duration) AdminLoginData {
|
||||
// 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, &http.Cookie{
|
||||
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: token,
|
||||
Value: value,
|
||||
Path: s.adminPrefix(),
|
||||
MaxAge: int(s.refreshTTL / time.Second),
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
Secure: !s.insecureCookie,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) refresh(w http.ResponseWriter, r *http.Request) {
|
||||
raw := bearerToken(r)
|
||||
raw, fromCookie := sessionToken(r)
|
||||
if raw == "" {
|
||||
s.logAuth(r, "failed", 0)
|
||||
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
||||
@@ -219,14 +228,22 @@ func (s *service) refresh(w http.ResponseWriter, r *http.Request) {
|
||||
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 := bearerToken(r)
|
||||
raw, _ := sessionToken(r)
|
||||
_, iat, exp, jti, err := bouncer.VerifyClaimsAudience(raw, s.secret, bouncer.AudienceBackend)
|
||||
if err != nil || jti == "" {
|
||||
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
||||
@@ -247,9 +264,27 @@ func (s *service) logout(w http.ResponseWriter, r *http.Request) {
|
||||
id = principal.ID
|
||||
}
|
||||
s.logAuth(r, "success", id)
|
||||
s.expireSessionCookie(w)
|
||||
WriteData(w, http.StatusOK, map[string]any{"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 {
|
||||
@@ -425,6 +460,22 @@ func adminLoginWindow(app *backpack.App) (int, int) {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user