后台新增 /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 天后台会话失效 —— 要修得加一张吊销表。
211 lines
6.3 KiB
Go
211 lines
6.3 KiB
Go
// Google / Telegram 登录的 HTTP 端点。GitHub 的在 comments.go——
|
||
// 三个 Provider 共用同一套读者会话与 upsert 逻辑,只是凭据交换方式不同:
|
||
// GitHub / Google 是授权码换 token,Telegram 是官方 widget 直接带签名资料。
|
||
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"io"
|
||
"net/http"
|
||
"strconv"
|
||
|
||
"oneblog/internal/auth"
|
||
"oneblog/internal/httpx"
|
||
"oneblog/internal/model"
|
||
"oneblog/internal/ratelimit"
|
||
"oneblog/internal/store"
|
||
)
|
||
|
||
// authProviders 列出已配置的登录方式。
|
||
// redirect 型前端直接跳 /api/auth/{id}/login;widget 型(Telegram)
|
||
// 前端内联官方脚本,需要 bot 用户名。
|
||
func (a *API) authProviders(w http.ResponseWriter, r *http.Request) {
|
||
providers := []map[string]any{}
|
||
if a.GH.Enabled() {
|
||
providers = append(providers, map[string]any{
|
||
"id": "github", "label": "GitHub", "kind": "redirect",
|
||
})
|
||
}
|
||
if a.GG.Enabled() {
|
||
providers = append(providers, map[string]any{
|
||
"id": "google", "label": "Google", "kind": "redirect",
|
||
})
|
||
}
|
||
if a.TG.Enabled() {
|
||
providers = append(providers, map[string]any{
|
||
"id": "telegram", "label": "Telegram", "kind": "widget", "login": a.TG.Bot,
|
||
})
|
||
}
|
||
httpx.OK(w, map[string]any{"providers": providers})
|
||
}
|
||
|
||
// googleLogin 跳 Google 授权页(state 防 CSRF 同 GitHub)
|
||
func (a *API) googleLogin(w http.ResponseWriter, r *http.Request) {
|
||
if !a.GG.Enabled() {
|
||
httpx.NotFound(w)
|
||
return
|
||
}
|
||
a.startOAuth(w, r, func(state string) string {
|
||
return a.GG.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/google", state)
|
||
})
|
||
}
|
||
|
||
// googleCallback 用 code 换身份,交给统一分流(绑定 / 已绑账号 / 新读者)。
|
||
func (a *API) googleCallback(w http.ResponseWriter, r *http.Request) {
|
||
if !a.GG.Enabled() {
|
||
httpx.NotFound(w)
|
||
return
|
||
}
|
||
ip := ratelimit.SourceKey(r)
|
||
if a.authFails.Blocked(ip) {
|
||
httpx.Error(w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试")
|
||
return
|
||
}
|
||
ck, err := r.Cookie(oauthStateCook)
|
||
if err != nil || ck.Value == "" || ck.Value != r.FormValue("state") {
|
||
a.authFails.Add(ip)
|
||
httpx.BadRequest(w, "state 不匹配,请重新登录")
|
||
return
|
||
}
|
||
accessToken, err := a.GG.Exchange(r.Context(), r.FormValue("code"), a.Cfg.SiteURL+"/api/auth/callback/google")
|
||
if err != nil {
|
||
a.authFails.Add(ip)
|
||
httpx.ServerError(w, err)
|
||
return
|
||
}
|
||
u, err := a.GG.FetchUser(r.Context(), accessToken)
|
||
if err != nil {
|
||
a.authFails.Add(ip)
|
||
httpx.ServerError(w, err)
|
||
return
|
||
}
|
||
// handle 优先用已验证邮箱(可读),否则退回 sub(Google 的稳定唯一 id)
|
||
handle := u.Sub
|
||
if u.EmailVerified && u.Email != "" {
|
||
handle = u.Email
|
||
}
|
||
name := u.Name
|
||
if name == "" {
|
||
name = handle
|
||
}
|
||
persona := model.Reader{
|
||
Provider: "google", Handle: handle, Name: name, AvatarURL: u.Picture,
|
||
}
|
||
if back := a.afterIdentity(w, r, "google", u.Sub, handle, persona); back != "" {
|
||
http.Redirect(w, r, back, http.StatusFound)
|
||
}
|
||
}
|
||
|
||
// telegramAuth 校验 Login Widget 回传的签名资料并登录。
|
||
// 前端把 widget 的 user 对象原样 POST 过来(见 reader.js 的 oneTelegramAuth)。
|
||
func (a *API) telegramAuth(w http.ResponseWriter, r *http.Request) {
|
||
if !a.TG.Enabled() {
|
||
httpx.NotFound(w)
|
||
return
|
||
}
|
||
// widget 回传是纯表单 POST,签名可被伪造重放——失败计数最必要的一路
|
||
ip := ratelimit.SourceKey(r)
|
||
if a.authFails.Blocked(ip) {
|
||
httpx.Error(w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试")
|
||
return
|
||
}
|
||
// 原样读 body:验签必须用收到的全部字段(官方规则),
|
||
// 身份字段再单独解一次
|
||
body, err := io.ReadAll(r.Body)
|
||
if err != nil {
|
||
httpx.BadRequest(w, "invalid body")
|
||
return
|
||
}
|
||
var fields map[string]any
|
||
if err := json.Unmarshal(body, &fields); err != nil || len(fields) == 0 {
|
||
a.authFails.Add(ip)
|
||
httpx.BadRequest(w, "invalid body")
|
||
return
|
||
}
|
||
if err := a.TG.VerifyMap(fields); err != nil {
|
||
a.authFails.Add(ip)
|
||
httpx.Error(w, http.StatusForbidden, "Telegram 登录校验失败,请重试")
|
||
return
|
||
}
|
||
var in auth.TelegramUser
|
||
if err := json.Unmarshal(body, &in); err != nil || in.IDInt() == 0 {
|
||
httpx.BadRequest(w, "invalid body")
|
||
return
|
||
}
|
||
handle := in.Username
|
||
if handle == "" {
|
||
// 没有公开 username 的用户用数字 id,保证 provider+handle 稳定唯一
|
||
handle = strconv.FormatInt(in.IDInt(), 10)
|
||
}
|
||
externUID := strconv.FormatInt(in.IDInt(), 10)
|
||
|
||
// Telegram 是 XHR + JSON 响应(不是整页跳转),所以这里走与 afterIdentity
|
||
// 同语义、但自己写响应的一份分流。
|
||
if bindRequested(r) {
|
||
if !a.adminSessionValid(r) {
|
||
clearBindCookie(w)
|
||
httpx.Unauthorized(w)
|
||
return
|
||
}
|
||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||
if err != nil {
|
||
clearBindCookie(w)
|
||
httpx.ServerError(w, err)
|
||
return
|
||
}
|
||
err = a.Store.BindIdentity(owner.ID, "telegram", externUID, handle)
|
||
clearBindCookie(w)
|
||
if errors.Is(err, store.ErrConflict) {
|
||
httpx.Error(w, http.StatusConflict, "该账号已绑定到其他用户")
|
||
return
|
||
}
|
||
if err != nil {
|
||
httpx.ServerError(w, err)
|
||
return
|
||
}
|
||
httpx.OK(w, map[string]any{"ok": true, "bound": "telegram"})
|
||
return
|
||
}
|
||
clearBindCookie(w)
|
||
|
||
// 已绑定的身份优先:站主用绑定的 Telegram 登录要拿到后台会话
|
||
if u, err := a.Store.GetUserByIdentity("telegram", externUID); err == nil {
|
||
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})
|
||
return
|
||
} else if !errors.Is(err, store.ErrNotFound) {
|
||
httpx.ServerError(w, err)
|
||
return
|
||
}
|
||
|
||
reader, err := a.Store.UpsertReader(model.Reader{
|
||
Provider: "telegram", Handle: handle, Name: in.DisplayName(),
|
||
AvatarURL: in.PhotoURL,
|
||
URL: tgProfileURL(in.Username),
|
||
})
|
||
if err != nil {
|
||
httpx.ServerError(w, err)
|
||
return
|
||
}
|
||
// 会话同样落 httpOnly cookie,前端 POST 完刷新 /api/auth/me 即可见
|
||
a.issueReaderSession(w, r, reader.ID)
|
||
httpx.OK(w, map[string]any{"user": map[string]any{
|
||
"id": reader.ID, "name": reader.Name, "handle": reader.Handle,
|
||
"avatar_url": reader.AvatarURL, "url": reader.URL,
|
||
"provider": reader.Provider, "is_owner": reader.Role == model.RoleOwner, "banned": reader.Banned,
|
||
}})
|
||
}
|
||
|
||
func tgProfileURL(username string) string {
|
||
if username == "" {
|
||
return ""
|
||
}
|
||
return "https://t.me/" + username
|
||
}
|