package httpapi import ( "context" "encoding/json" "log/slog" "net/http" "time" "github.com/go-chi/chi/v5" "commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/jwtauth" ) // Session management (AUTH.md §1, §6): list the caller's logins with their // capability level, and really revoke one (delete the row → its access token can // no longer refresh and dies within its short TTL). Revocation is a sensitive // action, so it requires a recent step-up. A standalone step-up endpoint lets the // UI establish that re-auth once (per session/device) and reuse it across actions // within the window — the same session isn't re-prompted repeatedly (#3). type stepUpReq struct { Code string `json:"code"` Verifier string `json:"verifier"` } // handleStepUp exchanges a fresh prompt=login code and, on success, stamps the // caller's session as stepped-up (per-session, cookie-keyed). Sensitive actions // within StepUpMaxAgeSeconds then skip re-auth. 403 on failure; 204 when step-up // is disabled (nothing to establish). func (s *Server) handleStepUp(w http.ResponseWriter, r *http.Request) { if !s.cfg.StepUpEnabled { w.WriteHeader(http.StatusNoContent) return } claims, _ := jwtauth.ClaimsFromContext(r.Context()) var body stepUpReq if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192)).Decode(&body); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if !s.verifyStepUp(r, claims.UserID, body.Code, body.Verifier) { writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"}) return } s.recordStepUp(r) w.WriteHeader(http.StatusNoContent) } type sessionView struct { ID string `json:"id"` DeviceName string `json:"device_name"` Kind string `json:"kind"` // oidc | self | guest | macos | windows | linux | ios Scope string `json:"scope"` // full | guest Current bool `json:"current"` Online bool `json:"online"` // device currently connected (SSE) — UI sorts online first // Native marks a device that authenticates with an IdP token and keeps no // web_session row (desktop / iOS). It is surfaced from the devices registry so // the session list covers every device; its "logout" routes to the device // endpoint rather than the web-session one. Native bool `json:"native"` CreatedAt int64 `json:"created_at"` LastUsedAt int64 `json:"last_used_at"` ExpiresAt int64 `json:"expires_at"` } // isNativeDeviceType reports whether a device type is a real native client (one // that authenticates via the IdP and keeps no web_session). browser devices are // expected to carry a web_session; "shortcut" tokens have their own panel. func isNativeDeviceType(t string) bool { switch t { case "macos", "windows", "linux", "ios": return true default: return false } } // handleSessionsList returns the caller's login sessions with their scope, so the // UI can show the permission level (完整 / 受限访客) and offer real logout. The list // is unified: web_sessions (browser / scan-login) PLUS native client devices // (desktop / iOS) that have no web_session of their own — so every logged-in // device appears in one place and orphaned device rows fall away (AUTH.md §4). func (s *Server) handleSessionsList(w http.ResponseWriter, r *http.Request) { claims, _ := jwtauth.ClaimsFromContext(r.Context()) now := time.Now().Unix() rows, err := s.queries.ListWebSessionsByUser(r.Context(), claims.UserID) if err != nil { slog.Error("list sessions failed", "err", err, "user", claims.UserID) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) return } current := "" if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" { current = sessionID(c.Value) } // Cookieless callers (native apps) can't match by cookie; fall back to their // own device name to mark the "current" row. callerDevice, _ := jwtauth.DeviceNameFromContext(r.Context()) out := make([]sessionView, 0, len(rows)) named := make(map[string]struct{}, len(rows)) for _, row := range rows { if row.ExpiresAt < now { continue // hide expired sessions (reaped separately) } if row.DeviceName != "" { named[row.DeviceName] = struct{}{} } out = append(out, sessionView{ ID: row.ID, DeviceName: row.DeviceName, Kind: row.Kind, Scope: row.Scope, Current: row.ID == current || (current == "" && row.DeviceName != "" && row.DeviceName == callerDevice), Online: row.DeviceName != "" && s.hub.Online(claims.UserID, row.DeviceName), CreatedAt: row.CreatedAt, LastUsedAt: row.LastUsedAt, ExpiresAt: row.ExpiresAt, }) } // Native clients keep no web_session — surface them from the devices registry, // skipping any whose name already has a web_session (no double-listing). if devs, derr := s.queries.ListDevicesByUser(r.Context(), claims.UserID); derr == nil { for _, d := range devs { if !isNativeDeviceType(d.Type) { continue } if _, ok := named[d.Name]; ok { continue } out = append(out, sessionView{ ID: "device:" + d.Name, DeviceName: d.Name, Kind: d.Type, Scope: "full", Native: true, Current: current == "" && d.Name != "" && d.Name == callerDevice, Online: s.hub.Online(claims.UserID, d.Name), CreatedAt: d.LastSeen, LastUsedAt: d.LastSeen, ExpiresAt: 0, }) } } writeJSON(w, http.StatusOK, map[string]any{"sessions": out}) } // deviceNameHasLiveSession reports whether the user still has a non-expired // web_session bound to deviceName — used to avoid deleting a device that another // live session (e.g. a re-login that left the old row) still depends on. func (s *Server) deviceNameHasLiveSession(ctx context.Context, userID, deviceName string) bool { rows, err := s.queries.ListWebSessionsByUser(ctx, userID) if err != nil { return false } now := time.Now().Unix() for _, ws := range rows { if ws.DeviceName == deviceName && ws.ExpiresAt >= now { return true } } return false } // handleSessionRevoke deletes a session row (scoped to its owner), really // invalidating that login. Requires a recent step-up (403 step_up_required // otherwise). Kicks the device's live SSE so it drops immediately. func (s *Server) handleSessionRevoke(w http.ResponseWriter, r *http.Request) { if s.cfg.StepUpEnabled && !s.sessionRecentlySteppedUp(r) { writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"}) return } claims, _ := jwtauth.ClaimsFromContext(r.Context()) id := chi.URLParam(r, "id") if id == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing id"}) return } // Read the device name before deletion so we can kick its SSE. deviceName := "" if sess, err := s.queries.GetWebSession(r.Context(), id); err == nil && sess.UserID == claims.UserID { deviceName = sess.DeviceName } n, err := s.queries.DeleteWebSessionForUser(r.Context(), db.DeleteWebSessionForUserParams{ ID: id, UserID: claims.UserID, }) if err != nil { slog.Error("revoke session failed", "err", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) return } if n == 0 { writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) return } if deviceName != "" { // Drop the device registration too, so a revoked device disappears from the // device list at once (we no longer remove devices by hand) — unless another // live session still uses this name. if !s.deviceNameHasLiveSession(r.Context(), claims.UserID, deviceName) { if _, derr := s.queries.DeleteDevice(r.Context(), db.DeleteDeviceParams{ UserID: claims.UserID, Name: deviceName, }); derr != nil { slog.Warn("delete device on session revoke failed", "err", derr) } } s.hub.Kick(claims.UserID, deviceName) s.hub.PublishPresence(r.Context(), claims.UserID) } w.WriteHeader(http.StatusNoContent) }