e51666c52e
- 新增 web_sessions 表(sqlc):refresh_token 以 AES-256-GCM 落盘,密钥取自 CDROP_SESSION_SECRET(env、不在库内);cookie 仅存不透明随机串,表 id 存其 SHA-256,故 DB 单独泄露既换不出可用 cookie、也解不出 token - /auth/exchange 建 session 并下发 HttpOnly; Secure; SameSite=Lax; Path=/api/auth cookie,响应体不再回传 refresh_token - /auth/refresh 改为 cookie 驱动(7 天滑动失活),同时即开机静默免重登; 新增 /auth/logout(删 session + 清 cookie)、/auth/device(写设备名入 session) - device_name 随 session 存,开机 refresh 带回前端,抗 iOS PWA 存储清除; setup / 改名时 syncWebDeviceName 推送服务端 - striped per-session 锁串行化同会话并发 refresh,防一次性 refresh_token 被 花两次而把用户从所有 tab 登出 - 前端 store 移除 refreshToken 字段(长寿命凭据彻底不进 JS);main.tsx 首屏前 bootstrapAuth 用 cookie 静默续期,命中直接进已登录态、避免登录页闪现 - config:prod 强制 CDROP_SESSION_SECRET,缺失拒启动 - 桌面端不受影响(自有 loopback flow + Go keyring,不碰这两个端点)
355 lines
11 KiB
Go
355 lines
11 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-jose/go-jose/v4"
|
|
"github.com/go-jose/go-jose/v4/jwt"
|
|
|
|
"commilitia.net/cdrop/internal/db"
|
|
)
|
|
|
|
// PROJECT_BRIEF.md §2 keeps the browser path on standard OIDC PKCE. Backend
|
|
// proxies the token endpoint so the browser doesn't need a CORS-friendly OIDC
|
|
// provider; the access_token still flows back to the browser unchanged.
|
|
|
|
type authConfigResp struct {
|
|
AuthMode string `json:"auth_mode"`
|
|
AuthorizeURL string `json:"authorize_url"`
|
|
// TokenURL is published so native clients (the desktop app) can run their
|
|
// own loopback PKCE flow directly against the provider, reusing this same
|
|
// client_id. The browser doesn't need it — it proxies through /api/auth/exchange.
|
|
TokenURL string `json:"token_url"`
|
|
ClientID string `json:"client_id"`
|
|
RedirectURI string `json:"redirect_uri"`
|
|
Scopes string `json:"scopes"`
|
|
}
|
|
|
|
// handleAuthConfig publishes everything the frontend needs to start the PKCE
|
|
// authorize redirect. No auth required (it's all public OIDC metadata).
|
|
func (s *Server) handleAuthConfig(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, authConfigResp{
|
|
AuthMode: s.cfg.AuthMode,
|
|
AuthorizeURL: s.cfg.OIDCAuthorizeURL,
|
|
TokenURL: s.cfg.OIDCTokenURL,
|
|
ClientID: s.cfg.OIDCClientID,
|
|
RedirectURI: s.cfg.OIDCRedirectURI,
|
|
Scopes: s.cfg.OIDCScopes,
|
|
})
|
|
}
|
|
|
|
type exchangeReq struct {
|
|
Code string `json:"code"`
|
|
CodeVerifier string `json:"code_verifier"`
|
|
}
|
|
|
|
type tokenResp struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
IDToken string `json:"id_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
TokenType string `json:"token_type"`
|
|
}
|
|
|
|
type userResp struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Avatar string `json:"avatar,omitempty"`
|
|
}
|
|
|
|
// exchangeResp deliberately omits refresh_token: the durable credential never
|
|
// reaches the browser. It's encrypted into the server-side web_sessions row and
|
|
// represented to the client only by the HttpOnly session cookie set alongside.
|
|
type exchangeResp struct {
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
User userResp `json:"user"`
|
|
}
|
|
|
|
func (s *Server) handleAuthExchange(w http.ResponseWriter, r *http.Request) {
|
|
var req exchangeReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
|
return
|
|
}
|
|
if req.Code == "" || req.CodeVerifier == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing code or code_verifier"})
|
|
return
|
|
}
|
|
if s.cfg.OIDCTokenURL == "" {
|
|
writeJSON(w, http.StatusInternalServerError,
|
|
map[string]string{"error": "OIDC token URL not configured"})
|
|
return
|
|
}
|
|
|
|
form := url.Values{}
|
|
form.Set("grant_type", "authorization_code")
|
|
form.Set("code", req.Code)
|
|
form.Set("code_verifier", req.CodeVerifier)
|
|
form.Set("client_id", s.cfg.OIDCClientID)
|
|
form.Set("redirect_uri", s.cfg.OIDCRedirectURI)
|
|
// Casdoor's "cdrop" application is a confidential client; PKCE is layered
|
|
// on top of the standard secret. Skip the secret if not configured to keep
|
|
// public-client OIDC providers happy.
|
|
if s.cfg.OIDCClientSecret != "" {
|
|
form.Set("client_secret", s.cfg.OIDCClientSecret)
|
|
}
|
|
|
|
tr, err := postOIDCToken(r.Context(), s.cfg.OIDCTokenURL, form)
|
|
if err != nil {
|
|
slog.Warn("oidc exchange failed", "err", err)
|
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
user, err := extractUser(tr.IDToken, tr.AccessToken)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway,
|
|
map[string]string{"error": "extract user: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Stash the refresh_token server-side and hand the browser an opaque,
|
|
// HttpOnly cookie instead. Guarded so a deploy without the encryption key (or
|
|
// an IdP that returned no refresh_token) still logs the user in — they just
|
|
// don't get passwordless re-login and re-auth on the next access-token expiry.
|
|
if len(s.sessionKey) > 0 && tr.RefreshToken != "" {
|
|
if err := s.createWebSession(r, w, user.ID, tr.RefreshToken); err != nil {
|
|
slog.Error("create web session failed", "err", err)
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, exchangeResp{
|
|
AccessToken: tr.AccessToken,
|
|
ExpiresIn: tr.ExpiresIn,
|
|
User: user,
|
|
})
|
|
}
|
|
|
|
// createWebSession encrypts the refresh_token, persists a new session row, and
|
|
// sets the session cookie on the response.
|
|
func (s *Server) createWebSession(r *http.Request, w http.ResponseWriter, userID, refreshToken string) error {
|
|
raw, id, err := newSessionToken()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
enc, err := s.encryptRefresh(refreshToken)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
now := time.Now()
|
|
if err := s.queries.CreateWebSession(r.Context(), db.CreateWebSessionParams{
|
|
ID: id,
|
|
UserID: userID,
|
|
RefreshToken: enc,
|
|
DeviceName: "",
|
|
UserAgent: truncate(r.UserAgent(), 256),
|
|
CreatedAt: now.Unix(),
|
|
LastUsedAt: now.Unix(),
|
|
ExpiresAt: now.Add(webSessionTTL).Unix(),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
setSessionCookie(w, raw)
|
|
return nil
|
|
}
|
|
|
|
// refreshResp is what the browser sees: a fresh access_token plus the identity
|
|
// and device name recovered from the session. No refresh_token — it stays
|
|
// server-side. This same endpoint, called with the cookie and no body on app
|
|
// boot, IS the passwordless re-login path.
|
|
type refreshResp struct {
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
User userResp `json:"user"`
|
|
DeviceName string `json:"device_name"`
|
|
}
|
|
|
|
func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
|
|
if s.cfg.OIDCTokenURL == "" {
|
|
writeJSON(w, http.StatusInternalServerError,
|
|
map[string]string{"error": "OIDC token URL not configured"})
|
|
return
|
|
}
|
|
if !s.sameOrigin(r) {
|
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad origin"})
|
|
return
|
|
}
|
|
|
|
c, err := r.Cookie(sessionCookieName)
|
|
if err != nil || c.Value == "" {
|
|
clearSessionCookie(w)
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "no session"})
|
|
return
|
|
}
|
|
id := sessionID(c.Value)
|
|
|
|
// Serialise concurrent refreshes of this session, then read the row INSIDE the
|
|
// lock: a racing tab may already have rotated the refresh_token, and we must
|
|
// spend the current one, not a stale copy the IdP would reject.
|
|
unlock := s.lockRefresh(id)
|
|
defer unlock()
|
|
|
|
sess, err := s.queries.GetWebSession(r.Context(), id)
|
|
if err != nil {
|
|
// Unknown / deleted session → not authenticated. Clear the stale cookie.
|
|
clearSessionCookie(w)
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid session"})
|
|
return
|
|
}
|
|
now := time.Now()
|
|
if sess.ExpiresAt < now.Unix() {
|
|
_ = s.queries.DeleteWebSession(r.Context(), id)
|
|
clearSessionCookie(w)
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "session expired"})
|
|
return
|
|
}
|
|
refreshToken, err := s.decryptRefresh(sess.RefreshToken)
|
|
if err != nil {
|
|
// Corrupt ciphertext or rotated key — the session is unusable, drop it.
|
|
_ = s.queries.DeleteWebSession(r.Context(), id)
|
|
clearSessionCookie(w)
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid session"})
|
|
return
|
|
}
|
|
|
|
form := url.Values{}
|
|
form.Set("grant_type", "refresh_token")
|
|
form.Set("refresh_token", refreshToken)
|
|
form.Set("client_id", s.cfg.OIDCClientID)
|
|
if s.cfg.OIDCClientSecret != "" {
|
|
form.Set("client_secret", s.cfg.OIDCClientSecret)
|
|
}
|
|
|
|
tr, err := postOIDCToken(r.Context(), s.cfg.OIDCTokenURL, form)
|
|
if err != nil {
|
|
// The IdP rejected the refresh_token (expired / revoked) — the session is
|
|
// dead end to end, so destroy it rather than leave a row that always 401s.
|
|
slog.Warn("oidc refresh failed", "err", err)
|
|
_ = s.queries.DeleteWebSession(r.Context(), id)
|
|
clearSessionCookie(w)
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "refresh rejected"})
|
|
return
|
|
}
|
|
|
|
user, err := extractUser(tr.IDToken, tr.AccessToken)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway,
|
|
map[string]string{"error": "extract user: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Rotate: Casdoor issues a new refresh_token on each grant; fall back to the
|
|
// existing one if it didn't. Slide the window forward and re-arm the cookie.
|
|
newRefresh := tr.RefreshToken
|
|
if newRefresh == "" {
|
|
newRefresh = refreshToken
|
|
}
|
|
if enc, encErr := s.encryptRefresh(newRefresh); encErr == nil {
|
|
if err := s.queries.RotateWebSession(r.Context(), db.RotateWebSessionParams{
|
|
RefreshToken: enc,
|
|
LastUsedAt: now.Unix(),
|
|
ExpiresAt: now.Add(webSessionTTL).Unix(),
|
|
ID: id,
|
|
}); err != nil {
|
|
slog.Error("rotate web session failed", "err", err)
|
|
}
|
|
} else {
|
|
slog.Error("encrypt rotated refresh token failed", "err", encErr)
|
|
}
|
|
setSessionCookie(w, c.Value)
|
|
|
|
writeJSON(w, http.StatusOK, refreshResp{
|
|
AccessToken: tr.AccessToken,
|
|
ExpiresIn: tr.ExpiresIn,
|
|
User: user,
|
|
DeviceName: sess.DeviceName,
|
|
})
|
|
}
|
|
|
|
var oidcHTTPClient = &http.Client{Timeout: 10 * time.Second}
|
|
|
|
func postOIDCToken(ctx interface{ Done() <-chan struct{} }, tokenURL string, form url.Values) (*tokenResp, error) {
|
|
req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := oidcHTTPClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("oidc unreachable: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("oidc status %d: %s", resp.StatusCode, truncate(string(body), 200))
|
|
}
|
|
|
|
var tr tokenResp
|
|
if err := json.Unmarshal(body, &tr); err != nil {
|
|
return nil, fmt.Errorf("oidc malformed: %w", err)
|
|
}
|
|
if tr.AccessToken == "" {
|
|
return nil, fmt.Errorf("oidc returned no access_token")
|
|
}
|
|
return &tr, nil
|
|
}
|
|
|
|
// extractUser parses {sub, preferred_username | name} from the id_token if
|
|
// available, else falls back to access_token. We DON'T verify the signature
|
|
// here — the backend's auth middleware verifies access_token on every API
|
|
// call via the JWKS cache, so any tamper would surface there.
|
|
func extractUser(idToken, accessToken string) (userResp, error) {
|
|
pick := idToken
|
|
if pick == "" {
|
|
pick = accessToken
|
|
}
|
|
parsed, err := jwt.ParseSigned(pick, []jose.SignatureAlgorithm{
|
|
jose.RS256, jose.RS384, jose.RS512,
|
|
jose.ES256, jose.ES384, jose.ES512,
|
|
jose.HS256,
|
|
})
|
|
if err != nil {
|
|
return userResp{}, err
|
|
}
|
|
var std jwt.Claims
|
|
custom := map[string]any{}
|
|
if err := parsed.UnsafeClaimsWithoutVerification(&std, &custom); err != nil {
|
|
return userResp{}, err
|
|
}
|
|
u := userResp{ID: std.Subject}
|
|
if v, ok := custom["preferred_username"].(string); ok && v != "" {
|
|
u.Name = v
|
|
} else if v, ok := custom["name"].(string); ok && v != "" {
|
|
u.Name = v
|
|
} else if v, ok := custom["email"].(string); ok && v != "" {
|
|
u.Name = v
|
|
} else {
|
|
u.Name = u.ID
|
|
}
|
|
// Casdoor 的 OIDC id_token 暴露 avatar(自定义) 与 picture(标准 claim) 两种字段;
|
|
// 不同 IdP 实现各异,所以都查一遍。值为空字符串视为未提供,前端落到字母方块回退。
|
|
if v, ok := custom["avatar"].(string); ok && v != "" {
|
|
u.Avatar = v
|
|
} else if v, ok := custom["picture"].(string); ok && v != "" {
|
|
u.Avatar = v
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "…"
|
|
}
|