Files
ONE/backend/internal/api/account.go
T
Sakurasan 4bb2ff4145 多用户与角色:owner / admin / reader 三级,用户管理页 + 密码上库
- users 表加 password_hash 列;后台账号(owner+admin)密码 bcrypt 存行内,
  首次登录把 env / settings 引导凭据自迁移成行哈希
- 会话 token 从用户名改为携带用户 ID,角色与停用状态每请求查库,
  改角色 / 停用账号即时生效(存量会话立即 401)
- 登录:先查 users 表,再走 settings 哈希 / env 引导链;
  admin/admin 开发模式在任何账号设过密码后失效
- 权限:系统设置、用户管理仅 owner;内容管理 admin+owner;
  admin 后台新增 用户 页(创建 / 重置密码 / 停用 / 删除),
  设置页「登录与存储」tab 对管理员隐藏
- 账户页加修改密码表单(旧密码校验,OAuth/Passkey 首设免旧密码);
  评论区管理员身份跟随各自账号,不再统一挂站主名下
- 修复:providers 为 nil 时账户页白屏(Go nil slice 序列化成 null)
2026-10-01 22:35:37 +08:00

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.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.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.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, u.ID)
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.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, userID int64) {
token, exp := a.AdminSessions.Issue(userID)
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, u.ID)
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})
}