Skip to content

Commit 5b2fcd4

Browse files
committed
feat(comments): Artalk SSO 免二次登录进管理后台
- 新增 internal/commentssso:admin 签发短时(60s)opaque token,Artalk /sso/exchange 经 {issuer}/userinfo 兑换身份(无需 JWT/密钥/发现文档) - SSO 路由仅 VANBLOG_COMMENTS_SSO_ENABLED=1 时注册,默认零攻击面; /token 端点仅 admin 可调用,/userinfo 只认有效 token - main.go 注册 SSO 管理器;站点配置页新增「打开评论管理(免二次登录)」按钮: 换票 → 写入 localStorage ArtalkUser → 跳转首页即具备管理员身份 - 附带 handler/manager 单元测试
1 parent 12a9133 commit 5b2fcd4

5 files changed

Lines changed: 372 additions & 0 deletions

File tree

app/src/pages/admin/site.astro

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ const currentPalette = field('palette') || 'default';
164164
<input name="artalk_server" placeholder="https://comment.example.com" class="px-1.5 py-0.5" />
165165
<label>Artalk Site 名</label>
166166
<input name="artalk_site" placeholder="VanBlog" class="px-1.5 py-0.5" />
167+
<button type="button" id="comments-manage-btn" class="col-span-2 justify-self-start">打开评论管理(免二次登录)</button>
167168
</div>
168169
<div id="comments-config-external" class="comments-config-group col-span-2 grid grid-cols-subgrid gap-2" style="display:none">
169170
<label>自定义脚本 / HTML</label>
@@ -275,6 +276,35 @@ const currentPalette = field('palette') || 'default';
275276
} catch (err) { alert('保存失败: ' + err.message); }
276277
});
277278

279+
// ── 评论 SSO:换票进 Artalk(免二次登录) ──
280+
const manageBtn = document.getElementById('comments-manage-btn');
281+
if (manageBtn) {
282+
manageBtn.addEventListener('click', async () => {
283+
const server = (document.querySelector('[name="artalk_server"]')?.value || '').replace(/\/+$/, '');
284+
if (!server) { alert('请先填写 Artalk Server 并保存'); return; }
285+
const authToken = pb.authStore?.token || '';
286+
try {
287+
const tokRes = await fetch('/api/vanblog/comments-sso/token', {
288+
method: 'POST',
289+
headers: authToken ? { 'Authorization': 'Bearer ' + authToken } : {},
290+
});
291+
if (!tokRes.ok) throw new Error('签发换票 token 失败');
292+
const { token } = await tokRes.json();
293+
const exRes = await fetch(`${server}/api/v2/sso/exchange`, {
294+
method: 'POST',
295+
headers: { 'Content-Type': 'application/json' },
296+
body: JSON.stringify({ token }),
297+
});
298+
if (!exRes.ok) throw new Error('Artalk 换票失败');
299+
const login = await exRes.json();
300+
localStorage.setItem('ArtalkUser', JSON.stringify(login));
301+
window.open('/', '_blank');
302+
} catch (err) {
303+
alert('评论管理登录失败:' + (err.message || err));
304+
}
305+
});
306+
}
307+
278308
// ── 重新加载主题:放入新主题后重扫 Caddy file_server 路由 ──
279309
const reloadBtn = document.getElementById('theme-reload-btn');
280310
const reloadStatus = document.getElementById('theme-reload-status');
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package commentssso
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
"time"
7+
8+
"github.com/pocketbase/pocketbase/core"
9+
"github.com/pocketbase/pocketbase/tests"
10+
)
11+
12+
// TestUserinfoEndpoint exercises the /userinfo HTTP handler that Artalk's
13+
// /sso/exchange calls. It does not need a real admin auth record — the
14+
// handler only validates the opaque bearer token against the in-memory store.
15+
func TestUserinfoEndpoint(t *testing.T) {
16+
scenarios := []tests.ApiScenario{
17+
{
18+
Name: "valid token returns verified identity",
19+
Method: http.MethodGet,
20+
URL: ssoUserinfoRoute,
21+
Headers: map[string]string{"Authorization": "Bearer valid-token"},
22+
ExpectedStatus: 200,
23+
ExpectedContent: []string{
24+
`"email":"admin@example.com"`,
25+
`"name":"admin"`,
26+
`"email_verified":true`,
27+
},
28+
},
29+
{
30+
Name: "missing bearer token",
31+
Method: http.MethodGet,
32+
URL: ssoUserinfoRoute,
33+
ExpectedStatus: 401,
34+
ExpectedContent: []string{`"status":401`},
35+
},
36+
{
37+
Name: "invalid bearer token",
38+
Method: http.MethodGet,
39+
URL: ssoUserinfoRoute,
40+
Headers: map[string]string{"Authorization": "Bearer does-not-exist"},
41+
ExpectedStatus: 401,
42+
ExpectedContent: []string{`"status":401`},
43+
},
44+
{
45+
Name: "expired bearer token",
46+
Method: http.MethodGet,
47+
URL: ssoUserinfoRoute,
48+
Headers: map[string]string{"Authorization": "Bearer expired-token"},
49+
ExpectedStatus: 401,
50+
ExpectedContent: []string{`"status":401`},
51+
},
52+
}
53+
54+
for _, scenario := range scenarios {
55+
scenario := scenario
56+
scenario.BeforeTestFunc = func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
57+
mgr := &Manager{app: app, store: newTokenStore()}
58+
mgr.store.put("valid-token", tokenEntry{
59+
email: "admin@example.com",
60+
name: "admin",
61+
expiresAt: time.Now().Add(time.Minute),
62+
})
63+
mgr.store.put("expired-token", tokenEntry{
64+
email: "expired@example.com",
65+
name: "expired",
66+
expiresAt: time.Now().Add(-time.Second),
67+
})
68+
e.Router.GET(ssoUserinfoRoute, mgr.handleUserinfo)
69+
}
70+
scenario.Test(t)
71+
}
72+
}
73+
74+
// TestIssueTokenRequiresAdmin ensures the token-issuing endpoint is gated.
75+
// Without an Authorization header e.Auth is nil, so requireAdmin must reject.
76+
func TestIssueTokenRequiresAdmin(t *testing.T) {
77+
scenario := tests.ApiScenario{
78+
Name: "unauthenticated is forbidden",
79+
Method: http.MethodPost,
80+
URL: ssoIssueRoute,
81+
ExpectedStatus: 403,
82+
ExpectedContent: []string{`"status":403`},
83+
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
84+
mgr := &Manager{app: app, store: newTokenStore()}
85+
e.Router.POST(ssoIssueRoute, mgr.handleIssueToken)
86+
},
87+
}
88+
scenario.Test(t)
89+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// Package commentssso implements a minimal OIDC-style SSO bridge for the
2+
// Artalk comment system. It lets a logged-in VanBlog admin exchange their
3+
// VanBlog session for a short-lived opaque token that Artalk's
4+
// POST /api/v2/sso/exchange endpoint can then redeem via GET /userinfo.
5+
//
6+
// Why so minimal: Artalk's /sso/exchange does NOT validate the token itself
7+
// (no JWT/JWKS verification) — it just forwards it as a Bearer token to
8+
// {issuer}/userinfo and trusts the JSON response. So the token can be an
9+
// opaque random string held in memory, with only the /userinfo endpoint
10+
// needing to recognise it. No JWT, no keys, no discovery document.
11+
//
12+
// Enabled by VANBLOG_COMMENTS_SSO_ENABLED=1 (default off). The /token
13+
// endpoint is admin-gated; /userinfo is unauthenticated by design (Artalk's
14+
// server calls it), but only returns data for a valid unexpired token.
15+
package commentssso
16+
17+
import (
18+
"crypto/rand"
19+
"encoding/hex"
20+
"net/http"
21+
"os"
22+
"strings"
23+
"sync"
24+
"time"
25+
26+
"github.com/pocketbase/pocketbase/core"
27+
)
28+
29+
const (
30+
ssoIssueRoute = "/api/vanblog/comments-sso/token"
31+
ssoUserinfoRoute = "/api/vanblog/comments-sso/userinfo"
32+
ssoTTL = 60 * time.Second
33+
ssoEnabledEnv = "VANBLOG_COMMENTS_SSO_ENABLED"
34+
)
35+
36+
type tokenEntry struct {
37+
email string
38+
name string
39+
expiresAt time.Time
40+
}
41+
42+
type tokenStore struct {
43+
mu sync.Mutex
44+
items map[string]tokenEntry
45+
}
46+
47+
func newTokenStore() *tokenStore {
48+
return &tokenStore{items: make(map[string]tokenEntry)}
49+
}
50+
51+
func (s *tokenStore) put(token string, entry tokenEntry) {
52+
s.mu.Lock()
53+
defer s.mu.Unlock()
54+
now := time.Now()
55+
for k, v := range s.items {
56+
if now.After(v.expiresAt) {
57+
delete(s.items, k)
58+
}
59+
}
60+
s.items[token] = entry
61+
}
62+
63+
func (s *tokenStore) get(token string) (tokenEntry, bool) {
64+
s.mu.Lock()
65+
defer s.mu.Unlock()
66+
entry, ok := s.items[token]
67+
if !ok || time.Now().After(entry.expiresAt) {
68+
return tokenEntry{}, false
69+
}
70+
return entry, true
71+
}
72+
73+
type Manager struct {
74+
app core.App
75+
store *tokenStore
76+
}
77+
78+
func New(app core.App) *Manager {
79+
m := &Manager{app: app, store: newTokenStore()}
80+
if !enabled() {
81+
return m
82+
}
83+
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
84+
se.Router.POST(ssoIssueRoute, m.handleIssueToken)
85+
se.Router.GET(ssoUserinfoRoute, m.handleUserinfo)
86+
return se.Next()
87+
})
88+
return m
89+
}
90+
91+
func enabled() bool {
92+
v := os.Getenv(ssoEnabledEnv)
93+
return v == "1" || v == "true"
94+
}
95+
96+
// requireAdmin mirrors the strict admin gate used elsewhere for destructive
97+
// ops. The SSO bridge lets an admin impersonate themselves in Artalk, so it
98+
// must be admin-only — not content-manager.
99+
func requireAdmin(auth *core.Record) bool {
100+
return auth != nil && auth.GetString("role") == "admin"
101+
}
102+
103+
func (m *Manager) handleIssueToken(e *core.RequestEvent) error {
104+
if !requireAdmin(e.Auth) {
105+
return e.ForbiddenError("admin role required", "")
106+
}
107+
email := e.Auth.GetString("email")
108+
if email == "" {
109+
return e.BadRequestError("admin account has no email address", "")
110+
}
111+
name := e.Auth.GetString("name")
112+
if name == "" {
113+
name = strings.Split(email, "@")[0]
114+
}
115+
token, err := generateToken()
116+
if err != nil {
117+
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate token"})
118+
}
119+
m.store.put(token, tokenEntry{
120+
email: email,
121+
name: name,
122+
expiresAt: time.Now().Add(ssoTTL),
123+
})
124+
return e.JSON(http.StatusOK, map[string]string{"token": token})
125+
}
126+
127+
func (m *Manager) handleUserinfo(e *core.RequestEvent) error {
128+
token := bearerToken(e.Request)
129+
if token == "" {
130+
return e.UnauthorizedError("missing bearer token", "")
131+
}
132+
entry, ok := m.store.get(token)
133+
if !ok {
134+
return e.UnauthorizedError("invalid or expired token", "")
135+
}
136+
return e.JSON(http.StatusOK, map[string]any{
137+
"sub": entry.email,
138+
"name": entry.name,
139+
"email": entry.email,
140+
"email_verified": true,
141+
})
142+
}
143+
144+
func generateToken() (string, error) {
145+
b := make([]byte, 32)
146+
if _, err := rand.Read(b); err != nil {
147+
return "", err
148+
}
149+
return hex.EncodeToString(b), nil
150+
}
151+
152+
func bearerToken(r *http.Request) string {
153+
h := r.Header.Get("Authorization")
154+
const prefix = "Bearer "
155+
if len(h) > len(prefix) && strings.EqualFold(h[:len(prefix)], prefix) {
156+
return strings.TrimSpace(h[len(prefix):])
157+
}
158+
return ""
159+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package commentssso
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"os"
7+
"testing"
8+
"time"
9+
)
10+
11+
func TestTokenStorePutGet(t *testing.T) {
12+
s := newTokenStore()
13+
s.put("tok1", tokenEntry{email: "a@b.c", name: "a", expiresAt: time.Now().Add(time.Minute)})
14+
got, ok := s.get("tok1")
15+
if !ok || got.email != "a@b.c" || got.name != "a" {
16+
t.Fatalf("get: got=%+v ok=%v", got, ok)
17+
}
18+
}
19+
20+
func TestTokenStoreExpired(t *testing.T) {
21+
s := newTokenStore()
22+
s.put("tok1", tokenEntry{email: "a@b.c", expiresAt: time.Now().Add(-time.Second)})
23+
if _, ok := s.get("tok1"); ok {
24+
t.Fatal("expired token unexpectedly returned")
25+
}
26+
}
27+
28+
func TestTokenStoreCleanupOnPut(t *testing.T) {
29+
s := newTokenStore()
30+
s.put("expired", tokenEntry{email: "x@y.z", expiresAt: time.Now().Add(-time.Second)})
31+
s.put("fresh", tokenEntry{email: "a@b.c", expiresAt: time.Now().Add(time.Minute)})
32+
if _, ok := s.get("expired"); ok {
33+
t.Fatal("expired token should have been cleaned up on put")
34+
}
35+
if _, ok := s.get("fresh"); !ok {
36+
t.Fatal("fresh token missing after cleanup")
37+
}
38+
}
39+
40+
func TestBearerToken(t *testing.T) {
41+
r := httptest.NewRequest(http.MethodGet, "/", nil)
42+
r.Header.Set("Authorization", "Bearer abc123")
43+
if got := bearerToken(r); got != "abc123" {
44+
t.Fatalf("got %q, want abc123", got)
45+
}
46+
}
47+
48+
func TestBearerTokenMissing(t *testing.T) {
49+
r := httptest.NewRequest(http.MethodGet, "/", nil)
50+
if got := bearerToken(r); got != "" {
51+
t.Fatalf("expected empty, got %q", got)
52+
}
53+
}
54+
55+
func TestGenerateToken(t *testing.T) {
56+
a, err := generateToken()
57+
if err != nil {
58+
t.Fatal(err)
59+
}
60+
b, err := generateToken()
61+
if err != nil {
62+
t.Fatal(err)
63+
}
64+
if a == b {
65+
t.Fatal("tokens should be unique")
66+
}
67+
if len(a) != 64 { // 32 random bytes hex-encoded
68+
t.Fatalf("unexpected token length %d", len(a))
69+
}
70+
}
71+
72+
func TestEnabled(t *testing.T) {
73+
old := os.Getenv(ssoEnabledEnv)
74+
defer os.Setenv(ssoEnabledEnv, old)
75+
76+
os.Unsetenv(ssoEnabledEnv)
77+
if enabled() {
78+
t.Fatal("should be disabled by default")
79+
}
80+
os.Setenv(ssoEnabledEnv, "1")
81+
if !enabled() {
82+
t.Fatal("should be enabled with 1")
83+
}
84+
os.Setenv(ssoEnabledEnv, "true")
85+
if !enabled() {
86+
t.Fatal("should be enabled with true")
87+
}
88+
os.Setenv(ssoEnabledEnv, "0")
89+
if enabled() {
90+
t.Fatal("should be disabled with 0")
91+
}
92+
}

0 commit comments

Comments
 (0)