fix: logout ends cookie-carried sessions, clears the session cookies and keeps the token revoked for its refresh window
POST /logout read the token from the Authorization header only, while the jwt guard also accepts the token and auth_token cookies. A session carried by a cookie (golem15.sociallogin's token_cookie sets an httpOnly one that the browser cannot turn into a bearer header) was answered 401, nothing was revoked and the cookie kept authenticating jwt.auth routes until the access token expired (golem15.user.jwt.ttl, 60 minutes). Logout never cleared a cookie either, and it and EndRequestSession kept the jwt_blacklist row only until the access token's exp, so once the sweep removed the row POST /refresh revived the logged-out token for the rest of its refresh window. classes.EndSession, which Logout now runs, revokes every session token the request carries (the bearer header, the token and auth_token cookies) that verifies, an expired one too while it could still be refreshed, until the later of exp and iat plus refresh_ttl, plus a minute, as bouncer.Refresh and the admin logout do. The answer, a 401 included, expires the auth.cookie token cookie and any other session cookie the request carries, with that cookie's path, domain, SameSite and Secure. EndRequestSession is its best-effort form. Response bodies are unchanged; an expired but still refreshable token now logs out with 200 instead of 401.
This commit is contained in:
297
logout_test.go
Normal file
297
logout_test.go
Normal file
@@ -0,0 +1,297 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
loggedOutBody = `{"message":"Logged out"}`
|
||||
unauthorizedRaw = `{"error":true,"message":"Unauthorized"}`
|
||||
blacklistedBody = `{"error":"Could not refresh token","msg":"The token has been blacklisted"}`
|
||||
userTokensPath = "/_user/api/v1/tokens"
|
||||
userLogoutPath = "/_user/api/v1/logout"
|
||||
userRefreshPath = "/_user/api/v1/refresh"
|
||||
)
|
||||
|
||||
// TestLogoutEndsTheSession: POST /logout ends the session however the token
|
||||
// travels (the bearer header, or only the token or auth_token cookie the
|
||||
// jwt guard also reads), so the next request with the same token or cookie
|
||||
// is 401 on the jwt.auth routes, at /fetch and at /refresh. It clears the
|
||||
// session cookies with the attributes the token cookie is written with, and
|
||||
// the revocation lasts for the token's refresh window: a logged-out token is
|
||||
// not refreshable after its access lifetime, and an expired but still
|
||||
// refreshable token is revoked by a logout too.
|
||||
func TestLogoutEndsTheSession(t *testing.T) {
|
||||
e := newAdminEnv(t)
|
||||
|
||||
t.Run("bearer", func(t *testing.T) {
|
||||
token := e.sessionLogin(t, "logout-bearer")
|
||||
if code := e.guarded(t, token, ""); code != http.StatusOK {
|
||||
t.Fatalf("jwt.auth before logout = %d", code)
|
||||
}
|
||||
rec := e.call(t, http.MethodPost, userLogoutPath, token, nil)
|
||||
assertLoggedOut(t, rec)
|
||||
e.assertSessionEnded(t, token)
|
||||
})
|
||||
|
||||
for _, name := range []string{"token", "auth_token"} {
|
||||
t.Run("cookie "+name, func(t *testing.T) {
|
||||
token := e.sessionLogin(t, "logout-"+name)
|
||||
if code := e.guarded(t, token, name); code != http.StatusOK {
|
||||
t.Fatalf("jwt.auth with the %s cookie before logout = %d", name, code)
|
||||
}
|
||||
rec := e.send(t, http.MethodPost, userLogoutPath, "", nil, "application/json", map[string]string{"Cookie": name + "=" + token})
|
||||
assertLoggedOut(t, rec)
|
||||
e.assertSessionEnded(t, token)
|
||||
want := e.cookieConfig(t)
|
||||
assertForgets(t, rec, want, want.Name)
|
||||
if name != want.Name {
|
||||
assertForgets(t, rec, want, name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("bearer logout forgets the token cookie too", func(t *testing.T) {
|
||||
token := e.sessionLogin(t, "logout-bearer-cookie")
|
||||
rec := e.call(t, http.MethodPost, userLogoutPath, token, nil)
|
||||
assertLoggedOut(t, rec)
|
||||
want := e.cookieConfig(t)
|
||||
assertForgets(t, rec, want, want.Name)
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == "auth_token" {
|
||||
t.Fatalf("a request without the auth_token cookie got a forget cookie for it: %s", c)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refused token still forgets the cookie", func(t *testing.T) {
|
||||
rec := e.send(t, http.MethodPost, userLogoutPath, "", nil, "application/json", map[string]string{"Cookie": "token=not-a-token"})
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthorizedRaw {
|
||||
t.Fatalf("logout with a bad cookie = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertForgets(t, rec, e.cookieConfig(t), "token")
|
||||
missing := e.call(t, http.MethodPost, userLogoutPath, "", nil)
|
||||
if missing.Code != http.StatusUnauthorized || missing.Body.String() != unauthorizedRaw {
|
||||
t.Fatalf("logout without a token = %d %s", missing.Code, missing.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("revocation outlives the access token", func(t *testing.T) {
|
||||
token := e.sessionLogin(t, "logout-window")
|
||||
claims := parseToken(t, token)
|
||||
jti, _ := claims["jti"].(string)
|
||||
iat, _ := claims.GetIssuedAt()
|
||||
exp, _ := claims.GetExpirationTime()
|
||||
assertLoggedOut(t, e.call(t, http.MethodPost, userLogoutPath, token, nil))
|
||||
var expiresAt time.Time
|
||||
if err := e.gdb.Raw(`SELECT expires_at FROM jwt_blacklist WHERE jti = ?`, jti).Scan(&expiresAt).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The later of exp and iat plus the refresh window, plus a minute.
|
||||
want := iat.Add(controllers.RefreshTTL(e.app)).Add(time.Minute)
|
||||
if d := expiresAt.Sub(want); d < -time.Second || d > time.Second {
|
||||
t.Fatalf("blacklist row expires at %v, want %v (refresh window plus a minute)", expiresAt, want)
|
||||
}
|
||||
// The sweep that runs once the access token has expired: the same
|
||||
// predicate as bouncer.PostgresBlacklist.Sweep, limited to this jti
|
||||
// so other tests' rows stay.
|
||||
if err := e.gdb.Exec(`DELETE FROM jwt_blacklist WHERE jti = ? AND expires_at < ?`, jti, exp.Add(2*time.Minute)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := e.refresh(t, token)
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != blacklistedBody {
|
||||
t.Fatalf("refresh of a logged-out token after its access lifetime = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("expired but refreshable token", func(t *testing.T) {
|
||||
user := e.seedUser(t, "logout-expired", nil)
|
||||
iat := time.Now().Add(-2 * time.Hour)
|
||||
token := userAudienceToken(t, strconv.FormatUint(uint64(user.ID), 10), "logout-expired-"+adminStamp(), iat, iat.Add(time.Hour))
|
||||
rec := e.call(t, http.MethodPost, userLogoutPath, token, nil)
|
||||
assertLoggedOut(t, rec)
|
||||
if refreshed := e.refresh(t, token); refreshed.Code != http.StatusUnauthorized || refreshed.Body.String() != blacklistedBody {
|
||||
t.Fatalf("refresh after logging out an expired token = %d %s", refreshed.Code, refreshed.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("every token the request carries", func(t *testing.T) {
|
||||
bearerToken := e.sessionLogin(t, "logout-both-bearer")
|
||||
cookieToken := e.sessionLogin(t, "logout-both-cookie")
|
||||
rec := e.send(t, http.MethodPost, userLogoutPath, bearerToken, nil, "application/json", map[string]string{"Cookie": "token=" + cookieToken})
|
||||
assertLoggedOut(t, rec)
|
||||
e.assertSessionEnded(t, bearerToken)
|
||||
e.assertSessionEnded(t, cookieToken)
|
||||
})
|
||||
|
||||
t.Run("refused tokens", func(t *testing.T) {
|
||||
user := e.seedUser(t, "logout-refused", nil)
|
||||
sub := strconv.FormatUint(uint64(user.ID), 10)
|
||||
closedAt := time.Now().Add(-controllers.RefreshTTL(e.app) - time.Hour)
|
||||
for name, token := range map[string]string{
|
||||
// The refresh window closed: nothing left to revoke.
|
||||
"closed window": userAudienceToken(t, sub, "logout-closed-"+adminStamp(), closedAt, closedAt.Add(time.Hour)),
|
||||
// No audience (a PHP token) is accepted only while unexpired.
|
||||
"expired without audience": craftToken(t, sub, "logout-legacy-old-"+adminStamp(), time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour)),
|
||||
// No jti: it cannot be blacklisted.
|
||||
"no jti": userAudienceToken(t, sub, "", time.Now(), time.Now().Add(time.Hour)),
|
||||
// A user that does not exist.
|
||||
"unknown user": userAudienceToken(t, "999999999", "logout-ghost-"+adminStamp(), time.Now(), time.Now().Add(time.Hour)),
|
||||
} {
|
||||
rec := e.call(t, http.MethodPost, userLogoutPath, token, nil)
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthorizedRaw {
|
||||
t.Fatalf("%s: logout = %d %s", name, rec.Code, rec.Body.String())
|
||||
}
|
||||
assertForgets(t, rec, e.cookieConfig(t), "token")
|
||||
}
|
||||
legacy := craftToken(t, sub, "logout-legacy-"+adminStamp(), time.Now().Add(-time.Minute), time.Now().Add(time.Hour))
|
||||
if code := e.guarded(t, legacy, ""); code != http.StatusOK {
|
||||
t.Fatalf("jwt.auth with an unexpired token without audience = %d", code)
|
||||
}
|
||||
assertLoggedOut(t, e.call(t, http.MethodPost, userLogoutPath, legacy, nil))
|
||||
if code := e.guarded(t, legacy, ""); code != http.StatusUnauthorized {
|
||||
t.Fatalf("jwt.auth after logging out a token without audience = %d", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("configured cookie attributes and refresh window", func(t *testing.T) {
|
||||
custom := newAdminEnv(t, func(c *compass.Config) {
|
||||
for key, value := range map[string]any{
|
||||
"auth.cookie.domain": "example.com",
|
||||
"auth.cookie.same_site": "strict",
|
||||
"auth.cookie.path": "/",
|
||||
"golem15.user.jwt.refresh_ttl": 120,
|
||||
} {
|
||||
if err := c.Set(key, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
token := custom.sessionLogin(t, "logout-custom")
|
||||
rec := custom.send(t, http.MethodPost, userLogoutPath, "", nil, "application/json", map[string]string{"Cookie": "token=" + token})
|
||||
assertLoggedOut(t, rec)
|
||||
want := custom.cookieConfig(t)
|
||||
if want.Domain != "example.com" || want.SameSite != http.SameSiteStrictMode {
|
||||
t.Fatalf("config not applied: %+v", want)
|
||||
}
|
||||
assertForgets(t, rec, want, "token")
|
||||
custom.assertSessionEnded(t, token)
|
||||
// Logout keeps the row for the window /refresh uses.
|
||||
claims := parseToken(t, token)
|
||||
jti, _ := claims["jti"].(string)
|
||||
iat, _ := claims.GetIssuedAt()
|
||||
var expiresAt time.Time
|
||||
if err := custom.gdb.Raw(`SELECT expires_at FROM jwt_blacklist WHERE jti = ?`, jti).Scan(&expiresAt).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if window := controllers.RefreshTTL(custom.app); window != 120*time.Minute {
|
||||
t.Fatalf("refresh window = %v", window)
|
||||
}
|
||||
if d := expiresAt.Sub(iat.Add(121 * time.Minute)); d < -time.Second || d > time.Second {
|
||||
t.Fatalf("blacklist row expires at %v, want iat plus 121 minutes", expiresAt)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// sessionLogin seeds a frontend user, signs in through POST /login and
|
||||
// returns the session token.
|
||||
func (e *adminEnv) sessionLogin(t *testing.T, label string) string {
|
||||
t.Helper()
|
||||
user := e.seedUser(t, label, nil)
|
||||
code, token := e.userLogin(t, user.Email, "correct-horse")
|
||||
if code != http.StatusOK || token == "" {
|
||||
t.Fatalf("login %s = %d", label, code)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// guarded calls GET /tokens, a jwt.auth route, with token as the bearer
|
||||
// header (cookie "") or only in the named cookie, and returns the status.
|
||||
func (e *adminEnv) guarded(t *testing.T, token, cookie string) int {
|
||||
t.Helper()
|
||||
if cookie == "" {
|
||||
return e.call(t, http.MethodGet, userTokensPath, token, nil).Code
|
||||
}
|
||||
return e.send(t, http.MethodGet, userTokensPath, "", nil, "application/json", map[string]string{"Cookie": cookie + "=" + token}).Code
|
||||
}
|
||||
|
||||
// assertSessionEnded: token no longer authenticates, through any carrier,
|
||||
// and cannot be refreshed.
|
||||
func (e *adminEnv) assertSessionEnded(t *testing.T, token string) {
|
||||
t.Helper()
|
||||
for _, carrier := range []string{"", "token", "auth_token"} {
|
||||
if code := e.guarded(t, token, carrier); code != http.StatusUnauthorized {
|
||||
t.Fatalf("jwt.auth after logout (cookie %q) = %d", carrier, code)
|
||||
}
|
||||
}
|
||||
if code := e.fetchCode(t, token); code != http.StatusUnauthorized {
|
||||
t.Fatalf("fetch after logout = %d", code)
|
||||
}
|
||||
if rec := e.refresh(t, token); rec.Code != http.StatusUnauthorized || rec.Body.String() != blacklistedBody {
|
||||
t.Fatalf("refresh after logout = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func (e *adminEnv) cookieConfig(t *testing.T) bouncer.CookieConfig {
|
||||
t.Helper()
|
||||
cfg, err := bouncer.LoadCookieConfig(e.app.Config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func assertLoggedOut(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != loggedOutBody {
|
||||
t.Fatalf("logout = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// assertForgets: the response deletes the cookie name with the path,
|
||||
// domain, SameSite, Secure and HttpOnly of the token cookie cfg writes.
|
||||
func assertForgets(t *testing.T, rec *httptest.ResponseRecorder, cfg bouncer.CookieConfig, name string) {
|
||||
t.Helper()
|
||||
set := cfg.TokenCookie("x", time.Hour)
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name != name {
|
||||
continue
|
||||
}
|
||||
if c.Value != "" || c.MaxAge >= 0 || c.Path != set.Path || c.Domain != set.Domain ||
|
||||
c.SameSite != set.SameSite || c.Secure != set.Secure || c.HttpOnly != set.HttpOnly {
|
||||
t.Fatalf("forget cookie %s does not match the token cookie %s", c, set)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("no forget cookie for %s in %v", name, rec.Result().Header.Values("Set-Cookie"))
|
||||
}
|
||||
|
||||
// userAudienceToken signs a session token the way bouncer.Mint does (user
|
||||
// audience, prv), with a chosen iat and exp.
|
||||
func userAudienceToken(t *testing.T, sub, jti string, iat, exp time.Time) string {
|
||||
t.Helper()
|
||||
claims := jwt.MapClaims{
|
||||
"sub": sub,
|
||||
"aud": []string{bouncer.AudienceUser},
|
||||
"iss": "http://example.com/_user/api/v1/login",
|
||||
"iat": iat.Unix(),
|
||||
"nbf": iat.Unix(),
|
||||
"exp": exp.Unix(),
|
||||
"jti": jti,
|
||||
"prv": prvHash,
|
||||
}
|
||||
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(sessionSecret))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return signed
|
||||
}
|
||||
Reference in New Issue
Block a user