package httpapi import ( "crypto/rand" "crypto/subtle" "encoding/base64" "encoding/json" "errors" "log/slog" "net" "net/http" "strings" "time" "commilitia.net/cdrop/internal/brokerclient" "commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/jwtauth" ) // Scan-login (QR), AUTH.md §4. A new device shows a QR; an already-logged-in // phone scans and approves it into the user's account as a restricted guest // session. Three secrets are kept apart so the QR — which can be photographed — // only lets someone *request* approval, never collect the session: // // - request_id : public handle, in the QR. // - approval_code : in the QR; binds the authed approver to this request. // - poll_secret : returned ONCE to the new device, never in the QR; only its // SHA-256 is stored. The session is delivered solely on the connection that // proves the plaintext, so a QR photographer (no poll_secret, not logged in) // fails twice. // // The session row is created when the new device collects it (handleQRStatus), // not at approve time, so the cookie secret is generated on and returned to the // new device's own TLS connection and never travels through the phone. // qrStatusPollWindow caps how long one status long-poll blocks before returning // "pending" for the client to re-poll. var (not const) so tests can shrink it. var qrStatusPollWindow = 25 * time.Second type qrStartReq struct { DeviceName string `json:"device_name"` DeviceType string `json:"device_type"` } type qrStartResp struct { RequestID string `json:"request_id"` PollSecret string `json:"poll_secret"` QRPayload string `json:"qr_payload"` ExpiresAt int64 `json:"expires_at"` } // userResp is the display identity handed to a client. After the broker migration // cdrop no longer holds an accounts table; the approver's name is best-effort (the new // device refreshes it from X-Auth-Name via /api/me once its token is live). type userResp struct { ID string `json:"id"` Name string `json:"name"` Avatar string `json:"avatar,omitempty"` } type qrStatusResp struct { Status string `json:"status"` // pending | approved | denied | expired AccessToken string `json:"access_token,omitempty"` RefreshToken string `json:"refresh_token,omitempty"` ExpiresIn int `json:"expires_in,omitempty"` User *userResp `json:"user,omitempty"` DeviceName string `json:"device_name,omitempty"` DeviceID string `json:"device_id,omitempty"` } type qrRequestResp struct { DeviceName string `json:"device_name"` DeviceType string `json:"device_type"` RequestIP string `json:"request_ip"` ExpiresAt int64 `json:"expires_at"` Status string `json:"status"` } type qrApproveReq struct { RequestID string `json:"request_id"` ApprovalCode string `json:"approval_code"` Scope string `json:"scope"` // full | guest Persist string `json:"persist"` // once | persist } type qrDenyReq struct { RequestID string `json:"request_id"` ApprovalCode string `json:"approval_code"` } // handleQRStart (public, rate-limited) opens a scan-login request for a new // device and returns the QR payload plus the device-private poll secret. func (s *Server) handleQRStart(w http.ResponseWriter, r *http.Request) { if !s.cfg.QRLoginOn() { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "scan login disabled"}) return } var req qrStartReq if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } deviceName := jwtauth.SanitizeDeviceName(req.DeviceName) if deviceName == "" { deviceName = "New device" } requestID, err1 := randB64(16) approvalCode, err2 := randB64(9) pollSecret, err3 := randB64(32) if err := errors.Join(err1, err2, err3); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "id gen"}) return } now := time.Now() expires := now.Add(time.Duration(s.cfg.QRRequestTTLSeconds) * time.Second) if err := s.queries.CreateLoginRequest(r.Context(), db.CreateLoginRequestParams{ ID: requestID, PollSecret: sessionID(pollSecret), // store only the hash ApprovalCode: approvalCode, NewDeviceName: deviceName, NewDeviceType: qrDeviceType(req.DeviceType), UserAgent: truncate(r.UserAgent(), 256), RequestIp: clientIP(r), CreatedAt: now.Unix(), ExpiresAt: expires.Unix(), }); err != nil { slog.Error("create login request failed", "err", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) return } writeJSON(w, http.StatusOK, qrStartResp{ RequestID: requestID, PollSecret: pollSecret, QRPayload: s.qrPayload(r, requestID, approvalCode), ExpiresAt: expires.Unix(), }) } // handleQRStatus (public, rate-limited) is the new device's long-poll. It proves // the poll_secret, then reports pending until the request is approved/denied/ // expired. On approval it creates the session, sets the cookie on THIS response, // mints the first access token, and marks the request consumed (single use). func (s *Server) handleQRStatus(w http.ResponseWriter, r *http.Request) { if !s.cfg.QRLoginOn() { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "scan login disabled"}) return } requestID := r.URL.Query().Get("request_id") pollSecret := r.Header.Get("X-Poll-Secret") if requestID == "" || pollSecret == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing request_id or poll secret"}) return } pollHash := sessionID(pollSecret) deadline := time.Now().Add(qrStatusPollWindow) for { req, err := s.queries.GetLoginRequest(r.Context(), requestID) if err != nil { // Unknown / reaped → treat as expired without leaking existence. writeJSON(w, http.StatusOK, qrStatusResp{Status: "expired"}) return } if subtle.ConstantTimeCompare([]byte(req.PollSecret), []byte(pollHash)) != 1 { writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad poll secret"}) return } if req.ExpiresAt < time.Now().Unix() { writeJSON(w, http.StatusOK, qrStatusResp{Status: "expired"}) return } switch req.Status { case "approved": s.collectQRSession(w, r, req) return case "denied": writeJSON(w, http.StatusOK, qrStatusResp{Status: "denied"}) return case "consumed": writeJSON(w, http.StatusOK, qrStatusResp{Status: "expired"}) return default: // pending if time.Now().After(deadline) { writeJSON(w, http.StatusOK, qrStatusResp{Status: "pending"}) return } select { case <-r.Context().Done(): return case <-time.After(time.Second): } } } } // collectQRSession turns an approved request into a live session for the new device. // ConsumeLoginRequest is the single-use guard: only the poll that flips // approved→consumed proceeds, so a duplicated/raced poll can't mint a second session. // The session itself is minted by the Auth Broker (path A): cdrop generates a stable // device_id, has the broker mint a scoped access+refresh bound to it, and records the // device row (with the broker's sid for later revocation). The new device holds the // broker tokens directly — no cdrop cookie or self-signed token. func (s *Server) collectQRSession(w http.ResponseWriter, r *http.Request, req db.LoginRequest) { n, err := s.queries.ConsumeLoginRequest(r.Context(), req.ID) if err != nil { slog.Error("consume login request failed", "err", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) return } if n == 0 { // Lost the race to another poll; the winner already has the session. writeJSON(w, http.StatusOK, qrStatusResp{Status: "expired"}) return } tier := "guest" if req.GrantScope == "full" { tier = "full" } accessTTL, refreshTTL := s.tierTTLs(tier) deviceID, err := newDeviceID() if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "id gen"}) return } sess, err := s.broker.MintSession(r.Context(), brokerclient.MintParams{ UserID: req.ApproverUserID, Tier: tier, AccessTTL: accessTTL, RefreshTTL: refreshTTL, Sliding: true, Label: req.NewDeviceName, Meta: deviceID, }) if err != nil { slog.Error("broker mint failed", "err", err, "user", req.ApproverUserID) writeJSON(w, http.StatusBadGateway, map[string]string{"error": "mint failed"}) return } now := time.Now().Unix() if err := s.queries.CreateDevice(r.Context(), db.CreateDeviceParams{ DeviceID: deviceID, UserID: req.ApproverUserID, Name: req.NewDeviceName, Type: req.NewDeviceType, Tier: tier, BrokerSid: sess.SID, CreatedAt: now, LastSeen: now, }); err != nil { // The session is already minted and usable; a failed device-row write only // costs local management state (revoke / list), so proceed rather than strand // the new device without its tokens. slog.Error("create device failed", "err", err, "user", req.ApproverUserID, "device", deviceID) } expiresIn := int(sess.AccessExpires - now) if expiresIn < 0 { expiresIn = 0 } // Best-effort display identity; the new device refreshes its real name from // X-Auth-Name via /api/me once its token is live at the edge. user := userResp{ID: req.ApproverUserID, Name: req.ApproverUserID} writeJSON(w, http.StatusOK, qrStatusResp{ Status: "approved", AccessToken: sess.Access, RefreshToken: sess.Refresh, ExpiresIn: expiresIn, User: &user, DeviceName: req.NewDeviceName, DeviceID: deviceID, }) } // tierTTLs returns the configured access + refresh TTLs (seconds) for a tier. func (s *Server) tierTTLs(tier string) (accessTTL, refreshTTL int) { if tier == "guest" { return s.cfg.GuestAccessTTLSeconds, s.cfg.GuestRefreshTTLSeconds } return s.cfg.FullAccessTTLSeconds, s.cfg.FullRefreshTTLSeconds } // handleQRRequest (full session) lets the approver see what they are about to // authorise. The approval_code (from the QR) gates the lookup, so only someone // who scanned the code can read the request's device info. func (s *Server) handleQRRequest(w http.ResponseWriter, r *http.Request) { if !s.cfg.QRLoginOn() { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "scan login disabled"}) return } req, ok := s.lookupQRRequest(w, r, r.URL.Query().Get("request_id"), r.URL.Query().Get("code")) if !ok { return } writeJSON(w, http.StatusOK, qrRequestResp{ DeviceName: req.NewDeviceName, DeviceType: req.NewDeviceType, RequestIP: req.RequestIp, ExpiresAt: req.ExpiresAt, Status: req.Status, }) } // handleQRApprove (full session) binds the request to the caller and records the // granted scope/persistence. The new device's poll then collects the session. func (s *Server) handleQRApprove(w http.ResponseWriter, r *http.Request) { if !s.cfg.QRLoginOn() { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "scan login disabled"}) return } var body qrApproveReq if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } req, ok := s.lookupQRRequest(w, r, body.RequestID, body.ApprovalCode) if !ok { return } claims, _ := jwtauth.ClaimsFromContext(r.Context()) // Step-up (a fresh re-auth before approving a device) is deferred to the broker // post-migration (/login?switch=1); a full-tier session is trusted to approve. // The route is already gated by requireFullSession, so a guest can't reach here. scope := "guest" if body.Scope == "full" { scope = "full" } persist := "once" if body.Persist == "persist" { persist = "persist" } // 原生客户端(非浏览器:iOS / macOS / Windows / Linux)只接受完整权限会话——用户原则: // 原生 App 不允许受限访客(受限访客仅是 Web / PWA 的权宜)。批准端只给「信任并继续 / 拒绝」, // 后端在此再兜底强制 full + persist,无论批准请求送来什么 scope。 if req.NewDeviceType != "" && req.NewDeviceType != "browser" { scope = "full" persist = "persist" } now := time.Now() n, err := s.queries.ApproveLoginRequest(r.Context(), db.ApproveLoginRequestParams{ ApproverUserID: claims.UserID, GrantScope: scope, GrantPersist: persist, ApprovedAt: ptrInt64(now.Unix()), ID: req.ID, }) if err != nil { slog.Error("approve login request failed", "err", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) return } if n == 0 { // Raced to denied/consumed/expired between lookup and update. writeJSON(w, http.StatusConflict, map[string]string{"error": "request no longer pending"}) return } w.WriteHeader(http.StatusNoContent) } // handleQRDeny (full session) rejects a pending request. func (s *Server) handleQRDeny(w http.ResponseWriter, r *http.Request) { if !s.cfg.QRLoginOn() { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "scan login disabled"}) return } var body qrDenyReq if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } req, ok := s.lookupQRRequest(w, r, body.RequestID, body.ApprovalCode) if !ok { return } if _, err := s.queries.DenyLoginRequest(r.Context(), req.ID); err != nil { slog.Error("deny login request failed", "err", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) return } w.WriteHeader(http.StatusNoContent) } // lookupQRRequest fetches a request and verifies the approval_code in constant // time. It writes the error response itself and returns ok=false on any failure // (missing args, unknown request, bad code, expired), so callers just early-return. func (s *Server) lookupQRRequest(w http.ResponseWriter, r *http.Request, requestID, code string) (db.LoginRequest, bool) { if requestID == "" || code == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing request_id or code"}) return db.LoginRequest{}, false } req, err := s.queries.GetLoginRequest(r.Context(), requestID) if err != nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": "request not found"}) return db.LoginRequest{}, false } if subtle.ConstantTimeCompare([]byte(req.ApprovalCode), []byte(code)) != 1 { writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad approval code"}) return db.LoginRequest{}, false } if req.ExpiresAt < time.Now().Unix() { writeJSON(w, http.StatusGone, map[string]string{"error": "request expired"}) return db.LoginRequest{}, false } return req, true } // qrPayload builds the URL encoded into the QR: the approver opens it on their // phone. Prefers the configured site origin; falls back to the request's own // scheme/host for dev. func (s *Server) qrPayload(r *http.Request, requestID, approvalCode string) string { origin := s.siteOrigin if origin == "" { scheme := "https" if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") == "" { scheme = "http" } origin = scheme + "://" + r.Host } return origin + "/link?r=" + requestID + "&c=" + approvalCode } func qrDeviceType(raw string) string { switch t := strings.ToLower(strings.TrimSpace(raw)); t { case "macos", "windows", "linux", "ios", "browser": return t default: return "browser" } } func clientIP(r *http.Request) string { if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { return host } return r.RemoteAddr } func ptrInt64(v int64) *int64 { return &v } func randB64(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } // newDeviceID mints a stable opaque device identifier ("dev_" + base64url(16 random // bytes)). It is cdrop's session<->device join key: passed to the broker as meta and // echoed back as X-Auth-Meta. Pure [A-Za-z0-9_-], no control bytes (validMeta-safe). func newDeviceID() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", err } return "dev_" + base64.RawURLEncoding.EncodeToString(b), nil }