Files
sm-user-plugin/session_cookie_test.go
Jakub Zych 8e4b863a3f fix: /fetch, /refresh, /update and /change-password accept the session cookies the jwt guard reads
The four routes (and the other signed-in routes through authenticate) read the bearer header, then the token and auth_token cookies, through classes.SessionToken and one cookie-aware guard.
A bearer header still wins and no credentials stays 401 with the old bodies.
A cookie-carried /refresh answers the rotated token in the body and writes no cookie.
2026-10-22 21:45:52 +02:00

254 lines
10 KiB
Go

package user
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/models"
)
const (
userFetchPath = "/_user/api/v1/fetch"
userUpdatePath = "/_user/api/v1/update"
userChangePasswordPath = "/_user/api/v1/change-password"
userConsentPath = "/_user/api/v1/marketing-consent"
tokenNotFoundBody = `{"error":"Token not found"}`
)
// sessionCookieNames are the cookies the plugin's jwt guard reads after the
// Authorization header.
var sessionCookieNames = []string{"token", "auth_token"}
// TestSessionRoutesAcceptCookies: GET /fetch, POST /refresh, POST /update and
// POST /change-password (and the other signed-in routes behind the shared
// authenticate) read the session token as the jwt guard does: the bearer
// header first, then the token and auth_token cookies. A cookie-only session
// that jwt.auth accepts is served; no credentials stays 401 with the old
// bodies; a bearer header wins over a cookie exactly as on jwt.auth; a
// cookie-carried token is refused for the same reasons as a bearer one; and a
// cookie-carried /refresh answers the new token in the body and sets no
// cookie.
func TestSessionRoutesAcceptCookies(t *testing.T) {
e := newAdminEnv(t)
for _, name := range sessionCookieNames {
t.Run("fetch via the "+name+" cookie", func(t *testing.T) {
token := e.sessionLogin(t, "cookie-fetch-"+name)
if code := e.guarded(t, token, name); code != http.StatusOK {
t.Fatalf("jwt.auth with the %s cookie = %d", name, code)
}
rec := e.cookieSend(t, http.MethodGet, userFetchPath, name, token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("fetch with the %s cookie = %d %s", name, rec.Code, rec.Body.String())
}
if got, want := fetchedID(t, rec), parseToken(t, token)["sub"]; got != want {
t.Fatalf("fetch served user %q, token names %v", got, want)
}
})
}
t.Run("fetch without credentials", func(t *testing.T) {
rec := e.send(t, http.MethodGet, userFetchPath, "", nil, "application/json", nil)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthorizedRaw {
t.Fatalf("fetch without credentials = %d %s", rec.Code, rec.Body.String())
}
})
t.Run("bearer wins", func(t *testing.T) {
a := e.sessionLogin(t, "cookie-bearer-a")
b := e.sessionLogin(t, "cookie-bearer-b")
cookie := map[string]string{"Cookie": "token=" + b}
rec := e.send(t, http.MethodGet, userFetchPath, a, nil, "application/json", cookie)
if rec.Code != http.StatusOK {
t.Fatalf("fetch with bearer A and cookie B = %d %s", rec.Code, rec.Body.String())
}
if got, want := fetchedID(t, rec), parseToken(t, a)["sub"]; got != want {
t.Fatalf("fetch served user %q, bearer names %v", got, want)
}
rec = e.send(t, http.MethodGet, userFetchPath, "not-a-token", nil, "application/json", cookie)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("fetch with an invalid bearer and a valid cookie = %d", rec.Code)
}
rec = e.send(t, http.MethodGet, userTokensPath, "not-a-token", nil, "application/json", cookie)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("jwt.auth with an invalid bearer and a valid cookie = %d", rec.Code)
}
})
t.Run("fetch refuses a cut-off cookie token", func(t *testing.T) {
user := e.seedUser(t, "cookie-cutoff", nil)
e.cutOff(t, user)
token := userAudienceToken(t, userSub(user), "cookie-cutoff-"+adminStamp(), time.Now().Add(-10*time.Second), time.Now().Add(time.Hour))
if rec := e.cookieSend(t, http.MethodGet, userFetchPath, "auth_token", token, nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("fetch with a cut-off auth_token cookie = %d", rec.Code)
}
})
for _, name := range sessionCookieNames {
t.Run("refresh via the "+name+" cookie", func(t *testing.T) {
token := e.sessionLogin(t, "cookie-refresh-"+name)
if code := e.guarded(t, token, name); code != http.StatusOK {
t.Fatalf("jwt.auth with the %s cookie = %d", name, code)
}
rec := e.cookieSend(t, http.MethodPost, userRefreshPath, name, token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("refresh with the %s cookie = %d %s", name, rec.Code, rec.Body.String())
}
var body struct {
Token string `json:"token"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Token == "" || body.Token == token {
t.Fatalf("refresh answered %s", rec.Body.String())
}
if cookies := rec.Result().Cookies(); len(cookies) != 0 {
t.Fatalf("refresh set cookies %v", cookies)
}
if code := e.fetchCode(t, body.Token); code != http.StatusOK {
t.Fatalf("fetch with the refreshed token = %d", code)
}
if rec := e.cookieSend(t, http.MethodGet, userFetchPath, name, token, nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("fetch with the refreshed-away token in the %s cookie = %d", name, rec.Code)
}
})
}
t.Run("refresh refuses a banned user through the cookie", func(t *testing.T) {
user := e.seedUser(t, "cookie-refresh-banned", nil)
e.seedThrottle(t, user, "", true, nil)
token := userAudienceToken(t, userSub(user), "cookie-banned-"+adminStamp(), time.Now(), time.Now().Add(time.Hour))
assertRefreshRefused(t, e.cookieSend(t, http.MethodPost, userRefreshPath, "token", token, nil))
})
t.Run("refresh refuses a cut-off token through the cookie", func(t *testing.T) {
user := e.seedUser(t, "cookie-refresh-cutoff", nil)
e.cutOff(t, user)
token := userAudienceToken(t, userSub(user), "cookie-refresh-cutoff-"+adminStamp(), time.Now().Add(-10*time.Second), time.Now().Add(time.Hour))
assertRefreshRefused(t, e.cookieSend(t, http.MethodPost, userRefreshPath, "token", token, nil))
})
t.Run("refresh without credentials", func(t *testing.T) {
rec := e.send(t, http.MethodPost, userRefreshPath, "", nil, "application/json", nil)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != tokenNotFoundBody {
t.Fatalf("refresh without credentials = %d %s", rec.Code, rec.Body.String())
}
})
for _, name := range sessionCookieNames {
t.Run("update via the "+name+" cookie", func(t *testing.T) {
user := e.seedUser(t, "cookie-update-"+name, nil)
token := e.login(t, user, "correct-horse")
if code := e.guarded(t, token, name); code != http.StatusOK {
t.Fatalf("jwt.auth with the %s cookie = %d", name, code)
}
rec := e.cookieSend(t, http.MethodPost, userUpdatePath, name, token, map[string]any{
"name": "Cookie", "surname": "Carrier", "email": user.Email,
})
if rec.Code != http.StatusOK {
t.Fatalf("update with the %s cookie = %d %s", name, rec.Code, rec.Body.String())
}
if got := e.reload(t, user.ID).Name; got == nil || *got != "Cookie" {
t.Fatalf("name after update = %v", got)
}
})
}
for _, name := range sessionCookieNames {
t.Run("change-password via the "+name+" cookie", func(t *testing.T) {
user := e.seedUser(t, "cookie-password-"+name, func(u *models.User) { u.HasSelfSetPassword = true })
token := e.login(t, user, "correct-horse")
if code := e.guarded(t, token, name); code != http.StatusOK {
t.Fatalf("jwt.auth with the %s cookie = %d", name, code)
}
rec := e.cookieSend(t, http.MethodPost, userChangePasswordPath, name, token, map[string]any{
"current_password": "correct-horse",
"password": "cookie-next-pass",
"password_confirmation": "cookie-next-pass",
})
if rec.Code != http.StatusOK {
t.Fatalf("change-password with the %s cookie = %d %s", name, rec.Code, rec.Body.String())
}
if rec := e.cookieSend(t, http.MethodGet, userFetchPath, name, token, nil); rec.Code != http.StatusOK {
t.Fatalf("fetch with the calling token after change-password = %d", rec.Code)
}
if code, _ := e.userLogin(t, user.Email, "cookie-next-pass"); code != http.StatusOK {
t.Fatalf("login with the new password = %d", code)
}
})
}
t.Run("marketing-consent via the token cookie", func(t *testing.T) {
token := e.sessionLogin(t, "cookie-consent")
rec := e.cookieSend(t, http.MethodPost, userConsentPath, "token", token, map[string]any{"marketing_consent": true})
if rec.Code != http.StatusOK {
t.Fatalf("marketing-consent with the token cookie = %d %s", rec.Code, rec.Body.String())
}
})
for _, path := range []string{userUpdatePath, userChangePasswordPath} {
t.Run(path+" without credentials", func(t *testing.T) {
rec := e.send(t, http.MethodPost, path, "", []byte(`{}`), "application/json", nil)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthorizedRaw {
t.Fatalf("%s without credentials = %d %s", path, rec.Code, rec.Body.String())
}
})
}
}
// cookieSend sends one request with no bearer header and the session token
// only in the named cookie. body is JSON-encoded unless it is nil.
func (e *adminEnv) cookieSend(t *testing.T, method, path, cookie, token string, body any) *httptest.ResponseRecorder {
t.Helper()
var raw []byte
if body != nil {
var err error
if raw, err = json.Marshal(body); err != nil {
t.Fatal(err)
}
}
return e.send(t, method, path, "", raw, "application/json", map[string]string{"Cookie": cookie + "=" + token})
}
// login signs user in through POST /login and returns the session token.
func (e *adminEnv) login(t *testing.T, user *models.User, password string) string {
t.Helper()
code, token := e.userLogin(t, user.Email, password)
if code != http.StatusOK || token == "" {
t.Fatalf("login %s = %d", user.Email, code)
}
return token
}
// cutOff sets the user's tokens_valid_after to now, truncated to the second.
func (e *adminEnv) cutOff(t *testing.T, user *models.User) {
t.Helper()
if err := e.gdb.Exec(`UPDATE users SET tokens_valid_after = ? WHERE id = ?`, time.Now().Truncate(time.Second), user.ID).Error; err != nil {
t.Fatal(err)
}
}
func userSub(user *models.User) string {
return strconv.FormatUint(uint64(user.ID), 10)
}
// fetchedID returns user.id of a /fetch answer as a decimal string.
func fetchedID(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
var body struct {
User struct {
ID json.Number `json:"id"`
} `json:"user"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("fetch body %s: %v", rec.Body.String(), err)
}
return fmt.Sprint(body.User.ID)
}