Compare commits

...

9 Commits

Author SHA1 Message Date
wangjia ec0942e1e7 feat(usercenter): SSO /sso 落地页(拿票兑会话→自动登录)
Deploy Server / deploy-server (push) Successful in 2m47s
App 打开 app.yanmeiai.com/sso?t=<ticket>&redirect=<路径>;本页 exchangeWebTicket→setSession
(与正常登录同路径)→跳白名单相对路径。镜像 jiu sso.njk。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 00:02:48 +08:00
wangjia 1e13f35219 feat(server): SSO 换票端点(App→Web 免登握手)
/v1/auth/web-ticket(需登录):签发一次性票据(crypto/rand 32B, Redis GETDEL 单用, 60s)。
/v1/auth/web-ticket/exchange(公开):票换与 /auth/login 同款 token pair。镜像 jiu。含 6 测试。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 23:54:15 +08:00
wangjia e4de308ba4 fix(usercenter): 首屏直接渲染登录页(修"空白")+ html suppressHydrationWarning
静态导出下 !ready 原来渲染空白背景 div,慢网络首屏看着是空的。改为 !ready||!authed
一律渲染 Login(静态无会话默认态),SSG 首屏即登录表单。html 加 suppressHydrationWarning
消除主题 data-theme 的 hydration 警告(#418)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 23:40:24 +08:00
wangjia 97caae95b8 feat(web): 官网 Windows 下载按钮上线(client CI 已产出 exe)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 23:12:57 +08:00
wangjia 4561ee4cc3 fix(ci): fetch-desktop-bin curl 加固(windows 外网 GFW 抖动)
Deploy Client / build-android (push) Successful in 1m58s
Deploy Client / build-windows (push) Successful in 6m51s
Deploy Client / release-deploy (push) Successful in 1m13s
wintun.net / github 下载偶发超时(curl 28)。retry 3→8、加 --retry-all-errors
+ --retry-connrefused + --connect-timeout 20,拉长重试窗口以命中可达时刻。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 22:56:20 +08:00
wangjia a53bbe743e fix(ci): release-deploy 合并为单次 download-artifact + flatten
Deploy Client / build-android (push) Successful in 1m33s
Deploy Client / build-windows (push) Failing after 8m7s
Deploy Client / release-deploy (push) Has been skipped
首次 windows release-deploy 失败:两次 download-artifact 复用致 act 对只读缓存
action(pack idx 444)EACCES。改一次性下全部 artifact(dist-raw/)再 flatten 到 dist/。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 22:44:17 +08:00
wangjia 7b1f49c182 fix(ci): compile-windows 传 ISCC 用 Windows 路径(cygpath)
Deploy Client / build-android (push) Successful in 1m38s
Deploy Client / build-windows (push) Successful in 8m29s
Deploy Client / release-deploy (push) Failing after 6s
首个 Windows CI 失败:ISCC 'Unknown option: /c/pangolin-build/.../pangolin.iss'——
ISCC 是原生 Windows 程序,MSYS /c/... 路径被当成选项。用 cygpath 转 C:\... 再传。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 22:31:40 +08:00
wangjia 8c8486f9af fix(ci): deploy-client 用 env 传 REF_NAME(防 Actions 注入)+ job.status 同法
Deploy Client / build-android (push) Successful in 1m38s
Deploy Client / build-windows (push) Failing after 6m27s
Deploy Client / release-deploy (push) Has been skipped
安全审查:run: 里内联 ${{ gitea.ref_name }} 有注入风险(恶意 tag 名);改经 env REF_NAME
+ 引用 "$REF_NAME"(同 deploy-server.yml 的 TAG 做法)。notify 的 job.status 同法。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 22:22:00 +08:00
wangjia 7d0f4c8065 ci: 恢复 build-windows job(windows-runner 已提级 user 级)
Deploy Client / build-windows (push) Failing after 20s
Deploy Client / build-android (push) Successful in 1m40s
Deploy Client / release-deploy (push) Has been skipped
runs-on: windows;release-deploy needs [build-android, build-windows] + 下载 windows artifact。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 22:15:06 +08:00
17 changed files with 607 additions and 28 deletions
+44 -13
View File
@@ -45,7 +45,8 @@ jobs:
env:
RELEASE_KEYSTORE: ${{ secrets.RELEASE_KEYSTORE }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: bash scripts/ci/compile-android.sh "${{ gitea.ref_name }}"
REF_NAME: ${{ gitea.ref_name }}
run: bash scripts/ci/compile-android.sh "$REF_NAME"
- name: Upload android artifact
uses: actions/upload-artifact@v3
@@ -53,43 +54,73 @@ jobs:
name: android
path: dist/
# build-windows(runs-on: windows)本轮移除:pangolin 暂无 windows runner。
# 待注册 windows runner 后从 git 历史(commit 2698116)恢复该 job + release-deploy
# 的 needs/下载步骤 + compile-windows.sh 已就绪。
build-windows:
runs-on: windows
env:
GOPROXY: https://goproxy.cn,direct
PUB_HOSTED_URL: https://pub.flutter-io.cn
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Compile (Windows installer)
shell: bash
env:
REF_NAME: ${{ gitea.ref_name }}
run: bash scripts/ci/compile-windows.sh "$REF_NAME"
- name: Upload windows artifact
uses: actions/upload-artifact@v3
with:
name: windows
path: dist/
release-deploy:
needs: [build-android]
needs: [build-android, build-windows]
runs-on: mac
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download android artifact
# 一次性下所有 artifact(不带 name),避免同 job 内两次复用 download-artifact
# action → act 对其只读缓存 git 仓库做二次操作时 EACCES(pack idx 444)。
- name: Download all artifacts
uses: actions/download-artifact@v3
with:
name: android
path: dist/
path: dist-raw/
- name: Flatten artifacts into dist/
shell: bash
run: |
mkdir -p dist
find dist-raw -type f -exec cp {} dist/ \;
echo "dist/ 内容:"; ls -la dist/
- name: Release → Forgejo
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
run: bash scripts/ci/release-client.sh "${{ gitea.ref_name }}"
REF_NAME: ${{ gitea.ref_name }}
run: bash scripts/ci/release-client.sh "$REF_NAME"
- name: Deploy → pangolin1 (downloads/)
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: bash scripts/ci/deploy-client.sh "${{ gitea.ref_name }}"
REF_NAME: ${{ gitea.ref_name }}
run: bash scripts/ci/deploy-client.sh "$REF_NAME"
- name: Notify
if: always()
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
REF_NAME: ${{ gitea.ref_name }}
JOB_STATUS: ${{ job.status }}
run: |
. scripts/ci/notify.sh
if [ "${{ job.status }}" = "success" ]; then
notify_ok "client ${{ gitea.ref_name }} released + deployed"
if [ "$JOB_STATUS" = "success" ]; then
notify_ok "client $REF_NAME released + deployed"
else
notify_fail "client ${{ gitea.ref_name }} pipeline failed"
notify_fail "client $REF_NAME pipeline failed"
fi
+2 -2
View File
@@ -140,7 +140,7 @@ fi
# ── 下载二进制压缩包 ──────────────────────────────────────────────────────────
printf '==> 下载 %s…\n' "${ARCHIVE_FILE}"
curl -fSL --retry 3 --retry-delay 2 \
curl -fSL --retry 8 --retry-delay 5 --retry-connrefused --retry-all-errors --connect-timeout 20 \
-o "${ARCHIVE_CACHE}" \
"${ARCHIVE_URL}"
@@ -197,7 +197,7 @@ if [[ "${TARGET_OS}" == "windows" ]]; then
if [[ "${FORCE}" == false && -f "${WINTUN_OUT}" ]]; then
printf '✓ wintun.dll 已存在,跳过(传 --force 重新下载)\n'
else
curl -fSL --retry 3 --retry-delay 2 \
curl -fSL --retry 8 --retry-delay 5 --retry-connrefused --retry-all-errors --connect-timeout 20 \
-o "${WINTUN_ZIP_CACHE}" \
"${WINTUN_URL}"
+11 -3
View File
@@ -109,9 +109,17 @@ ISS="${BUILD_CLIENT}/windows/installer/pangolin.iss"
sed -i "s/^#define MyAppVersion .*/#define MyAppVersion \"${VER}\"/" "$ISS"
echo "==> building installer with Inno Setup (version ${VER})"
# MSYS_NO_PATHCONV / MSYS2_ARG_CONV_EXCL disable Git Bash's automatic conversion
# of paths, matching jiu's ISCC invocation fix.
MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' "$ISCC" "$ISS"
# ISCC 是原生 Windows 程序,只认 Windows 路径(C:\...);传 MSYS 风格 /c/... 会被它
# 当成选项 → "Unknown option: /c/...pangolin.iss"。故先用 cygpath 把 .iss 转成
# Windows 路径再传(jiu 是直接写死 C:\ 字面量;这里用 cygpath 更稳)。
# 不用 $() 命令替换(仓库约定):cygpath 输出落临时文件、read 读回。
cygpath -w "$ISS" > /tmp/pangolin_iss_win.$$
ISS_WIN=""
read -r ISS_WIN < /tmp/pangolin_iss_win.$$
rm -f /tmp/pangolin_iss_win.$$
# MSYS_NO_PATHCONV / MSYS2_ARG_CONV_EXCL 关掉 Git Bash 对 /-开头参数的自动路径转换
# (否则会把 .iss 里将来可能的 /D 定义也改坏)。ISS_WIN 已是 Windows 路径,原样传即可。
MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' "$ISCC" "$ISS_WIN"
# pangolin.iss has no explicit OutputDir -> Inno defaults to {src}\Output
# (relative to the .iss file), filename OutputBaseFilename=pangolin-setup-<ver>.
+7
View File
@@ -378,6 +378,9 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
v1.Post("/auth/login", authHandler.Login)
v1.Post("/auth/refresh", authHandler.Refresh)
v1.Post("/auth/logout", authHandler.Logout)
// App→网页免登录换票的公开一端;票据本身即凭证,无需 Bearer(见下方
// 受保护分组里的签票端 /auth/web-ticket)。
v1.Post("/auth/web-ticket/exchange", authHandler.WebTicketExchange)
if totpHandler != nil {
v1.Post("/auth/login/totp", totpHandler.LoginTOTP)
}
@@ -405,6 +408,10 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
}
})
protected.Post("/redeem", redeemHandler.ServeHTTP)
if authHandler != nil {
// 签票端要求已登录(拿当前 JWT 的 uid/uuid);兑换端见上方公开分组。
protected.Post("/auth/web-ticket", authHandler.WebTicket)
}
protected.Get("/usage", usageHandler.ServeHTTP)
protected.Get("/usage/devices", deviceUsageHandler.ServeHTTP)
protected.Post("/ads/unlock", adsHandler.ServeHTTP)
+9
View File
@@ -83,4 +83,13 @@ var (
MessageZH: "服务器内部错误,请稍后重试",
MessageEn: "Internal server error, please try again later",
}
// ErrTicketInvalid — the web-ticket (App→网页免登录一次性票据) is missing,
// already used, or expired. Intentionally generic (mirrors ErrCodeInvalid) so
// the three cases can't be distinguished from the response.
ErrTicketInvalid = &apierr.Error{
Code: "auth.ticket_invalid",
MessageZH: "登录票据无效或已过期,请重新从 App 打开",
MessageEn: "Login ticket is invalid or expired, please reopen from the app",
}
)
+70 -1
View File
@@ -29,6 +29,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
r.Post("/auth/login", h.Login)
r.Post("/auth/refresh", h.Refresh)
r.Post("/auth/logout", h.Logout)
r.Post("/auth/web-ticket/exchange", h.WebTicketExchange)
}
// Logout handles POST /v1/auth/logout. The refresh token to revoke is taken from
@@ -158,6 +159,74 @@ func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
writeTokenPair(w, pair)
}
// ═══════════════ App → 网页免登录(一次性换票,magic-link)═══════════════
// webTicketRequest / webTicketResponse — POST /v1/auth/web-ticket (auth-required).
type webTicketResponse struct {
Ticket string `json:"ticket"`
ExpiresIn int `json:"expires_in"`
}
// WebTicket handles POST /v1/auth/web-ticket (auth-required, mounted in the
// RequireAuth group — see main.go). Mints a one-time ticket for the currently
// authenticated user so the app can open the web user-center pre-authenticated
// (`https://<host>/sso?t=<ticket>`). Rate-limited to 1/sec/user.
func (h *Handler) WebTicket(w http.ResponseWriter, r *http.Request) {
uid, ok := UserIDFromContext(r.Context())
if !ok {
writeAPIErr(w, ErrUnauthorized, 0)
return
}
uuid, ok := UserUUIDFromContext(r.Context())
if !ok {
writeAPIErr(w, ErrUnauthorized, 0)
return
}
ticket, ttl, retryAfter, apiErr := h.svc.IssueWebTicket(r.Context(), uid, uuid)
if apiErr != nil {
writeAPIErr(w, apiErr, retryAfter)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(webTicketResponse{Ticket: ticket, ExpiresIn: ttl})
}
// webTicketExchangeRequest is the public exchange body.
type webTicketExchangeRequest struct {
Ticket string `json:"ticket"`
Device deviceBody `json:"device"`
}
// WebTicketExchange handles POST /v1/auth/web-ticket/exchange (public, no
// bearer auth — the ticket itself is the credential). Validates+consumes the
// one-time ticket and issues the SAME token-pair shape as a normal login for
// the ticket's user, registering the device like Login does. Invalid, expired,
// or already-used tickets all map to a generic 401.
func (h *Handler) WebTicketExchange(w http.ResponseWriter, r *http.Request) {
var req webTicketExchangeRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Ticket == "" {
writeAPIErr(w, ErrInvalidRequest, 0)
return
}
pair, deviceLimit, apiErr := h.svc.LoginWithWebTicket(r.Context(), req.Ticket, clientIP(r), req.Device.toMeta())
if apiErr != nil {
writeAPIErr(w, apiErr, 0)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(tokenPairResponse{
AccessToken: pair.AccessToken,
RefreshToken: pair.RefreshToken,
ExpiresIn: pair.ExpiresIn,
DeviceLimit: deviceLimit,
})
}
// ---- helpers ----
// decodeJSON decodes the request body, writing a 400 on malformed input.
@@ -206,7 +275,7 @@ func statusFor(e *apierr.Error) int {
return http.StatusConflict
case ErrRateLimited.Code, ErrAccountLocked.Code:
return http.StatusTooManyRequests
case ErrInvalidCredentials.Code, ErrInvalidToken.Code, ErrUnauthorized.Code:
case ErrInvalidCredentials.Code, ErrInvalidToken.Code, ErrUnauthorized.Code, ErrTicketInvalid.Code:
return http.StatusUnauthorized
case ErrAccountBanned.Code:
return http.StatusForbidden
+130
View File
@@ -0,0 +1,130 @@
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
"github.com/wangjia/pangolin/server/internal/apierr"
)
// ═══════════════ App → 网页免登录(一次性换票,magic-link)═══════════════
//
// The app (already holding a valid JWT) mints a one-time ticket via
// IssueWebTicket and opens the system browser at .../sso?t=<ticket>. The web
// user-center exchanges the ticket via LoginWithWebTicket for a normal token
// pair — same shape as /auth/login — without ever seeing the app's own tokens.
//
// Storage: Redis only (no DB row). Tickets are single-instance, ephemeral
// (60s TTL) credentials — a DB table would need its own cleanup job for what
// Redis already gives for free via key expiry. Consumption uses GETDEL, which
// is atomic server-side: a replayed/concurrent second exchange always loses
// the race and gets redis.Nil, so single-use is enforced without a CAS dance.
const (
// webTicketTTL is the ticket lifetime: long enough for the system browser
// to open and hit the exchange endpoint, short enough to bound replay risk.
webTicketTTL = 60 * time.Second
// webTicketPrefix namespaces ticket keys in Redis.
webTicketPrefix = "auth:webticket:"
// scopeWebTicket is the rate-limit scope for ticket issuance (1/sec/user).
scopeWebTicket = "webticket"
)
func webTicketKey(ticket string) string { return webTicketPrefix + ticket }
// genWebTicket returns a fresh, cryptographically random one-time ticket. 32
// bytes (256 bits) of entropy makes guessing infeasible within the 60s TTL.
func genWebTicket() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("auth: gen web ticket: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// IssueWebTicket mints a one-time ticket for an already-authenticated user
// (userID/userUUID come from the caller's validated JWT claims — see
// Handler.WebTicket). Rate-limited to 1/sec/user as a rebuff-abuse backstop
// (the endpoint already requires login). The ticket value itself never
// carries a real token — only enough to look the user up on exchange.
func (s *Service) IssueWebTicket(ctx context.Context, userID int64, userUUID string) (ticket string, ttlSeconds int, retryAfter time.Duration, apiErr *apierr.Error) {
ok, ra, err := s.rl.Allow(ctx, scopeWebTicket, strconv.FormatInt(userID, 10), 1, time.Second)
if err != nil {
return "", 0, 0, ErrInternal
}
if !ok {
return "", 0, ra, ErrRateLimited
}
tk, err := genWebTicket()
if err != nil {
return "", 0, 0, ErrInternal
}
val := userID2UUID(userID, userUUID)
if err := s.rdb.Set(ctx, webTicketKey(tk), val, webTicketTTL).Err(); err != nil {
return "", 0, 0, ErrInternal
}
return tk, int(webTicketTTL.Seconds()), 0, nil
}
// LoginWithWebTicket validates+atomically-consumes a one-time ticket and
// issues a fresh token pair for its user, registering the device exactly like
// a normal Login (best-effort — see recordLogin). Any invalid/expired/
// already-used ticket collapses to ErrTicketInvalid (no distinguishing info
// leaked).
func (s *Service) LoginWithWebTicket(ctx context.Context, ticket, ip string, device DeviceMeta) (*TokenPair, *DeviceLimit, *apierr.Error) {
if ticket == "" {
return nil, nil, ErrTicketInvalid
}
// GETDEL is atomic: concurrent/replayed exchanges of the same ticket race
// on a single Redis command, so exactly one caller ever sees the value.
val, err := s.rdb.GetDel(ctx, webTicketKey(ticket)).Result()
if errors.Is(err, redis.Nil) {
return nil, nil, ErrTicketInvalid
}
if err != nil {
return nil, nil, ErrInternal
}
userID, userUUID, ok := splitUserID2UUID(val)
if !ok {
// Our own format, corrupt only via a Redis-level anomaly — never surface
// internals to the (unauthenticated) caller.
return nil, nil, ErrInternal
}
pair, jti, err := s.tokens.IssueWithJTI(ctx, userID, userUUID)
if err != nil {
return nil, nil, ErrInternal
}
dl := s.recordLogin(ctx, userID, jti, ip, device)
return pair, dl, nil
}
// userID2UUID / splitUserID2UUID encode the ticket's Redis value as
// "<userID>:<userUUID>". UUIDs are hyphenated hex (no ':'), so a single
// SplitN(2) round-trips unambiguously.
func userID2UUID(userID int64, userUUID string) string {
return strconv.FormatInt(userID, 10) + ":" + userUUID
}
func splitUserID2UUID(val string) (userID int64, userUUID string, ok bool) {
parts := strings.SplitN(val, ":", 2)
if len(parts) != 2 || parts[1] == "" {
return 0, "", false
}
id, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || id == 0 {
return 0, "", false
}
return id, parts[1], true
}
+186
View File
@@ -0,0 +1,186 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// newWebTicketHandler wires a Handler with BOTH the public routes (via
// RegisterRoutes) and the auth-required /auth/web-ticket route, mirroring how
// main.go mounts them in two separate groups (public vs RequireAuth).
func newWebTicketHandler(t *testing.T, cfg ServiceConfig) (*Service, http.Handler) {
t.Helper()
svc, _, _ := newService(t, cfg)
h := NewHandler(svc)
r := chi.NewRouter()
h.RegisterRoutes(r) // public: includes /auth/web-ticket/exchange
r.Group(func(protected chi.Router) {
protected.Use(RequireAuth(svc.tokens))
protected.Post("/auth/web-ticket", h.WebTicket)
})
return svc, r
}
// issueAccessToken mints a real, valid access token for a fresh user id/uuid
// pair — bypassing full register/login, since the ticket flow only cares that
// RequireAuth's middleware injects a valid uid/uuid into the request context.
func issueAccessToken(t *testing.T, svc *Service) (accessToken string, userID int64, userUUID string) {
t.Helper()
userID = 42
userUUID = uuid.NewString()
pair, _, err := svc.tokens.IssueWithJTI(context.Background(), userID, userUUID)
if err != nil {
t.Fatalf("issue access token: %v", err)
}
return pair.AccessToken, userID, userUUID
}
func doAuthed(t *testing.T, h http.Handler, method, path, token string, body interface{}) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
if body != nil {
_ = json.NewEncoder(&buf).Encode(body)
}
req := httptest.NewRequest(method, path, &buf)
req.RemoteAddr = "203.0.113.5:1234"
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// TestWebTicket_IssueAndExchange covers the full happy path: an authed app
// mints a ticket, the (unauthenticated) web side exchanges it for a token
// pair shaped exactly like a normal /auth/login response.
func TestWebTicket_IssueAndExchange(t *testing.T) {
svc, h := newWebTicketHandler(t, ServiceConfig{})
token, wantUID, wantUUID := issueAccessToken(t, svc)
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("issue status = %d, body %s", rec.Code, rec.Body)
}
var issued webTicketResponse
if err := json.Unmarshal(rec.Body.Bytes(), &issued); err != nil {
t.Fatalf("decode issue response: %v", err)
}
if issued.Ticket == "" {
t.Fatal("empty ticket")
}
if issued.ExpiresIn != 60 {
t.Fatalf("expires_in = %d, want 60", issued.ExpiresIn)
}
rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "",
map[string]any{"ticket": issued.Ticket, "device": map[string]string{"platform": "web"}})
if rec.Code != http.StatusOK {
t.Fatalf("exchange status = %d, body %s", rec.Code, rec.Body)
}
var pair tokenPairResponse
if err := json.Unmarshal(rec.Body.Bytes(), &pair); err != nil {
t.Fatalf("decode exchange response: %v", err)
}
if pair.AccessToken == "" || pair.RefreshToken == "" || pair.ExpiresIn != 900 {
t.Fatalf("bad token pair: %+v", pair)
}
// The exchanged access token must authenticate as the SAME user the app
// ticket was issued for.
claims, err := svc.tokens.ParseAccess(pair.AccessToken)
if err != nil {
t.Fatalf("parse exchanged access token: %v", err)
}
if claims.UID != wantUID || claims.Subject != wantUUID {
t.Fatalf("exchanged token identifies uid=%d uuid=%s, want uid=%d uuid=%s",
claims.UID, claims.Subject, wantUID, wantUUID)
}
}
// TestWebTicket_SingleUse_ReplayRejected: a second exchange of the same
// ticket (replay) must fail 401, even though the first succeeded.
func TestWebTicket_SingleUse_ReplayRejected(t *testing.T) {
svc, h := newWebTicketHandler(t, ServiceConfig{})
token, _, _ := issueAccessToken(t, svc)
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil)
var issued webTicketResponse
_ = json.Unmarshal(rec.Body.Bytes(), &issued)
rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "",
map[string]any{"ticket": issued.Ticket})
if rec.Code != http.StatusOK {
t.Fatalf("first exchange should succeed, got %d: %s", rec.Code, rec.Body)
}
rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "",
map[string]any{"ticket": issued.Ticket})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("replayed exchange status = %d, want 401 (body %s)", rec.Code, rec.Body)
}
}
// TestWebTicket_Expired_Rejected simulates TTL expiry the same way the rest of
// this package does (service_test.go TestService_CodeExpired): delete the
// Redis key directly rather than fast-forwarding a clock.
func TestWebTicket_Expired_Rejected(t *testing.T) {
svc, h := newWebTicketHandler(t, ServiceConfig{})
token, _, _ := issueAccessToken(t, svc)
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil)
var issued webTicketResponse
_ = json.Unmarshal(rec.Body.Bytes(), &issued)
svc.rdb.Del(context.Background(), webTicketKey(issued.Ticket))
rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "",
map[string]any{"ticket": issued.Ticket})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expired ticket exchange status = %d, want 401 (body %s)", rec.Code, rec.Body)
}
}
// TestWebTicket_Bogus_Rejected: a made-up ticket that was never issued.
func TestWebTicket_Bogus_Rejected(t *testing.T) {
_, h := newWebTicketHandler(t, ServiceConfig{})
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "",
map[string]any{"ticket": "not-a-real-ticket"})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("bogus ticket exchange status = %d, want 401 (body %s)", rec.Code, rec.Body)
}
}
// TestWebTicket_RequiresAuth: minting a ticket without a bearer token is
// rejected by RequireAuth before it ever reaches the handler.
func TestWebTicket_RequiresAuth(t *testing.T) {
_, h := newWebTicketHandler(t, ServiceConfig{})
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", "", nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated issue status = %d, want 401", rec.Code)
}
}
// TestWebTicket_RateLimited: a second ticket request within the same second
// for the same user is rejected 429 (rebuff-abuse backstop; the endpoint
// already requires login).
func TestWebTicket_RateLimited(t *testing.T) {
svc, h := newWebTicketHandler(t, ServiceConfig{})
token, _, _ := issueAccessToken(t, svc)
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("first issue status = %d, body %s", rec.Code, rec.Body)
}
rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("second issue status = %d, want 429 (body %s)", rec.Code, rec.Body)
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ export const viewport: Viewport = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh" data-theme="light">
<html lang="zh" data-theme="light" suppressHydrationWarning>
<head>
{/* 设计令牌单一真相源,原样链入(SRI 由构建期注入) */}
{/* eslint-disable-next-line @next/next/no-css-tags */}
+102
View File
@@ -0,0 +1,102 @@
'use client';
// app/sso/page.tsx — App→网页免登录落地页(镜像 jiu 的 web/sso.njk)。
//
// 流程:App 已持正常登录态,先 POST /v1/auth/web-ticket 签一次性票据(60s TTL、
// 单次可用),再打开系统浏览器到 https://<usercenter>/sso/?t=<ticket>&redirect=<本站路径>。
// 本页凭票 POST /v1/auth/web-ticket/exchange(公开端点)兑换与 login() 同形状的
// token pair,经与登录页相同的 setSession(见 lib/api/http.ts::exchangeWebTicket)
// 落地会话,再跳 redirect —— 其余页面(UserCenter.tsx)据此把它当作一次正常登录。
//
// 因 next.config.js 是 output:'export' 的纯静态导出,这里没有服务端 searchParams
// 可用,票据与 redirect 目标都在挂载后从 window.location.search 读取。
//
// 安全要点(与 jiu 的 sso.njk 一致)
// - URL 只携带一次性票据,从不携带真正的 access/refresh token;
// - redirect 白名单:仅接受单个 '/' 开头、且不以 '//' 或反斜杠开头的本站相对
// 路径,其余一律回退到 '/'(登录页),防 open redirect;
// - 票据用后立即从地址栏抹掉(history.replaceState),不留浏览器历史;
// - 兑换失败(票据无效/过期/已用)一律落回登录页('/'),不泄露失败原因细节。
import React, { useEffect, useState } from 'react';
import { Mark } from '../../components/icons';
import { ErrorLine } from '../../components/Login';
import { card } from '../../components/shared';
import { useUI } from '../../lib/theme';
import { makeT } from '../../lib/i18n';
import { getClient } from '../../lib/api/client';
import { bilingual } from '../../lib/api/errors';
/** redirect 白名单:仅本站相对路径(单个 '/' 开头),其余一律回 '/'。 */
function safeRedirect(raw: string | null): string {
if (!raw) return '/';
if (raw.charAt(0) !== '/' || raw.charAt(1) === '/' || raw.indexOf('\\') >= 0) return '/';
return raw;
}
export default function SsoPage() {
const { lang } = useUI();
const t = makeT(lang);
const [failed, setFailed] = useState(false);
const [msg, setMsg] = useState('');
useEffect(() => {
let alive = true;
const params = new URLSearchParams(window.location.search);
const ticket = params.get('t');
const redirect = safeRedirect(params.get('redirect'));
// 票据只用一次,立刻从地址栏抹掉,不留浏览器历史(与 jiu 一致)。
try {
window.history.replaceState(null, '', '/sso/');
} catch {
/* ignore */
}
if (!ticket) {
window.location.replace('/');
return;
}
const api = getClient();
api
.exchangeWebTicket(ticket)
.then(() => {
if (!alive) return;
window.location.replace(redirect);
})
.catch((e) => {
if (!alive) return;
setMsg(bilingual(e, lang));
setFailed(true);
setTimeout(() => {
window.location.replace('/');
}, 1500);
});
return () => {
alive = false;
};
// 仅挂载时读取一次 URL 并发起兑换;lang 变化不应重跑网络请求。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg)', padding: 24 }}>
<div style={{ ...card, width: 420, maxWidth: '100%', padding: '36px 34px', boxSizing: 'border-box', textAlign: 'center' }}>
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 18 }}>
<Mark size={32} />
</div>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 20, color: 'var(--fg1)' }}>
{failed ? t('ssoFailTitle') : t('ssoTitle')}
</div>
<div style={{ fontSize: 13.5, color: 'var(--fg3)', margin: '8px 0 0' }}>
{failed ? t('ssoFailHint') : t('ssoMsg')}
</div>
{failed && msg && (
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'center' }}>
<ErrorLine text={msg} />
</div>
)}
</div>
</div>
);
}
+3 -4
View File
@@ -100,10 +100,9 @@ export default function UserCenter() {
setView('overview');
}
if (!ready) {
return <div style={{ minHeight: '100vh', background: 'var(--bg)' }} />;
}
if (!authed) {
// 静态导出无服务端会话:首屏(!ready)与未登录一律直接渲染登录页,避免出现空白
// 背景(慢网络下用户会看到"空的")。已登录用户(有 refresh)会话续期完成后再切面板。
if (!ready || !authed) {
return <Login onDone={() => { setAuthed(true); setView('overview'); }} />;
}
+1
View File
@@ -18,6 +18,7 @@ export const ERROR_TEXT: Record<string, { zh: string; en: string }> = {
device_limit: { zh: '设备数量已达上限', en: 'Device limit reached' },
device_not_found: { zh: '设备不存在', en: 'Device not found' },
unauthorized: { zh: '登录已失效,请重新登录', en: 'Session expired, please log in again' },
'auth.ticket_invalid': { zh: '登录票据无效或已过期,请重新从 App 打开', en: 'Login ticket is invalid or expired, please reopen from the app' },
network: { zh: '网络异常,请稍后重试', en: 'Network error, please retry' },
unknown: { zh: '操作失败,请稍后重试', en: 'Something went wrong, please retry' },
};
+15
View File
@@ -142,6 +142,21 @@ export class HttpClient implements ApiClient {
return session;
}
// App→Web 免登录:POST /v1/auth/web-ticket/exchange(公开端点,无 Authorization 头)。
// 服务端消费一次性票据后返回与 /v1/auth/login 相同的扁平 TokenPair(不会走 TOTP 分支——
// 票据本身已代表 App 内已完成的完整登录),故直接映射 session 并 setSession,与
// login()/loginTotp() 落地同一份会话状态。
async exchangeWebTicket(ticket: string): Promise<Session> {
const r = await this.request<RawTokenPair>('/v1/auth/web-ticket/exchange', {
method: 'POST',
body: { ticket },
auth: false,
});
const session = mapSession(r);
setSession(session);
return session;
}
async refresh(): Promise<Session> {
const rt = getRefreshToken();
if (!rt) throw new ApiError({ code: 'unauthorized', message_zh: '登录已失效', message_en: 'Session expired' });
+15
View File
@@ -64,6 +64,21 @@ export class MockClient implements ApiClient {
return s;
}
// 演示态:任意非 'invalid' 票据都兑换成功;'invalid' 用于验收失败态文案。
async exchangeWebTicket(ticket: string): Promise<Session> {
await delay(300);
if (ticket === 'invalid') {
throw new ApiError({
code: 'auth.ticket_invalid',
message_zh: '登录票据无效或已过期,请重新从 App 打开',
message_en: 'Login ticket is invalid or expired, please reopen from the app',
});
}
const s = makeSession();
setSession(s);
return s;
}
async refresh(): Promise<Session> {
await delay(150);
const s = makeSession();
+2
View File
@@ -82,6 +82,8 @@ export interface ApiClient {
login(email: string, password: string): Promise<LoginResult>;
/** 登录二段式:提交 TOTP 动态码换取 session */
loginTotp(pendingToken: string, code: string): Promise<Session>;
/** App→Web 免登录:凭一次性票据兑换与 login() 同形状的会话(公开端点,无需 TOTP) */
exchangeWebTicket(ticket: string): Promise<Session>;
refresh(): Promise<Session>;
getMe(): Promise<Me>;
getSubscription(): Promise<SubscriptionInfo>;
+6
View File
@@ -27,6 +27,12 @@ export const STRINGS: Record<string, Entry> = {
loginLocked: { zh: '失败次数过多,账户已临时锁定', en: 'Too many attempts — account temporarily locked' },
lockedCountdown: { zh: '请于 {s} 秒后重试', en: 'Try again in {s}s' },
/* sso(App→网页免登录落地页) */
ssoTitle: { zh: '正在登录…', en: 'Signing in…' },
ssoMsg: { zh: '正在验证来自 App 的登录票据', en: 'Verifying the sign-in ticket from the app' },
ssoFailTitle: { zh: '登录跳转失败', en: 'Sign-in redirect failed' },
ssoFailHint: { zh: '即将跳转到登录页,请手动登录', en: 'Redirecting to the login page, please sign in manually' },
/* overview */
greeting: { zh: '欢迎回来', en: 'Welcome back' },
curPlan: { zh: '当前套餐', en: 'Current plan' },
+3 -4
View File
@@ -6,14 +6,13 @@ import { SITE } from '../config/site';
interface Props { t: T }
const { t } = Astro.props;
// href 缺省 = 本轮未接入下载(iOS / macOS / Windows / Linux),按钮渲染为禁用态占位。
// 本轮仅 Android 有真实产物;Windows 待其 CI(需 windows runner)产出 exe 后接
// SITE.downloads.windows(已在 site.ts 备好)。
// href 缺省 = 本轮未接入下载(iOS / macOS / Linux),按钮渲染为禁用态占位。
// Android + Windows 已由客户端 CI 产出真实产物并部署到 /downloads。
const plats: { icon: string; name: string; ver: string; href?: string }[] = [
{ icon: 'smartphone', name: 'iOS', ver: 'iOS 16+' },
{ icon: 'smartphone', name: 'Android', ver: 'Android 9+', href: SITE.downloads.android },
{ icon: 'laptop', name: 'macOS', ver: 'macOS 12+' },
{ icon: 'monitor', name: 'Windows', ver: 'Win 10/11' },
{ icon: 'monitor', name: 'Windows', ver: 'Win 10/11', href: SITE.downloads.windows },
{ icon: 'terminal', name: 'Linux', ver: 'deb / rpm' },
];
---