8e4b863 answered a cookie-carried refresh in the body only and blacklisted the old jti, so an HttpOnly cookie (golem15.sociallogin token_cookie) kept a dead token. Now the cookie named auth.cookie.name gets the rotated token and the body is {}; bearer and auth_token refreshes are unchanged, and refusals write no cookie.
264 lines
10 KiB
Go
264 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; a
|
|
// /refresh through the token cookie (the auth.cookie default) gets that
|
|
// cookie back with the rotated token and an empty body, and one through
|
|
// auth_token gets the rotated token in the body and 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 next string
|
|
if name == "token" {
|
|
if rec.Body.String() != `{}` {
|
|
t.Fatalf("refresh with the token cookie answered %s", rec.Body.String())
|
|
}
|
|
next = assertSessionCookie(t, rec, e.cookieConfig(t), 20160*60)
|
|
} else {
|
|
var body struct {
|
|
Token string `json:"token"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
next = body.Token
|
|
if cookies := rec.Result().Cookies(); len(cookies) != 0 {
|
|
t.Fatalf("refresh set cookies %v", cookies)
|
|
}
|
|
}
|
|
if next == "" || next == token {
|
|
t.Fatalf("refresh answered %s", rec.Body.String())
|
|
}
|
|
if code := e.fetchCode(t, next); 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)
|
|
}
|