账户中心:站主资料可编辑 + 身份绑定 + 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:
Sakurasan committed 2026-09-30 01:08:55 +08:00
1 parent 7e302c51a6
commit f1e639e0ba
30 files changed
+2523 -105

No files matched your search

+280
View File
@@ -0,0 +1,280 @@
// 账户相关的公开端点:第三方身份绑定、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})
}
+187
View File
@@ -0,0 +1,187 @@
package api
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"oneblog/internal/auth"
"oneblog/internal/config"
"oneblog/internal/model"
)
// fakeAdmin 满足 API.AdminSessions 接口。不 import admin 包 ——
// 两个 API 之间不该为了测试互相依赖。
type fakeAdmin struct{ valid map[string]bool }
func (f fakeAdmin) Verify(token string) (string, error) {
if f.valid[token] {
return "admin", nil
}
return "", errors.New("bad session")
}
func (f fakeAdmin) Issue(string) (string, time.Time) {
return "issued-admin-token", time.Now().Add(time.Hour)
}
func (f fakeAdmin) TTL() int { return 3600 }
func newAccountAPI(t *testing.T) (*API, http.Handler) {
t.Helper()
a, h := newTestAPI(t)
a.Cfg = &config.Config{SiteURL: "http://localhost:8080", AdminUser: "admin"}
a.AdminSessions = fakeAdmin{valid: map[string]bool{"good-session": true}}
a.GH = auth.GitHub{ClientID: "id", ClientSecret: "sec"}
return a, h
}
// 绑定动作必须已登录后台 —— 否则任何人都能往别人账号上塞身份
func TestBindRequiresAdminSession(t *testing.T) {
_, h := newAccountAPI(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/github/bind", nil))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("无会话绑定: got %d, want 401", rec.Code)
}
}
func TestBindWithAdminSessionLinksAndRedirects(t *testing.T) {
a, h := newAccountAPI(t)
owner, err := a.Store.EnsureOwner("admin")
if err != nil {
t.Fatal(err)
}
back := a.Cfg.SiteURL + "/admin/account"
req := httptest.NewRequest(http.MethodGet, "/api/auth/github/bind", nil)
req.AddCookie(&http.Cookie{Name: "one_session", Value: "good-session"})
req.AddCookie(&http.Cookie{Name: oauthBackCook, Value: back})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("got %d %s, want 302 跳 GitHub", rec.Code, rec.Body.String())
}
loc := rec.Header().Get("Location")
if !strings.HasPrefix(loc, "https://github.com/login/oauth/authorize") {
t.Fatalf("没跳授权页: %s", loc)
}
// 绑定意图必须落到一次性 cookie 上
var sawBind bool
for _, ck := range rec.Result().Cookies() {
if ck.Name == oauthBindCook && ck.Value == "1" {
sawBind = true
}
}
if !sawBind {
t.Fatal("绑定 cookie 没设上,回调时无从判断意图")
}
_ = owner
}
// 回调命中「已绑定给站主」的身份 → 必须发后台会话,而不是读者会话
func TestBoundOwnerIdentityGrantsAdminSession(t *testing.T) {
a, _ := newAccountAPI(t)
owner, err := a.Store.EnsureOwner("admin")
if err != nil {
t.Fatal(err)
}
if err := a.Store.BindIdentity(owner.ID, "github", "4242", "littleckin"); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/auth/callback/github", strings.NewReader("code=x&state=y"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
// 直接测分流函数:绕开真 GitHub
back := a.afterIdentity(rec, req, "github", "4242", "littleckin",
model.Reader{Provider: "github", Handle: "littleckin", Name: "n"})
if back != "" {
t.Fatalf("站主命中绑定应自己收尾(不发跳),got back=%q", back)
}
var admin, reader bool
for _, ck := range rec.Result().Cookies() {
switch ck.Name {
case "one_session":
admin = ck.Value == "issued-admin-token"
case auth.ReaderCookie:
reader = true
}
}
if !admin {
t.Fatal("没发后台会话")
}
if reader {
t.Fatal("站主登录不该只拿到读者会话")
}
}
// 陌生身份(没绑定)走老路:建读者档案 + 发读者会话 + 回跳
func TestUnknownIdentityFallsBackToReader(t *testing.T) {
a, _ := newAccountAPI(t)
if _, err := a.Store.EnsureOwner("admin"); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.AddCookie(&http.Cookie{Name: oauthBackCook, Value: "http://localhost:3000"})
rec := httptest.NewRecorder()
back := a.afterIdentity(rec, req, "github", "999", "stranger",
model.Reader{Provider: "github", Handle: "stranger", Name: "Stranger"})
if back != "http://localhost:3000" {
t.Fatalf("回跳地址不对: %q", back)
}
var reader bool
for _, ck := range rec.Result().Cookies() {
if ck.Name == auth.ReaderCookie && ck.Value != "" {
reader = true
}
if ck.Name == "one_session" {
t.Fatal("陌生身份拿到了后台会话")
}
}
if !reader {
t.Fatal("没发读者会话")
}
}
// 已被别人绑走的外部账号,不能再绑给站主
func TestBindConflictReturns409(t *testing.T) {
a, _ := newAccountAPI(t)
owner, _ := a.Store.EnsureOwner("admin")
other, err := a.Store.UpsertReader(model.Reader{Provider: "github", Handle: "real-owner", Name: "x"})
if err != nil {
t.Fatal(err)
}
if err := a.Store.BindIdentity(other.ID, "github", "4242", "real-owner"); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.AddCookie(&http.Cookie{Name: oauthBindCook, Value: "1"})
rec := httptest.NewRecorder()
a.afterIdentity(rec, req, "github", "4242", "hijack",
model.Reader{Provider: "github", Handle: "hijack", Name: "h"})
if rec.Code != http.StatusConflict {
t.Fatalf("got %d, want 409", rec.Code)
}
// 确认没被抢走
got, err := a.Store.GetUserByIdentity("github", "4242")
if err != nil || got.ID != other.ID {
t.Fatalf("身份归属被改动: %+v err=%v", got, err)
}
_ = owner
}
// passkey 未启用时公开端点要 404,不能 500
func TestPasskeyEndpointsAbsentWhenDisabled(t *testing.T) {
a, h := newAccountAPI(t)
if a.Passkeys != nil {
t.Skip("Passkeys 应未装配")
}
for _, p := range []string{"/api/auth/passkey/begin", "/api/auth/passkey/finish"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, p, strings.NewReader("{}")))
if rec.Code != http.StatusNotFound {
t.Errorf("%s: got %d, want 404", p, rec.Code)
}
}
}
+34 -2
View File
@@ -34,14 +34,23 @@ type API struct {
// 评论区读者会话与 GitHub OAuth(main.go 装配)
ReaderSessions *auth.ReaderSessions
GH auth.GitHub
// AdminSessions 是后台管理员会话验证器(admin.Sessions 满足它)。
// 管理员登录后台后无需再走读者登录即可用站主身份评论。
// AdminSessions 是后台管理员会话(admin.Sessions 满足它)。
// 前台访客登录时命中「已绑定给站主」的身份就靠它发后台会话,
// 所以除了 Verify 还要 Issue/TTL。
AdminSessions interface {
Verify(token string) (string, error)
Issue(user string) (string, time.Time)
TTL() int
}
// 其余登录方式(main.go 装配,未配置的自动不开放)
GG auth.Google
TG auth.Telegram
// Passkeys 是 WebAuthn 服务(main.go 装配;nil 表示未启用,路由不开放)
Passkeys *auth.Passkeys
// passkeyFails 按 IP 限失败次数:passkey 登录是公开端点,
// 虽然伪造断言过不了验签,但不该让人无限次试。
passkeyFails *ratelimit.Window
// Hub 是评论变更的进程内广播(SSE 用;与后台 admin 共享同一实例)
Hub *hub.Hub
@@ -64,6 +73,9 @@ func (a *API) Routes() http.Handler {
a.authFails = ratelimit.New(maxAuthFails, authFailWindow)
a.commentNew = ratelimit.New(maxComments, commentWindow)
}
if a.passkeyFails == nil {
a.passkeyFails = ratelimit.New(maxAuthFails, authFailWindow)
}
mux := http.NewServeMux()
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
if err := a.Store.Ping(r.Context()); err != nil {
@@ -81,6 +93,11 @@ func (a *API) Routes() http.Handler {
mux.HandleFunc("/api/auth/google/login", a.googleLogin)
mux.HandleFunc("/api/auth/callback/google", a.googleCallback)
mux.HandleFunc("/api/auth/telegram", a.telegramAuth)
// 第三方身份绑定(需已登录后台)与 passkey 登录
mux.HandleFunc("/api/auth/github/bind", a.beginBind)
mux.HandleFunc("/api/auth/google/bind", a.beginBind)
mux.HandleFunc("/api/auth/passkey/begin", a.passkeyBegin)
mux.HandleFunc("/api/auth/passkey/finish", a.passkeyFinish)
mux.HandleFunc("/api/comments", a.comments)
mux.HandleFunc("/api/comments/stream", a.commentsStream)
mux.HandleFunc("/api/comments/", a.commentSub)
@@ -108,10 +125,25 @@ func (a *API) site(w http.ResponseWriter, r *http.Request) {
httpx.ServerError(w, err)
return
}
st.AuthorAvatarURL = a.avatarURL(st.AuthorAvatarKey)
// uploads_public_base 告诉前端哪些图片直链是自己的存储(可转 /uploads/thumb/ 缩略图)
httpx.OK(w, map[string]any{"settings": st, "uploads_public_base": a.Cfg.UploadsPublicBase})
}
// avatarURL 把 settings 里的头像 key 解析成可访问 URL。
// key 指向的文件已被删除时返回空串 —— 前台据此回落到站标,
// 而不是留一个打不开的裂图。
func (a *API) avatarURL(key string) string {
if key == "" {
return ""
}
f, err := a.Store.GetFileByKey(key)
if err != nil {
return ""
}
return storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase)
}
func listOptions(r *http.Request, defSize int) store.ListOptions {
return store.ListOptions{
Kind: httpx.QueryString(r, "kind"),
+20 -23
View File
@@ -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 {
+59 -41
View File
@@ -5,16 +5,16 @@ package api
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strconv"
"time"
"oneblog/internal/auth"
"oneblog/internal/httpx"
"oneblog/internal/model"
"oneblog/internal/ratelimit"
"oneblog/internal/store"
)
// authProviders 列出已配置的登录方式。
@@ -40,41 +40,18 @@ func (a *API) authProviders(w http.ResponseWriter, r *http.Request) {
httpx.OK(w, map[string]any{"providers": providers})
}
// issueReaderCookie 登录成功后的公共收尾:发读者会话 + 决定跳回去的地址
func (a *API) issueReaderCookie(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: int((30 * 24 * time.Hour).Seconds())})
// 回到发起登录的前台;没有记录(直接敲 URL 进来的)就回站点根
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
}
// googleLogin 跳 Google 授权页(state 防 CSRF 同 GitHub)
func (a *API) googleLogin(w http.ResponseWriter, r *http.Request) {
if !a.GG.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.GG.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/google", state), http.StatusFound)
a.startOAuth(w, r, func(state string) string {
return a.GG.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/google", state)
})
}
// googleCallback 用 code 换身份:Google 用户 → upsert 读者 → 发会话
// googleCallback 用 code 换身份,交给统一分流(绑定 / 已绑账号 / 新读者)。
func (a *API) googleCallback(w http.ResponseWriter, r *http.Request) {
if !a.GG.Enabled() {
httpx.NotFound(w)
@@ -112,15 +89,12 @@ func (a *API) googleCallback(w http.ResponseWriter, r *http.Request) {
if name == "" {
name = handle
}
reader, err := a.Store.UpsertReader(model.Reader{
Provider: "google", Handle: handle, Name: name,
AvatarURL: u.Picture,
})
if err != nil {
httpx.ServerError(w, err)
return
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)
}
http.Redirect(w, r, a.issueReaderCookie(w, r, reader.ID), http.StatusFound)
}
// telegramAuth 校验 Login Widget 回传的签名资料并登录。
@@ -164,6 +138,52 @@ func (a *API) telegramAuth(w http.ResponseWriter, r *http.Request) {
// 没有公开 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,
@@ -174,13 +194,11 @@ func (a *API) telegramAuth(w http.ResponseWriter, r *http.Request) {
return
}
// 会话同样落 httpOnly cookie,前端 POST 完刷新 /api/auth/me 即可见
token, _ := a.ReaderSessions.Issue(reader.ID)
http.SetCookie(w, &http.Cookie{Name: auth.ReaderCookie, Value: token, Path: "/",
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int((30 * 24 * time.Hour).Seconds())})
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": false, "banned": reader.Banned,
"provider": reader.Provider, "is_owner": reader.Role == model.RoleOwner, "banned": reader.Banned,
}})
}