后台新增 /admin/account 一页,四块:公开资料、密码、第三方账号、Passkey。 - schema:users 加 role(默认 reader),新表 user_identities、passkeys。 user_identities 上双 UNIQUE —— (provider, extern_uid) 防一个外部账号顶两个身份, (user_id, provider) 防一站主绑两个同平台号,绑错也劫持不了。 extern_uid 存平台稳定 ID,不存用户名(用户名可改)。 - 头像存 files 里的 key 而非 URL,换存储/CDN 不失效;单 key SetSetting 写入, 避开 UpdateSettings 的整表替换会把它抹掉。站主名/简介从设置页挪到账户页, 一个字段只留一个编辑入口。 - OAuth 绑定要求先有后台会话(绑定动作本身是提权路径);已绑的站主身份登录后 直接发 one_session,读者身份仍发 one_reader。 - passkey 走 go-webauthn v0.15.0(最后一条吃 go 1.24 的版本线),可发现凭据登录。 必须显式设 ONE_WEBAUTHN_ORIGINS 才启用,不配就安静关掉。 签名计数只记克隆警告、不硬拦 —— 云同步 passkey 的计数本就不单调。 - 密码故意留在 ONE_ADMIN_PASSWORD,不做哈希入库:这是「解绑一切、删光 passkey 也还能进门」的保底,比 env 明文更值得守。memos 那个 SSO 建号随机密码无重置 入口的坑,从设计上绕开。 已知限制:会话仍是有状态无关的 HMAC cookie,删 passkey / 解绑不会让已发出的 7 天后台会话失效 —— 要修得加一张吊销表。
281 lines
9.7 KiB
Go
281 lines
9.7 KiB
Go
// 账户相关的公开端点:第三方身份绑定、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})
|
|
}
|