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
+71 -8
View File
@@ -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
}