lib/api: Extract session store (#9425)
This is an extract from PR #9175, which can be reviewed in isolation to reduce the volume of changes to review all at once in #9175. There are about to be several services and API handlers that read and set cookies and session state, so this abstraction will prove helpful. In particular a motivating cause for this is that with the current architecture in PR #9175, in `api.go` the [`webauthnService` needs to access the session](https://github.com/syncthing/syncthing/pull/9175/files#diff-e2e14f22d818b8e635572ef0ee7718dee875c365e07225d760a6faae8be7772dR309-R310) for authentication purposes but needs to be instantiated before the `configMuxBuilder` for config purposes, because the WebAuthn additions to config management need to perform WebAuthn registration ceremonies, but currently the session management is embedded in the `basicAuthAndSessionMiddleware` which is [instantiated much later](https://github.com/syncthing/syncthing/pull/9175/files#diff-e2e14f22d818b8e635572ef0ee7718dee875c365e07225d760a6faae8be7772dL371-R380) and only if authentication is enabled in `guiCfg`. This refactorization extracts the session management out from `basicAuthAndSessionMiddleware` so that `basicAuthAndSessionMiddleware` and `webauthnService` can both use the same shared session management service to perform session management logic. ### Testing This is a refactorization intended to not change any externally observable behaviour, so existing tests (e.g., `api_auth_test.go`) should cover this where appropriate. I have manually verified that: - Appending `+ "foo"` to the cookie name in `createSession` causes `TestHtmlFormLogin/invalid_URL_returns_403_before_auth_and_404_after_auth` and `TestHtmlFormLogin/UTF-8_auth_works` to fail - Inverting the return value of `hasValidSession` cases a whole bunch of tests in `TestHTTPLogin` and `TestHtmlFormLogin` to fail - (Fixed) Changing the cookie to `MaxAge: 1000` in `destroySession` does NOT cause any tests to fail! - Added tests `TestHtmlFormLogin/Logout_removes_the_session_cookie`, `TestHTTPLogin/*/Logout_removes_the_session_cookie`, `TestHtmlFormLogin/Session_cookie_is_invalid_after_logout` and `TestHTTPLogin/200_path#01/Session_cookie_is_invalid_after_logout` to cover this. - Manually verified that these tests pass both before and after the changes in this PR, and that changing the cookie to `MaxAge: 1000` or not calling `m.tokens.Delete(cookie.Value)` in `destroySession` makes the respective pair of tests fail.
This commit is contained in:
+84
-3
@@ -498,6 +498,15 @@ func hasSessionCookie(cookies []*http.Cookie) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func hasDeleteSessionCookie(cookies []*http.Cookie) bool {
|
||||
for _, cookie := range cookies {
|
||||
if cookie.MaxAge < 0 && strings.HasPrefix(cookie.Name, "sessionid") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func httpGet(url string, basicAuthUsername string, basicAuthPassword string, xapikeyHeader string, authorizationBearer string, cookies []*http.Cookie, t *testing.T) *http.Response {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
for _, cookie := range cookies {
|
||||
@@ -527,7 +536,7 @@ func httpGet(url string, basicAuthUsername string, basicAuthPassword string, xap
|
||||
return resp
|
||||
}
|
||||
|
||||
func httpPost(url string, body map[string]string, t *testing.T) *http.Response {
|
||||
func httpPost(url string, body map[string]string, cookies []*http.Cookie, t *testing.T) *http.Response {
|
||||
bodyBytes, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -538,6 +547,10 @@ func httpPost(url string, body map[string]string, t *testing.T) *http.Response {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, cookie := range cookies {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -622,6 +635,43 @@ func TestHTTPLogin(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Logout removes the session cookie", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
resp := httpGetBasicAuth(url, "üser", "räksmörgås") // string literals in Go source code are in UTF-8
|
||||
if resp.StatusCode != expectedOkStatus {
|
||||
t.Errorf("Unexpected non-%d return code %d for authed request (UTF-8)", expectedOkStatus, resp.StatusCode)
|
||||
}
|
||||
if !hasSessionCookie(resp.Cookies()) {
|
||||
t.Errorf("Expected session cookie for authed request (UTF-8)")
|
||||
}
|
||||
logoutResp := httpPost(baseURL+"/rest/noauth/auth/logout", nil, resp.Cookies(), t)
|
||||
if !hasDeleteSessionCookie(logoutResp.Cookies()) {
|
||||
t.Errorf("Expected session cookie to be deleted for logout request")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Session cookie is invalid after logout", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
loginResp := httpGetBasicAuth(url, "üser", "räksmörgås") // string literals in Go source code are in UTF-8
|
||||
if loginResp.StatusCode != expectedOkStatus {
|
||||
t.Errorf("Unexpected non-%d return code %d for authed request (UTF-8)", expectedOkStatus, loginResp.StatusCode)
|
||||
}
|
||||
if !hasSessionCookie(loginResp.Cookies()) {
|
||||
t.Errorf("Expected session cookie for authed request (UTF-8)")
|
||||
}
|
||||
|
||||
resp := httpGet(url, "", "", "", "", loginResp.Cookies(), t)
|
||||
if resp.StatusCode != expectedOkStatus {
|
||||
t.Errorf("Unexpected non-%d return code %d for cookie-authed request (UTF-8)", expectedOkStatus, resp.StatusCode)
|
||||
}
|
||||
|
||||
httpPost(baseURL+"/rest/noauth/auth/logout", nil, loginResp.Cookies(), t)
|
||||
resp = httpGet(url, "", "", "", "", loginResp.Cookies(), t)
|
||||
if resp.StatusCode != expectedFailStatus {
|
||||
t.Errorf("Expected session to be invalid (status %d) after logout, got status: %d", expectedFailStatus, resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ISO-8859-1 auth works", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
resp := httpGetBasicAuth(url, "\xfcser", "r\xe4ksm\xf6rg\xe5s") // escaped ISO-8859-1
|
||||
@@ -708,7 +758,7 @@ func TestHtmlFormLogin(t *testing.T) {
|
||||
resourceUrl404 := baseURL + "/any-path/that/does/nooooooot/match-any/noauth-pattern"
|
||||
|
||||
performLogin := func(username string, password string) *http.Response {
|
||||
return httpPost(loginUrl, map[string]string{"username": username, "password": password}, t)
|
||||
return httpPost(loginUrl, map[string]string{"username": username, "password": password}, nil, t)
|
||||
}
|
||||
|
||||
performResourceRequest := func(url string, cookies []*http.Cookie) *http.Response {
|
||||
@@ -773,9 +823,40 @@ func TestHtmlFormLogin(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Logout removes the session cookie", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// JSON is always UTF-8, so ISO-8859-1 case is not applicable
|
||||
resp := performLogin("üser", "räksmörgås") // string literals in Go source code are in UTF-8
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("Unexpected non-204 return code %d for authed request (UTF-8)", resp.StatusCode)
|
||||
}
|
||||
logoutResp := httpPost(baseURL+"/rest/noauth/auth/logout", nil, resp.Cookies(), t)
|
||||
if !hasDeleteSessionCookie(logoutResp.Cookies()) {
|
||||
t.Errorf("Expected session cookie to be deleted for logout request")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Session cookie is invalid after logout", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// JSON is always UTF-8, so ISO-8859-1 case is not applicable
|
||||
loginResp := performLogin("üser", "räksmörgås") // string literals in Go source code are in UTF-8
|
||||
if loginResp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("Unexpected non-204 return code %d for authed request (UTF-8)", loginResp.StatusCode)
|
||||
}
|
||||
resp := performResourceRequest(resourceUrl, loginResp.Cookies())
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Unexpected non-200 return code %d for authed request (UTF-8)", resp.StatusCode)
|
||||
}
|
||||
httpPost(baseURL+"/rest/noauth/auth/logout", nil, loginResp.Cookies(), t)
|
||||
resp = performResourceRequest(resourceUrl, loginResp.Cookies())
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("Expected session to be invalid (status 403) after logout, got status: %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("form login is not applicable to other URLs", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
resp := httpPost(baseURL+"/meta.js", map[string]string{"username": "üser", "password": "räksmörgås"}, t)
|
||||
resp := httpPost(baseURL+"/meta.js", map[string]string{"username": "üser", "password": "räksmörgås"}, nil, t)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("Unexpected non-403 return code %d for incorrect form login URL", resp.StatusCode)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user