账户中心:站主资料可编辑 + 身份绑定 + 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
@@ -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})
|
||||
}
|
||||
@@ -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,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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}})
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user