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) }