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:
co-authored by
Claude Opus 5.5
parent
95e02b567b
commit
4ef050ae04
+70
-31
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user