账户中心:站主资料可编辑 + 身份绑定 + passkey 登录
后台新增 /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 天后台会话失效 —— 要修得加一张吊销表。
This commit is contained in:
1 parent
7e302c51a6
commit
f1e639e0ba
30 files changed
+2523
-105
No files matched your search
@@ -10,7 +10,6 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -94,29 +93,19 @@ func (a *API) authLogout(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.OK(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// githubLogin 跳转 GitHub 授权页。state 防 CSRF 存短命 cookie;
|
||||
// 授权完成回到 callback 后必须带上同一个值。
|
||||
// 同时把发起登录的前台 origin 记下来(one_oauth_back),
|
||||
// callback 用它跳回去——开发时前端 3000 / 后端 8080 分离才不会落错站。
|
||||
// githubLogin 跳转 GitHub 授权页。state / 回跳地址的处理抽到 startOAuth,
|
||||
// 与「绑定」入口共用同一套跳转(bind 只是多打一个一次性 cookie)。
|
||||
func (a *API) githubLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.GH.Enabled() {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
state := randHex(16)
|
||||
http.SetCookie(w, &http.Cookie{Name: oauthStateCook, Value: state, Path: "/",
|
||||
HttpOnly: true, MaxAge: 600})
|
||||
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, a.GH.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/github", state), http.StatusFound)
|
||||
a.startOAuth(w, r, func(state string) string {
|
||||
return a.GH.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/github", state)
|
||||
})
|
||||
}
|
||||
|
||||
// githubCallback 用 code 换身份:GitHub 用户 → upsert 读者 → 发会话 →
|
||||
// 回到首页。
|
||||
// githubCallback 用 code 换身份,交给统一分流(绑定 / 已绑账号 / 新读者)。
|
||||
func (a *API) githubCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.GH.Enabled() {
|
||||
httpx.NotFound(w)
|
||||
@@ -145,19 +134,27 @@ func (a *API) githubCallback(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if u.Login == "" {
|
||||
a.authFails.Add(ip)
|
||||
httpx.ServerError(w, errors.New("github 未返回用户名"))
|
||||
return
|
||||
}
|
||||
name := u.Name
|
||||
if name == "" {
|
||||
name = u.Login
|
||||
}
|
||||
reader, err := a.Store.UpsertReader(model.Reader{
|
||||
persona := model.Reader{
|
||||
Provider: "github", Handle: u.Login, Name: name,
|
||||
AvatarURL: u.AvatarURL, URL: u.HTMLURL,
|
||||
})
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, a.issueReaderCookie(w, r, reader.ID), http.StatusFound)
|
||||
// 稳定 id 才是绑定键;老接口没返回 id 时退化为按 handle 认人
|
||||
extern := strconv.FormatInt(u.ID, 10)
|
||||
if u.ID == 0 {
|
||||
extern = ""
|
||||
}
|
||||
if back := a.afterIdentity(w, r, "github", extern, u.Login, persona); back != "" {
|
||||
http.Redirect(w, r, back, http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
func randHex(n int) string {
|
||||
|
||||
Reference in new issue
Block a user