Sign in as a user instead of with an API key
The web UI signs in with a username and password and holds a session cookie; the TUI was the only client still needing an API key pasted into a config file. It now asks for the same credentials on a form at start. What is kept between runs is the session token, not the password, in session.json under the config directory, mode 0600 and keyed by server URL so one server's token is never offered to another. It resumes on the next start; the server's sessions last 30 days and slide with use. L signs out, which ends the session on the server and deletes the saved one even if the server cannot be reached. The client attaches the cookie by hand instead of using a cookie jar: the server marks it Secure behind https, and a jar drops a Secure cookie it is given over plain http, which would break a local server for no reason. It sends no Authorization header at all, since the server judges a request carrying one on that alone and never falls back to the cookie. Writes go through the server's cross-origin guard, which lets a client that sends neither Origin nor Sec-Fetch-Site through; checked against a real v0.20.1 server for both reads and writes. A 401 from anything means the session is gone (expired, ended from the web UI, or the account disabled), so the TUI returns to the form with the reason, forgets the saved token, and drops what the last session loaded rather than showing it to whoever signs in next. A 403 is a permission and leaves the session alone. The refresh timer is started once, so signing out and in does not leave two running. An account with no password cannot sign in, and the server answers it exactly like a wrong password, so the form's message says a password must be set first. Users created only for API access hit this. Breaking: api_key in config.yaml is no longer used. It is not an error to leave it there; the form says it is ignored. API keys still exist on the server and k in Users still manages them.
This commit is contained in:
+92
-12
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -11,29 +12,98 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionCookie is the cookie terdut-server's web UI signs in with.
|
||||
const SessionCookie = "terdut_session"
|
||||
|
||||
// Client talks to terdut-server as the user who signed in. Login trades a
|
||||
// username and password for a session, the same one the web UI holds, and every
|
||||
// request after it carries that session's cookie.
|
||||
//
|
||||
// The cookie is attached by hand rather than through a cookie jar: the server
|
||||
// marks it Secure behind https, and a jar drops a Secure cookie it is handed
|
||||
// over plain http, which would make a local server unusable for no reason. There
|
||||
// is nothing else a jar would do here — the token is opaque and does not change
|
||||
// while the session lives.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
apiKey string
|
||||
session string
|
||||
}
|
||||
|
||||
func NewClient(baseURL, apiKey string) *Client {
|
||||
func NewClient(baseURL string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
apiKey: apiKey,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SetSession resumes a session from a token saved earlier.
|
||||
func (c *Client) SetSession(token string) { c.session = token }
|
||||
|
||||
// HasSession reports whether there is a session to try. It says nothing about
|
||||
// whether the server still honours it.
|
||||
func (c *Client) HasSession() bool { return c.session != "" }
|
||||
|
||||
// Login signs in and returns the session token, which the client also keeps and
|
||||
// sends from then on. The server answers a wrong password, an unknown user and
|
||||
// an account with no password all with the same 401, so the caller cannot tell
|
||||
// them apart. Too many failures come back as 429.
|
||||
func (c *Client) Login(username, password string) (string, error) {
|
||||
body := struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}{Username: username, Password: password}
|
||||
req, err := c.newRequestWithBody(http.MethodPost, "/api/login", body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// A stale session must not ride along on the request that replaces it.
|
||||
req.Header.Del("Cookie")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", statusError(resp)
|
||||
}
|
||||
for _, ck := range resp.Cookies() {
|
||||
if ck.Name == SessionCookie && ck.Value != "" {
|
||||
c.session = ck.Value
|
||||
return ck.Value, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("server signed us in but sent no %s cookie", SessionCookie)
|
||||
}
|
||||
|
||||
// Logout ends the session on the server and forgets it here.
|
||||
func (c *Client) Logout() error {
|
||||
req, err := c.newRequest(http.MethodPost, "/api/logout")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = c.do(req, nil)
|
||||
c.session = ""
|
||||
return err
|
||||
}
|
||||
|
||||
// authorize puts the session on a request.
|
||||
func (c *Client) authorize(req *http.Request) {
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if c.session != "" {
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: c.session})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) newRequest(method, path string) (*http.Request, error) {
|
||||
req, err := http.NewRequest(method, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
c.authorize(req)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
@@ -51,6 +121,21 @@ func (e *StatusError) Error() string {
|
||||
return fmt.Sprintf("server returned %d", e.Code)
|
||||
}
|
||||
|
||||
// IsUnauthorized reports whether err is the server refusing the session: it
|
||||
// expired, was ended elsewhere, or belongs to an account since disabled.
|
||||
func IsUnauthorized(err error) bool {
|
||||
var se *StatusError
|
||||
return errors.As(err, &se) && se.Code == http.StatusUnauthorized
|
||||
}
|
||||
|
||||
func statusError(resp *http.Response) error {
|
||||
var e struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&e)
|
||||
return &StatusError{Code: resp.StatusCode, Message: e.Error}
|
||||
}
|
||||
|
||||
func (c *Client) do(req *http.Request, out any) error {
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -59,11 +144,7 @@ func (c *Client) do(req *http.Request, out any) error {
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
var e struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&e)
|
||||
return &StatusError{Code: resp.StatusCode, Message: e.Error}
|
||||
return statusError(resp)
|
||||
}
|
||||
|
||||
if out != nil {
|
||||
@@ -125,8 +206,7 @@ func (c *Client) newRequestWithBody(method, path string, body any) (*http.Reques
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
c.authorize(req)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
@@ -19,7 +19,9 @@ type call struct {
|
||||
path string
|
||||
query string
|
||||
body string
|
||||
auth string
|
||||
cookie string
|
||||
// authz is the Authorization header, which the client no longer sends at all.
|
||||
authz string
|
||||
}
|
||||
|
||||
// stub serves one canned response and records the request that fetched it.
|
||||
@@ -29,22 +31,107 @@ func stub(t *testing.T, status int, response string) (*Client, *call) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
got.method, got.path, got.query = r.Method, r.URL.Path, r.URL.RawQuery
|
||||
got.body, got.auth = string(body), r.Header.Get("Authorization")
|
||||
got.body, got.authz = string(body), r.Header.Get("Authorization")
|
||||
if ck, err := r.Cookie(SessionCookie); err == nil {
|
||||
got.cookie = ck.Value
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
io.WriteString(w, response)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return NewClient(srv.URL, "test-key"), got
|
||||
c := NewClient(srv.URL)
|
||||
c.SetSession("test-session")
|
||||
return c, got
|
||||
}
|
||||
|
||||
func TestClient_SendsBearerToken(t *testing.T) {
|
||||
func TestClient_SendsTheSessionCookie(t *testing.T) {
|
||||
c, got := stub(t, http.StatusOK, `[]`)
|
||||
if _, err := c.ListIncidents(0, "", false, false, 0); err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if got.auth != "Bearer test-key" {
|
||||
t.Errorf("expected bearer token, got %q", got.auth)
|
||||
if got.cookie != "test-session" {
|
||||
t.Errorf("expected the session cookie, got %q", got.cookie)
|
||||
}
|
||||
// The server judges a request with an Authorization header on that alone and
|
||||
// never falls back to the cookie, so sending one would defeat the session.
|
||||
if got.authz != "" {
|
||||
t.Errorf("expected no Authorization header, got %q", got.authz)
|
||||
}
|
||||
}
|
||||
|
||||
// Login has to work over plain http, where a cookie jar would discard the
|
||||
// Secure cookie a server behind https sets.
|
||||
func TestLogin_KeepsTheSessionFromTheCookie(t *testing.T) {
|
||||
var body string
|
||||
var sentCookie bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
body = string(b)
|
||||
_, err := r.Cookie(SessionCookie)
|
||||
sentCookie = err == nil
|
||||
http.SetCookie(w, &http.Cookie{Name: SessionCookie, Value: "fresh", Path: "/", HttpOnly: true, Secure: true})
|
||||
w.WriteHeader(http.StatusOK)
|
||||
io.WriteString(w, `{"user":{"id":1},"has_password":true}`)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
c := NewClient(srv.URL)
|
||||
c.SetSession("stale")
|
||||
token, err := c.Login("niklas", "correct horse")
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
if token != "fresh" || !c.HasSession() {
|
||||
t.Errorf("expected the new token to be kept, got %q", token)
|
||||
}
|
||||
if body != `{"username":"niklas","password":"correct horse"}` {
|
||||
t.Errorf("unexpected body %q", body)
|
||||
}
|
||||
if sentCookie {
|
||||
t.Error("a stale session must not ride along on the login that replaces it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogin_RefusalCarriesTheServersWords(t *testing.T) {
|
||||
c, _ := stub(t, http.StatusUnauthorized, `{"error":"invalid username or password"}`)
|
||||
_, err := c.Login("niklas", "wrong")
|
||||
if !IsUnauthorized(err) || !strings.Contains(err.Error(), "invalid username or password") {
|
||||
t.Errorf("expected the server's 401 message, got %v", err)
|
||||
}
|
||||
|
||||
c, _ = stub(t, http.StatusTooManyRequests, `{"error":"too many attempts"}`)
|
||||
if _, err := c.Login("niklas", "wrong"); err == nil || IsUnauthorized(err) {
|
||||
t.Errorf("a rate limit is not an authentication failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogin_NoCookieIsAnError(t *testing.T) {
|
||||
c, _ := stub(t, http.StatusOK, `{}`)
|
||||
if _, err := c.Login("niklas", "pw"); err == nil {
|
||||
t.Error("a 200 without a session cookie is not a sign-in")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogout_ForgetsTheSession(t *testing.T) {
|
||||
c, got := stub(t, http.StatusNoContent, ``)
|
||||
if err := c.Logout(); err != nil {
|
||||
t.Fatalf("logout: %v", err)
|
||||
}
|
||||
if got.method != "POST" || got.path != "/api/logout" || got.cookie != "test-session" {
|
||||
t.Errorf("unexpected request %s %s cookie=%q", got.method, got.path, got.cookie)
|
||||
}
|
||||
if c.HasSession() {
|
||||
t.Error("the session should be gone locally")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsUnauthorized(t *testing.T) {
|
||||
if !IsUnauthorized(&StatusError{Code: 401}) {
|
||||
t.Error("a 401 is unauthorized")
|
||||
}
|
||||
if IsUnauthorized(&StatusError{Code: 403}) || IsUnauthorized(errors.New("x")) || IsUnauthorized(nil) {
|
||||
t.Error("only a 401 means the session is refused; a 403 is a permission")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user