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.
298 lines
12 KiB
Go
298 lines
12 KiB
Go
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
|
|
}
|