// 账户相关的公开端点:第三方身份绑定、passkey 登录。 // // 绑定复用登录的 OAuth 跳转,只在发起时多打一个一次性 cookie 表明意图; // 回调拿到身份后统一走 afterIdentity 分流,免得三个 provider 各写一遍判断。 // // 分流规则(顺序即优先级): // 1. 带绑定意图 + 当前是有效管理员会话 → 把该外部身份绑到站主账号,回账户页 // 2. 该外部身份已绑到某账号 → 站主发后台会话,读者发读者会话 // 3. 谁都不认识 → 按老路建/更新读者档案,发读者会话 // // 第 1 步必须校验管理员会话:否则任何人都能跑一遍自己的 OAuth 流程, // 把身份塞进别人的账号上。 package api import ( "encoding/json" "errors" "log" "net/http" "net/url" "strings" "oneblog/internal/auth" "oneblog/internal/httpx" "oneblog/internal/model" "oneblog/internal/ratelimit" "oneblog/internal/store" ) const oauthBindCook = "one_oauth_bind" // startOAuth 备好 state(防 CSRF)与回跳地址,然后跳到 provider 授权页。 // authorize 拿到 state 拼出最终授权 URL —— state 必须在这里生成, // 又要出现在 URL 里,所以用回调而不是先算好传进来。 func (a *API) startOAuth(w http.ResponseWriter, r *http.Request, authorize func(state string) string) { state := randHex(16) http.SetCookie(w, &http.Cookie{Name: oauthStateCook, Value: state, Path: "/", HttpOnly: true, MaxAge: 600}) // 记下发起登录的前台 origin:开发时前端 :3000、后端 :8080 分离, // 回调只有靠它才知道该跳回哪儿。 if ref := r.Referer(); ref != "" { if u, err := url.Parse(ref); err == nil && u.Scheme != "" && u.Host != "" { http.SetCookie(w, &http.Cookie{Name: oauthBackCook, Value: u.Scheme + "://" + u.Host, Path: "/", HttpOnly: true, MaxAge: 600}) } } http.Redirect(w, r, authorize(state), http.StatusFound) } // beginBind 发起绑定。只有已登录的后台管理员能发起,且平台必须已配置。 func (a *API) beginBind(w http.ResponseWriter, r *http.Request) { provider := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/auth/"), "/") provider = strings.TrimSuffix(provider, "/bind") if !a.adminSessionValid(r) { httpx.Unauthorized(w) return } setBindCookie(w) switch provider { case "github": if !a.GH.Enabled() { clearBindCookie(w) httpx.NotFound(w) return } a.startOAuth(w, r, func(state string) string { return a.GH.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/github", state) }) case "google": if !a.GG.Enabled() { clearBindCookie(w) httpx.NotFound(w) return } a.startOAuth(w, r, func(state string) string { return a.GG.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/google", state) }) default: clearBindCookie(w) httpx.NotFound(w) } } func setBindCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{Name: oauthBindCook, Value: "1", Path: "/", HttpOnly: true, MaxAge: 600, SameSite: http.SameSiteLaxMode}) } func clearBindCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{Name: oauthBindCook, Value: "", Path: "/", MaxAge: -1}) } func bindRequested(r *http.Request) bool { ck, err := r.Cookie(oauthBindCook) return err == nil && ck.Value == "1" } // adminSessionValid 判断当前请求带的是不是有效后台会话。 // 公开 API 只依赖注入的接口(不 import admin 包,免得两个 API 互相引用成环)。 func (a *API) adminSessionValid(r *http.Request) bool { if a.AdminSessions == nil { return false } ck, err := r.Cookie(adminCookieName) if err != nil || ck.Value == "" { return false } _, verr := a.AdminSessions.Verify(ck.Value) return verr == nil } const adminCookieName = "one_session" // afterIdentity 见文件头的分流规则。返回空串表示响应已写好,调用方直接 return。 func (a *API) afterIdentity(w http.ResponseWriter, r *http.Request, provider, externUID, display string, persona model.Reader) string { defer clearBindCookie(w) // 意图用完即清,免得下次普通登录误判成绑定 if externUID == "" { // provider 没给稳定 id:宁可退回老流程按 handle 认人,也不建一条 // 空 extern_uid 的绑定 —— 空值会和别人的空值撞唯一键。 return a.issueReaderLogin(w, r, persona) } if bindRequested(r) { owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser) if err != nil { httpx.ServerError(w, err) return "" } if err := a.Store.BindIdentity(owner.ID, provider, externUID, display); err != nil { if errors.Is(err, store.ErrConflict) { httpx.Error(w, http.StatusConflict, "该账号已绑定到其他用户") return "" } httpx.ServerError(w, err) return "" } http.Redirect(w, r, strings.TrimRight(a.Cfg.SiteURL, "/")+"/admin/account?bound="+url.QueryEscape(provider), http.StatusFound) return "" } // 已绑定的身份优先于新建档案:站主用绑定的 GitHub 登录要拿到后台会话 u, err := a.Store.GetUserByIdentity(provider, externUID) switch { case err == nil && u.Role == model.RoleOwner: a.issueAdminSession(w, r) return "" case err == nil: return a.issueReaderSession(w, r, u.ID) case !errors.Is(err, store.ErrNotFound): httpx.ServerError(w, err) return "" } return a.issueReaderLogin(w, r, persona) } func (a *API) issueReaderLogin(w http.ResponseWriter, r *http.Request, persona model.Reader) string { reader, err := a.Store.UpsertReader(persona) if err != nil { httpx.ServerError(w, err) return "" } return a.issueReaderSession(w, r, reader.ID) } func (a *API) issueReaderSession(w http.ResponseWriter, r *http.Request, readerID int64) string { token, _ := a.ReaderSessions.Issue(readerID) http.SetCookie(w, &http.Cookie{Name: auth.ReaderCookie, Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: a.ReaderSessions.TTL()}) return a.loginBack(w, r) } // loginBack 决定登录完跳回哪儿:优先回发起登录的前台 origin // (开发时前端 :3000、后端 :8080 分离,只有它才不会跳错站), // 没有记录(直接敲 URL 进来的)就回站点根。 func (a *API) loginBack(w http.ResponseWriter, r *http.Request) string { back := a.Cfg.SiteURL if ck, err := r.Cookie(oauthBackCook); err == nil && ck.Value != "" { if u, err := url.Parse(ck.Value); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" && u.Path == "" { back = u.Scheme + "://" + u.Host } } http.SetCookie(w, &http.Cookie{Name: oauthBackCook, Value: "", Path: "/", MaxAge: -1}) return back } // issueAdminSession 让已绑定的第三方身份直接换发后台会话 —— 「绑定即提权」 // 的落点。Secure / SameSite 与密码登录发的 cookie 完全一致,否则 HTTPS 下 // 浏览器会把它当不安全 cookie 丢掉。 func (a *API) issueAdminSession(w http.ResponseWriter, r *http.Request) { token, exp := a.AdminSessions.Issue(a.Cfg.AdminUser) http.SetCookie(w, &http.Cookie{ Name: adminCookieName, Value: token, Path: "/", HttpOnly: true, Secure: isTLS(r), SameSite: http.SameSiteLaxMode, Expires: exp, MaxAge: a.AdminSessions.TTL(), }) } // isTLS 判断最终用户看到的是不是 HTTPS(含反代 X-Forwarded-Proto)。 // admin 包有一份同名私有函数:两边各自独立,不为三行代码造共享包。 func isTLS(r *http.Request) bool { if r.TLS != nil { return true } return r.Header.Get("X-Forwarded-Proto") == "https" } // ---------- passkey 登录(公开) ---------- // passkeyBegin 发起一次发现式登录:不预先要用户名,凭据自己带出身份。 func (a *API) passkeyBegin(w http.ResponseWriter, r *http.Request) { if a.Passkeys == nil { httpx.NotFound(w) return } if a.passkeyFails.Blocked(ratelimit.SourceKey(r)) { httpx.Error(w, http.StatusTooManyRequests, "尝试次数过多,请稍后再试") return } options, token, err := a.Passkeys.BeginLogin() if err != nil { httpx.ServerError(w, err) return } httpx.OK(w, map[string]any{"options": options, "token": token}) } // passkeyFinish 校验断言。命中站主发后台会话,命中读者发读者会话。 func (a *API) passkeyFinish(w http.ResponseWriter, r *http.Request) { if a.Passkeys == nil { httpx.NotFound(w) return } var in struct { Token string `json:"token"` Credential json.RawMessage `json:"credential"` } if err := httpx.Decode(r, &in); err != nil || in.Token == "" || len(in.Credential) == 0 { httpx.BadRequest(w, "token 与 credential 都要传") return } ip := ratelimit.SourceKey(r) if a.passkeyFails.Blocked(ip) { httpx.Error(w, http.StatusTooManyRequests, "尝试次数过多,请稍后再试") return } res, err := a.Passkeys.FinishLogin(in.Token, in.Credential, func(credID string) (model.Passkey, error) { return a.Store.GetPasskeyByCredentialID(credID) }) if err != nil { a.passkeyFails.Add(ip) httpx.Error(w, http.StatusUnauthorized, "passkey 校验失败") return } pk, err := a.Store.GetPasskeyByCredentialID(res.CredentialID) if err != nil { a.passkeyFails.Add(ip) httpx.Unauthorized(w) return } if err := a.Store.TouchPasskey(pk.ID, res.SignCount); err != nil { // 计数回写失败不该挡住已验签成功的登录,但要留痕: // 丢了计数就等于丢了克隆检测能力。 log.Printf("passkey: 回写签名计数失败 (id=%d): %v", pk.ID, err) } if res.CloneWarning { log.Printf("passkey: 凭据 %q 签名计数回退,可能被克隆或多设备同步", pk.Name) } u, err := a.Store.GetReader(pk.UserID) if err != nil { httpx.Unauthorized(w) return } if u.Role == model.RoleOwner { a.issueAdminSession(w, r) httpx.OK(w, map[string]any{"ok": true, "role": u.Role}) return } a.issueReaderSession(w, r, u.ID) httpx.OK(w, map[string]any{"ok": true, "role": u.Role}) }