diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 4de31af..4f3843b 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -120,6 +120,19 @@ paths: properties: email: { type: string, format: email } password: { type: string } + client: + type: string + enum: [web, app] + description: | + `app` for the iOS app. Its session comes back as + `session_token` instead of a cookie and slides — every use + moves its end forward, up to `VERDANDE_APP_SESSION_TTL` + (180 days) unused. Left out, it is a browser login. + device_name: + type: string + maxLength: 100 + description: Required with `client: app`; shown in the list of sessions. + example: Kristians iPhone responses: "200": description: | @@ -132,7 +145,14 @@ paths: properties: user: { $ref: "#/components/schemas/User" } totp_required: { type: boolean } + session_token: + type: string + description: | + An app login only, also while `totp_required` — the app + sends it to `/auth/login/totp` too. Sent from then on as + `Authorization: Bearer`. A browser never receives it. "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Validation" } "429": { $ref: "#/components/responses/RateLimited" } /api/v1/auth/login/totp: @@ -4439,6 +4459,10 @@ components: A personal API token, `vrd_…`. Carries its owner's permissions exactly — there is no separate service identity — and is refused on `/tokens`. + Or the iOS app's session, the `session_token` from an app login. That + one is a session and reaches everything a browser session does. A + browser's own session is never accepted here. + responses: BadRequest: description: The body could not be read. @@ -4973,6 +4997,12 @@ components: it so anything the summary gets wrong is still visible. example: Chrome på macOS user_agent: { type: string } + client: + type: string + enum: [web, app] + description: | + `app` for the iOS app, whose `device` is the name it gave itself and + whose `expires_at` moves forward each time it is used. ip: { type: string } created_at: { type: string, format: date-time } last_seen_at: { type: string, format: date-time } diff --git a/internal/config/config.go b/internal/config/config.go index f269895..c1e146c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,8 +24,12 @@ type Config struct { DataDir string SessionTTL time.Duration - InviteTTL time.Duration - ResetTTL time.Duration + // AppSessionTTL is how long a session from the iOS app may sit unused before + // it ends. It slides: every use moves the end forward, so a phone in daily use + // stays signed in and one left in a drawer does not. + AppSessionTTL time.Duration + InviteTTL time.Duration + ResetTTL time.Duration SMTP SMTP @@ -166,6 +170,9 @@ func Load() (*Config, error) { if c.SessionTTL, err = envDuration("VERDANDE_SESSION_TTL", 30*24*time.Hour); err != nil { return nil, err } + if c.AppSessionTTL, err = envDuration("VERDANDE_APP_SESSION_TTL", 180*24*time.Hour); err != nil { + return nil, err + } if c.InviteTTL, err = envDuration("VERDANDE_INVITE_TTL", 7*24*time.Hour); err != nil { return nil, err } diff --git a/internal/httpapi/app_session_test.go b/internal/httpapi/app_session_test.go new file mode 100644 index 0000000..457f677 --- /dev/null +++ b/internal/httpapi/app_session_test.go @@ -0,0 +1,269 @@ +package httpapi + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/url" + "testing" + "time" +) + +// The iOS app loads its interface from itself — capacitor://localhost — so it +// never shares an origin with the server and never gets a cookie back. It signs +// in like a browser does and carries the same kind of session, as a bearer token. + +// appDo is a request the way the app makes it: no cookie jar, the token in a +// header, and the browser engine reporting the request as cross-site, because to +// WKWebView it is. +func (ts *testServer) appDo(t *testing.T, token, method, path string, body any) (*http.Response, map[string]any) { + t.Helper() + var reader io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + reader = bytes.NewReader(raw) + } + req, err := http.NewRequest(method, ts.URL+path, reader) + if err != nil { + t.Fatal(err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + req.Header.Set("Origin", "capacitor://localhost") + req.Header.Set("Sec-Fetch-Site", "cross-site") + + resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + t.Cleanup(func() { resp.Body.Close() }) + var decoded map[string]any + raw, _ := io.ReadAll(resp.Body) + if len(raw) > 0 { + _ = json.Unmarshal(raw, &decoded) + } + return resp, decoded +} + +func appLogin(t *testing.T, ts *testServer) (*http.Response, map[string]any) { + t.Helper() + return ts.appDo(t, "", "POST", "/api/v1/auth/login", map[string]string{ + "email": "kristian@example.dk", "password": "et langt kodeord", + "client": "app", "device_name": "Kristians iPhone", + }) +} + +func TestAppLoginReturnsATokenAndNoCookie(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + resp, body := appLogin(t, ts) + if resp.StatusCode != http.StatusOK { + t.Fatalf("app login: %d %v", resp.StatusCode, body) + } + if len(resp.Cookies()) != 0 { + t.Errorf("an app login set a cookie: %v", resp.Cookies()) + } + token, _ := body["session_token"].(string) + if token == "" { + t.Fatalf("no session_token in %v", body) + } + if body["user"] == nil { + t.Error("no user in the response") + } + + // The token is the whole credential, from a foreign origin, reads and writes. + resp, me := ts.appDo(t, token, "GET", "/api/v1/auth/me", nil) + if resp.StatusCode != http.StatusOK || me["email"] != "kristian@example.dk" { + t.Fatalf("me with the app token: %d %v", resp.StatusCode, me) + } + resp, body = ts.appDo(t, token, "POST", "/api/v1/projects", map[string]string{"name": "Fra telefonen"}) + if resp.StatusCode != http.StatusCreated { + t.Errorf("a write from the app was refused: %d %v", resp.StatusCode, body) + } + + // It is a session, not an API token: it reaches what a token may not, and it + // is in the list of devices under the name the app gave itself. + resp, body = ts.appDo(t, token, "GET", "/api/v1/auth/sessions", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("sessions from the app: %d %v", resp.StatusCode, body) + } + var found bool + for _, raw := range body["sessions"].([]any) { + s := raw.(map[string]any) + if s["current"] == true { + found = true + if s["device"] != "Kristians iPhone" || s["client"] != "app" { + t.Errorf("the app's session reads %v / %v", s["device"], s["client"]) + } + } + } + if !found { + t.Error("the app's own session is not marked current") + } + + // And signing out ends it. + if resp, _ = ts.appDo(t, token, "POST", "/api/v1/auth/logout", nil); resp.StatusCode != http.StatusNoContent { + t.Fatalf("logout: %d", resp.StatusCode) + } + if resp, _ = ts.appDo(t, token, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusUnauthorized { + t.Errorf("the token still works after logout: %d", resp.StatusCode) + } +} + +// A browser's session never leaves its httpOnly cookie. Its value replayed as a +// bearer must not work: that would turn the header into a second way past the +// SameSite and CSRF locks the cookie sits behind. +func TestABrowserSessionIsNotABearer(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + var cookie string + for _, c := range ts.client.Jar.Cookies(mustURL(t, ts.URL)) { + if c.Name == sessionCookieInsecure { + cookie = c.Value + } + } + if cookie == "" { + t.Fatal("bootstrap left no session cookie") + } + if resp, _ := ts.appDo(t, cookie, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusUnauthorized { + t.Errorf("a browser session worked as a bearer: %d", resp.StatusCode) + } + // Control: the same value is still a perfectly good cookie. + if resp, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusOK { + t.Errorf("control: the cookie itself: %d", resp.StatusCode) + } +} + +func TestAppLoginWithASecondFactor(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + _, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{"password": "et langt kodeord"}) + secret, _ := body["secret"].(string) + if resp, body := ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{"code": codeFor(t, secret)}); resp.StatusCode != http.StatusOK { + t.Fatalf("totp confirm: %d %v", resp.StatusCode, body) + } + + resp, body := appLogin(t, ts) + token, _ := body["session_token"].(string) + if resp.StatusCode != http.StatusOK || body["totp_required"] != true || token == "" { + t.Fatalf("app login should hand over a token and ask for a code: %d %v", resp.StatusCode, body) + } + if body["user"] != nil { + t.Error("the user came back before the code was given") + } + + // Half a login reaches nothing, as in a browser, and does not slide yet. + resp, body = ts.appDo(t, token, "GET", "/api/v1/auth/me", nil) + if resp.StatusCode != http.StatusUnauthorized || body["code"] != CodeTOTPRequired { + t.Errorf("a pending app session reached /me: %d %v", resp.StatusCode, body) + } + session, _, err := ts.db.SessionByToken(t.Context(), token) + if err != nil { + t.Fatal(err) + } + if session.IdleTTL != 0 || time.Until(session.ExpiresAt) > 11*time.Minute { + t.Errorf("a pending app session lives %v and slides by %v", time.Until(session.ExpiresAt), session.IdleTTL) + } + + resp, body = ts.appDo(t, token, "POST", "/api/v1/auth/login/totp", map[string]string{"code": codeFor(t, secret)}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("totp from the app: %d %v", resp.StatusCode, body) + } + if len(resp.Cookies()) != 0 { + t.Errorf("finishing an app login set a cookie: %v", resp.Cookies()) + } + if resp, _ = ts.appDo(t, token, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusOK { + t.Errorf("me after the code: %d", resp.StatusCode) + } + session, _, _ = ts.db.SessionByToken(t.Context(), token) + if session.IdleTTL != 7*24*time.Hour { + t.Errorf("a finished app session slides by %v, want the app TTL", session.IdleTTL) + } +} + +// A phone in daily use must not sign itself out; one left in a drawer must. The +// end moves forward each time the session is used, and a browser's does not. +func TestAnAppSessionSlides(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + _, body := appLogin(t, ts) + token := body["session_token"].(string) + + // Pretend it was last used a while ago and is a day from ending. + soon := time.Now().Add(24 * time.Hour).Unix() + if _, err := ts.db.ExecContext(t.Context(), + `UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE client = 'app'`, + time.Now().Add(-time.Hour).Unix(), soon); err != nil { + t.Fatal(err) + } + if resp, _ := ts.appDo(t, token, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusOK { + t.Fatalf("me: %d", resp.StatusCode) + } + var expires int64 + if err := ts.db.QueryRowContext(t.Context(), + `SELECT expires_at FROM sessions WHERE client = 'app'`).Scan(&expires); err != nil { + t.Fatal(err) + } + if left := time.Until(time.Unix(expires, 0)); left < 6*24*time.Hour { + t.Errorf("using the app session left %v on it, want about the app TTL", left) + } + + // Control: a browser session used the same way keeps its fixed end. + if _, err := ts.db.ExecContext(t.Context(), + `UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE client = 'web'`, + time.Now().Add(-time.Hour).Unix(), soon); err != nil { + t.Fatal(err) + } + ts.do(t, "GET", "/api/v1/auth/me", nil) + if err := ts.db.QueryRowContext(t.Context(), + `SELECT expires_at FROM sessions WHERE client = 'web'`).Scan(&expires); err != nil { + t.Fatal(err) + } + if expires != soon { + t.Errorf("a browser session's end moved from %d to %d", soon, expires) + } +} + +func TestAppLoginRefusesWhatItDoesNotKnow(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + for _, tc := range []struct { + name string + body map[string]string + field string + }{ + {"unknown client", map[string]string{"client": "android", "device_name": "x"}, "client"}, + {"app without a name", map[string]string{"client": "app", "device_name": " "}, "device_name"}, + } { + tc.body["email"] = "kristian@example.dk" + tc.body["password"] = "et langt kodeord" + resp, body := ts.appDo(t, "", "POST", "/api/v1/auth/login", tc.body) + if resp.StatusCode != http.StatusUnprocessableEntity { + t.Errorf("%s: status %d, want 422", tc.name, resp.StatusCode) + continue + } + if fields, _ := body["fields"].(map[string]any); fields[tc.field] == nil { + t.Errorf("%s: no error on %q in %v", tc.name, tc.field, body) + } + } +} + +func mustURL(t *testing.T, raw string) *url.URL { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + return u +} diff --git a/internal/httpapi/auth_handlers.go b/internal/httpapi/auth_handlers.go index 158b3a1..9a3c215 100644 --- a/internal/httpapi/auth_handlers.go +++ b/internal/httpapi/auth_handlers.go @@ -159,11 +159,40 @@ func (s *Server) handleSetDefaultReminder(w http.ResponseWriter, r *http.Request type loginRequest struct { Email string `json:"email"` Password string `json:"password"` + // Client is "app" when the iOS app signs in; empty is a browser. DeviceName is + // what the app calls itself, shown in the list of signed-in devices. + Client string `json:"client"` + DeviceName string `json:"device_name"` } type loginResponse struct { User *userJSON `json:"user,omitempty"` TOTPRequired bool `json:"totp_required"` + // SessionToken is set for an app login only, and instead of the cookie: the + // app loads its interface from itself, so it never shares an origin with the + // server and a cookie would not come back. It is sent from then on as + // `Authorization: Bearer`. A browser never sees it — its session stays in an + // httpOnly cookie no script can read. + SessionToken string `json:"session_token,omitempty"` +} + +// appClient reports whether a login asked for an app session, and refuses a value +// it does not know rather than quietly giving it a browser session: a client that +// asked for something else and got a cookie would look signed in until its next +// request, and then not be. +func appClient(w http.ResponseWriter, client, deviceName string) (app, ok bool) { + switch client { + case "", "web": + return false, true + case "app": + if strings.TrimSpace(deviceName) == "" { + writeFieldErrors(w, map[string]string{"device_name": "an app names the device it signs in from"}) + return false, false + } + return true, true + } + writeFieldErrors(w, map[string]string{"client": `must be "app" or left out`}) + return false, false } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { @@ -171,6 +200,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { if err := decodeJSON(w, r, &req); err != nil { return } + app, ok := appClient(w, req.Client, req.DeviceName) + if !ok { + return + } user, err := s.db.UserByEmail(r.Context(), req.Email) if err != nil || auth.VerifyPassword(user.PasswordHash, req.Password) != nil { @@ -215,22 +248,40 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { // A session that still needs a code gets a short life of its own: long enough // to fetch a phone, not long enough to be worth stealing. ttl := s.cfg.SessionTTL + if app { + ttl = s.cfg.AppSessionTTL + } if user.TOTPEnabled { ttl = 10 * time.Minute } - token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled) + token, session, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled) if err != nil { s.internal(w, r, "create session", err) return } - s.setSessionCookie(w, token, ttl) - if user.TOTPEnabled { - writeJSON(w, http.StatusOK, loginResponse{TOTPRequired: true}) - return + resp := loginResponse{TOTPRequired: user.TOTPEnabled} + if app { + // Sliding only once the login is finished; a pending one keeps its ten + // minutes and handleLoginTOTP turns it on. + idle := s.cfg.AppSessionTTL + if user.TOTPEnabled { + idle = 0 + } + if err := s.db.MarkAppSession(r.Context(), session.ID, req.DeviceName, idle); err != nil { + s.internal(w, r, "mark app session", err) + return + } + resp.SessionToken = token + } else { + s.setSessionCookie(w, token, ttl) } - uj := toUserJSON(user) - writeJSON(w, http.StatusOK, loginResponse{User: &uj}) + + if !user.TOTPEnabled { + uj := toUserJSON(user) + resp.User = &uj + } + writeJSON(w, http.StatusOK, resp) } type totpRequest struct { @@ -305,7 +356,19 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) { s.loginLimiter.reset(clientIP(r)) s.totpLimiter.reset(user.ID) - if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil { + // An app's session finishes as a sliding one; it already holds its token and + // gets no cookie. + if session.Client == "app" { + if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.AppSessionTTL, s.cfg.AppSessionTTL); err != nil { + s.internal(w, r, "promote session", err) + return + } + uj := toUserJSON(user) + writeJSON(w, http.StatusOK, loginResponse{User: &uj}) + return + } + + if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL, 0); err != nil { s.internal(w, r, "promote session", err) return } diff --git a/internal/httpapi/auth_test.go b/internal/httpapi/auth_test.go index 110dfbe..02831b7 100644 --- a/internal/httpapi/auth_test.go +++ b/internal/httpapi/auth_test.go @@ -51,12 +51,13 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer cfg := &config.Config{ // `localhost`, not a loopback IP: passkeys refuse an IP address as a relying // party id, and every browser makes the same exception for localhost. - BaseURL: "http://localhost", - DataDir: t.TempDir(), - SessionTTL: 24 * time.Hour, - InviteTTL: 7 * 24 * time.Hour, - ResetTTL: time.Hour, - Dev: true, + BaseURL: "http://localhost", + DataDir: t.TempDir(), + SessionTTL: 24 * time.Hour, + AppSessionTTL: 7 * 24 * time.Hour, + InviteTTL: 7 * 24 * time.Hour, + ResetTTL: time.Hour, + Dev: true, } if configure != nil { configure(cfg) diff --git a/internal/httpapi/middleware.go b/internal/httpapi/middleware.go index 8e6f5a1..3aa3a45 100644 --- a/internal/httpapi/middleware.go +++ b/internal/httpapi/middleware.go @@ -126,9 +126,10 @@ func (s *Server) requireAdmin(next http.Handler) http.Handler { }) } -// authenticate resolves a request to a user, by session cookie or by API token. +// authenticate resolves a request to a user, by session cookie, by API token, or by +// an app's session sent as a bearer token. // -// Both are accepted everywhere. An API token belongs to a person and carries their +// All are accepted everywhere. An API token belongs to a person and carries their // permissions exactly — there is no separate service identity to reason about, and // a script therefore cannot reach anything its owner could not. // errNoCredentials means the request carried nothing to authenticate with, as @@ -145,6 +146,21 @@ func (s *Server) authenticate(r *http.Request) (*store.User, *store.Session, err return user, nil, nil } + // Any other bearer is an app session (see loginResponse.SessionToken). It is + // accepted only for a session that was issued to the app: a browser's session + // never leaves its httpOnly cookie, and a cookie value found somewhere and + // replayed as a header must not become a second way to use it. + if token := bearerToken(r); token != "" { + session, user, err := s.db.SessionByToken(r.Context(), token) + if err != nil { + return nil, nil, err + } + if session.Client != "app" { + return nil, nil, store.ErrNotFound + } + return user, session, nil + } + cookie, err := r.Cookie(s.cookieName()) if err != nil || cookie.Value == "" { return nil, nil, errNoCredentials @@ -200,6 +216,14 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler { next.ServeHTTP(w, r) return } + // The iOS app, before it has a token: its sign-in. A web page cannot send + // this Origin — the browser writes the header, not the page — and an app + // login sets no cookie, so there is no session here for a forged request + // to ride on. + if isAppOrigin(r.Header.Get("Origin")) { + next.ServeHTTP(w, r) + return + } // Sec-Fetch-Site is set by the browser and cannot be forged by page script. switch r.Header.Get("Sec-Fetch-Site") { @@ -220,6 +244,20 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler { }) } +// appOrigins are where the iOS app's interface is served from. Capacitor loads +// the bundled build on this scheme inside WKWebView, so every request the app +// makes to a server arrives from here, whichever server it is. +var appOrigins = []string{"capacitor://localhost"} + +func isAppOrigin(origin string) bool { + for _, o := range appOrigins { + if origin == o { + return true + } + } + return false +} + func (s *Server) isOwnOrigin(origin string) bool { if strings.EqualFold(origin, s.cfg.BaseURL) { return true diff --git a/internal/httpapi/sessions_handlers.go b/internal/httpapi/sessions_handlers.go index 43c478f..21db515 100644 --- a/internal/httpapi/sessions_handlers.go +++ b/internal/httpapi/sessions_handlers.go @@ -23,8 +23,11 @@ type sessionJSON struct { // Device is a summary; UserAgent is what it was summarised from. Both, because // the summary is what you read and the original is what settles the question // when the summary is not enough. - Device string `json:"device"` - UserAgent string `json:"user_agent"` + Device string `json:"device"` + UserAgent string `json:"user_agent"` + // Client is "web" or "app". An app session slides — its expires_at moves + // forward each time it is used — where a browser's is fixed. + Client string `json:"client"` IP string `json:"ip"` CreatedAt string `json:"created_at"` LastSeenAt string `json:"last_seen_at"` @@ -47,11 +50,17 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) { out := make([]sessionJSON, 0, len(sessions)) for _, sess := range sessions { + // The app names itself; a browser is described from its user agent. + device := sess.DeviceName + if device == "" { + device = describeUserAgent(sess.UA) + } out = append(out, sessionJSON{ ID: sess.ID, Current: current != nil && sess.ID == current.ID, - Device: describeUserAgent(sess.UA), + Device: device, UserAgent: sess.UA, + Client: sess.Client, IP: sess.IP, CreatedAt: sess.CreatedAt.Format(time.RFC3339), LastSeenAt: sess.LastSeenAt.Format(time.RFC3339), diff --git a/internal/store/migrations/0037_sessioner_fra_en_app.sql b/internal/store/migrations/0037_sessioner_fra_en_app.sql new file mode 100644 index 0000000..674f333 --- /dev/null +++ b/internal/store/migrations/0037_sessioner_fra_en_app.sql @@ -0,0 +1,19 @@ +-- Sessioner fra en app. +-- +-- En app logger ind som en browser gør, med adgangskode og evt. kode, og får en +-- almindelig session. Forskellen er transporten: tokenet kommer i svaret og sendes +-- som `Authorization: Bearer`, fordi en app, der henter sit UI fra sig selv, ikke +-- deler origin med serveren og derfor aldrig får cookien tilbage. At det er en +-- SESSION og ikke et API-token er hele pointen: den står i listen over enheder, +-- kan logges ud derfra, og den når indstillingerne for 2FA og nøgler, som et +-- token med vilje ikke gør. +-- +-- device_name er det navn, appen giver sig selv ("Kristians iPhone"), så listen +-- siger noget, en person genkender. idle_ttl er sekunder: større end 0 betyder, at +-- sessionen glider — hver gang den bruges, flyttes udløbet frem. En browser får +-- 0 og sin faste levetid som før; en telefon, man har i lommen hver dag, må ikke +-- logge sig selv ud hver 30. dag, men en telefon, der ligger i en skuffe et halvt +-- år, skal. +ALTER TABLE sessions ADD COLUMN client TEXT NOT NULL DEFAULT 'web'; +ALTER TABLE sessions ADD COLUMN device_name TEXT NOT NULL DEFAULT ''; +ALTER TABLE sessions ADD COLUMN idle_ttl INTEGER NOT NULL DEFAULT 0; diff --git a/internal/store/sessions.go b/internal/store/sessions.go index c8b0fe5..31211fc 100644 --- a/internal/store/sessions.go +++ b/internal/store/sessions.go @@ -15,9 +15,36 @@ type Session struct { UA string IP string PendingTOTP bool - CreatedAt time.Time - LastSeenAt time.Time - ExpiresAt time.Time + // Client is "web" for a browser and "app" for the iOS app, which carries its + // session as a bearer token rather than a cookie. DeviceName is what the app + // calls itself; a browser has none and is described from its user agent. + Client string + DeviceName string + // IdleTTL above zero makes the session slide: each use moves ExpiresAt to + // IdleTTL from now. Zero is the fixed lifetime a browser session has always had. + IdleTTL time.Duration + CreatedAt time.Time + LastSeenAt time.Time + ExpiresAt time.Time +} + +const sessionColumns = `id, user_id, ua, ip, pending_totp, client, device_name, idle_ttl, + created_at, last_seen_at, expires_at` + +func scanSession(row interface{ Scan(...any) error }) (*Session, error) { + var s Session + var pending int + var idle, created, lastSeen, expires int64 + if err := row.Scan(&s.ID, &s.UserID, &s.UA, &s.IP, &pending, &s.Client, &s.DeviceName, &idle, + &created, &lastSeen, &expires); err != nil { + return nil, err + } + s.PendingTOTP = pending == 1 + s.IdleTTL = time.Duration(idle) * time.Second + s.CreatedAt = time.Unix(created, 0).UTC() + s.LastSeenAt = time.Unix(lastSeen, 0).UTC() + s.ExpiresAt = time.Unix(expires, 0).UTC() + return &s, nil } // CreateSession issues a session and returns the value to put in the cookie. @@ -40,6 +67,7 @@ func (db *DB) CreateSession(ctx context.Context, userID, ua, ip string, ttl time UA: truncate(ua, 500), IP: truncate(ip, 100), PendingTOTP: pendingTOTP, + Client: "web", CreatedAt: now, LastSeenAt: now, ExpiresAt: now.Add(ttl), @@ -63,13 +91,8 @@ func (db *DB) CreateSession(ctx context.Context, userID, ua, ip string, ttl time func (db *DB) SessionByToken(ctx context.Context, token string) (*Session, *User, error) { id := auth.HashToken(token) - var s Session - var pending int - var created, lastSeen, expires int64 - err := db.QueryRowContext(ctx, - `SELECT id, user_id, ua, ip, pending_totp, created_at, last_seen_at, expires_at - FROM sessions WHERE id = ?`, id). - Scan(&s.ID, &s.UserID, &s.UA, &s.IP, &pending, &created, &lastSeen, &expires) + s, err := scanSession(db.QueryRowContext(ctx, + `SELECT `+sessionColumns+` FROM sessions WHERE id = ?`, id)) if errors.Is(err, sql.ErrNoRows) { return nil, nil, ErrNotFound } @@ -77,11 +100,6 @@ func (db *DB) SessionByToken(ctx context.Context, token string) (*Session, *User return nil, nil, err } - s.PendingTOTP = pending == 1 - s.CreatedAt = time.Unix(created, 0).UTC() - s.LastSeenAt = time.Unix(lastSeen, 0).UTC() - s.ExpiresAt = time.Unix(expires, 0).UTC() - if time.Now().After(s.ExpiresAt) { _, _ = db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, s.ID) return nil, nil, ErrNotFound @@ -95,20 +113,47 @@ func (db *DB) SessionByToken(ctx context.Context, token string) (*Session, *User // last_seen_at drives the session list in settings ("this device, 2 minutes // ago"). It is written at most once a minute: every request would be a write // per request, which on SQLite means a write lock per request. + // + // A sliding session moves its end in the same write, and only then: its + // expiry is therefore accurate to the minute, which is far finer than the + // months it is measured in. if time.Since(s.LastSeenAt) > time.Minute { - _, _ = db.ExecContext(ctx, - `UPDATE sessions SET last_seen_at = ? WHERE id = ?`, time.Now().Unix(), s.ID) + now := time.Now() + if s.IdleTTL > 0 && !s.PendingTOTP { + s.ExpiresAt = now.Add(s.IdleTTL).UTC() + _, _ = db.ExecContext(ctx, + `UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?`, + now.Unix(), s.ExpiresAt.Unix(), s.ID) + } else { + _, _ = db.ExecContext(ctx, + `UPDATE sessions SET last_seen_at = ? WHERE id = ?`, now.Unix(), s.ID) + } } - return &s, user, nil + return s, user, nil +} + +// MarkAppSession records that a session belongs to the iOS app, what the device +// calls itself, and how long it may sit unused (see Session.IdleTTL). +// +// A login still waiting on its code is marked with idle 0, so it keeps its ten +// minutes; PromoteSession turns sliding on once the code is in. +func (db *DB) MarkAppSession(ctx context.Context, sessionID, deviceName string, idle time.Duration) error { + _, err := db.ExecContext(ctx, + `UPDATE sessions SET client = 'app', device_name = ?, idle_ttl = ? WHERE id = ?`, + truncate(deviceName, 100), int64(idle/time.Second), sessionID) + return err } // PromoteSession completes a two-step login: the session stops being pending and // its clock restarts, so the full lifetime is counted from finishing the login and // not from typing the password. -func (db *DB) PromoteSession(ctx context.Context, sessionID string, ttl time.Duration) error { +// +// idle above zero makes the finished session slide (see Session.IdleTTL); a +// browser passes zero. +func (db *DB) PromoteSession(ctx context.Context, sessionID string, ttl, idle time.Duration) error { res, err := db.ExecContext(ctx, - `UPDATE sessions SET pending_totp = 0, expires_at = ? WHERE id = ? AND pending_totp = 1`, - time.Now().Add(ttl).Unix(), sessionID) + `UPDATE sessions SET pending_totp = 0, expires_at = ?, idle_ttl = ? WHERE id = ? AND pending_totp = 1`, + time.Now().Add(ttl).Unix(), int64(idle/time.Second), sessionID) if err != nil { return err } @@ -147,7 +192,7 @@ func (db *DB) DeleteUserSessions(ctx context.Context, userID string) error { func (db *DB) ListSessions(ctx context.Context, userID string) ([]Session, error) { rows, err := db.QueryContext(ctx, - `SELECT id, user_id, ua, ip, pending_totp, created_at, last_seen_at, expires_at + `SELECT `+sessionColumns+` FROM sessions WHERE user_id = ? AND pending_totp = 0 ORDER BY last_seen_at DESC`, userID) if err != nil { return nil, err @@ -156,17 +201,11 @@ func (db *DB) ListSessions(ctx context.Context, userID string) ([]Session, error var out []Session for rows.Next() { - var s Session - var pending int - var created, lastSeen, expires int64 - if err := rows.Scan(&s.ID, &s.UserID, &s.UA, &s.IP, &pending, &created, &lastSeen, &expires); err != nil { + s, err := scanSession(rows) + if err != nil { return nil, err } - s.PendingTOTP = pending == 1 - s.CreatedAt = time.Unix(created, 0).UTC() - s.LastSeenAt = time.Unix(lastSeen, 0).UTC() - s.ExpiresAt = time.Unix(expires, 0).UTC() - out = append(out, s) + out = append(out, *s) } return out, rows.Err() }