feat(login): appen kan logge ind og bære sin session som bearer

Første trin mod en iOS-app. Appen pakker den eksisterende flade ind og henter
den fra sig selv (capacitor://localhost), så den deler aldrig origin med
serveren og får aldrig cookien tilbage. Den logger derfor ind som en browser,
med adgangskode og evt. kode, men får sin session i svaret i stedet for i en
cookie og sender den som `Authorization: Bearer`.

Det er med vilje en SESSION og ikke et API-token: den står i listen over
enheder under det navn, appen giver sig selv, kan logges ud derfra, og den når
2FA og nøgler, som et token med vilje ikke gør.

- `POST /auth/login` tager `client: "app"` og `device_name`. Svaret får
  `session_token` og ingen cookie, også mens `totp_required` er sand.
- En app-session glider: hver brug flytter udløbet frem, op til
  `VERDANDE_APP_SESSION_TTL` (180 dage) ubrugt. En browser beholder sin faste
  levetid. En halvfærdig login glider ikke; den beholder sine ti minutter.
- En bearer, der ikke er et API-token, slås op som session, men kun hvis den
  blev udstedt til appen. En browsers cookie-værdi som header må ikke blive en
  anden vej forbi SameSite og CSRF-låsen.
- CSRF-låsen genkender appens origin. Det var prøven, der fandt det: selve
  login-kaldet har intet token endnu og blev afvist som cross-site. En
  hjemmeside kan ikke skrive `Origin`-headeren, og en app-login sætter ingen
  cookie, så der er ingen session for en forfalsket anmodning at ride på.

Passkeys er udenfor i første omgang: RP-ID er bundet til domænet og kræver
apple-app-site-association på hver instans.

Migration 0037: sessions.client, device_name, idle_ttl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-10-23 13:55:46 +02:00
co-authored by Claude Opus 5.5
parent 95e02b567b
commit 4ef050ae04
9 changed files with 527 additions and 52 deletions
+12 -3
View File
@@ -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),