diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 48feac7..0b41889 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -270,6 +270,7 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv v1.Post("/auth/register", authHandler.Register) v1.Post("/auth/login", authHandler.Login) v1.Post("/auth/refresh", authHandler.Refresh) + v1.Post("/auth/logout", authHandler.Logout) } // Webhook: HMAC-authenticated, no JWT. @@ -283,6 +284,8 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv protected.Route("/me", func(me chi.Router) { me.Get("/", accountAPI.GetMe) // 子路由根,避免与 Route("/me") 冲突致 404 devicesHandler.RegisterRoutes(me) + // Web 用户中心调用 /v1/me/redeem;app 仍用 /v1/redeem。两者同处理器。 + me.Post("/redeem", redeemHandler.ServeHTTP) }) protected.Post("/redeem", redeemHandler.ServeHTTP) protected.Get("/usage", usageHandler.ServeHTTP) diff --git a/server/internal/auth/handler.go b/server/internal/auth/handler.go index 99c0d71..3d1a636 100644 --- a/server/internal/auth/handler.go +++ b/server/internal/auth/handler.go @@ -28,6 +28,16 @@ func (h *Handler) RegisterRoutes(r chi.Router) { r.Post("/auth/register", h.Register) r.Post("/auth/login", h.Login) r.Post("/auth/refresh", h.Refresh) + r.Post("/auth/logout", h.Logout) +} + +// Logout handles POST /v1/auth/logout. The refresh token to revoke is taken from +// the X-Refresh-Token header (the access token in Authorization is not enough — +// revocation keys on the refresh JTI). Always 204; the client clears its session +// regardless of the server-side outcome. +func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { + h.svc.Logout(r.Context(), r.Header.Get("X-Refresh-Token")) + w.WriteHeader(http.StatusNoContent) } // ---- request/response bodies (mirror openapi.yaml) ---- diff --git a/server/internal/auth/service.go b/server/internal/auth/service.go index 139e380..b5ed649 100644 --- a/server/internal/auth/service.go +++ b/server/internal/auth/service.go @@ -261,6 +261,15 @@ func (s *Service) Login(ctx context.Context, rawEmail, password, ip string) (*To return pair, 0, nil } +// Logout revokes the given refresh token. Idempotent: an empty, unknown, or +// malformed token is a no-op (the client clears its own session regardless). +func (s *Service) Logout(ctx context.Context, refreshToken string) { + if refreshToken == "" { + return + } + _ = s.tokens.RevokeRefresh(ctx, refreshToken) +} + // Refresh validates and rotates a refresh token. func (s *Service) Refresh(ctx context.Context, refreshToken string) (*TokenPair, *apierr.Error) { if refreshToken == "" { diff --git a/server/internal/auth/token.go b/server/internal/auth/token.go index 8bff725..5727bc1 100644 --- a/server/internal/auth/token.go +++ b/server/internal/auth/token.go @@ -234,6 +234,17 @@ func (tm *TokenManager) Revoke(ctx context.Context, jti string) error { return tm.rdb.Del(ctx, refreshKeyPrefix+jti).Err() } +// RevokeRefresh parses a refresh token and revokes its JTI. Used by logout. +// Returns an error only when the token is structurally invalid; a missing or +// already-rotated JTI is a no-op (logout is idempotent). +func (tm *TokenManager) RevokeRefresh(ctx context.Context, refreshToken string) error { + claims, err := tm.parse(refreshToken, typRefresh) + if err != nil { + return err + } + return tm.Revoke(ctx, claims.ID) +} + // whitelist stores the refresh JTI with the refresh TTL. func (tm *TokenManager) whitelist(ctx context.Context, jti string, userID int64) error { if err := tm.rdb.Set(ctx, refreshKeyPrefix+jti, userID, tm.refreshTTL).Err(); err != nil { diff --git a/server/internal/httpapi/account.go b/server/internal/httpapi/account.go index 2a90b03..2b52c44 100644 --- a/server/internal/httpapi/account.go +++ b/server/internal/httpapi/account.go @@ -1,6 +1,7 @@ package httpapi import ( + "context" "database/sql" "encoding/json" "net/http" @@ -21,29 +22,43 @@ func NewAccountAPI(db *sql.DB) *AccountAPI { return &AccountAPI{db: db} } // ─── GET /v1/me ────────────────────────────────────────────────────────────── type meResponse struct { - UUID string `json:"uuid"` - Email string `json:"email"` - DpUUID string `json:"dp_uuid"` - Plan string `json:"plan"` // "free" | "pro" | "team" - ExpireAt *string `json:"expire_at"` // RFC3339 UTC, null = no active sub + UUID string `json:"uuid"` + Email string `json:"email"` + DpUUID string `json:"dp_uuid"` + Plan string `json:"plan"` // "free" | "pro" | "team" + // expire_at kept for the existing app; expires_at is the same value for the + // web user-center (both RFC3339 UTC, null = no active sub). + ExpireAt *string `json:"expire_at"` + ExpiresAt *string `json:"expires_at"` + // Web user-center fields. + DevicesUsed int `json:"devices_used"` + DevicesMax int `json:"devices_max"` + QuotaTodayMin *int `json:"quota_today_min"` // null = unlimited (pro/team) + DataTodayGB float64 `json:"data_today_gb"` + WeeklyGB []float64 `json:"weekly_gb"` // last 7 days, oldest→newest + TOTPEnabled bool `json:"totp_enabled"` } +const bytesPerGB = 1024.0 * 1024.0 * 1024.0 + // GetMe handles GET /v1/me. func (a *AccountAPI) GetMe(w http.ResponseWriter, r *http.Request) { - uid, ok := auth.UserIDFromContext(r.Context()) + ctx := r.Context() + uid, ok := auth.UserIDFromContext(ctx) if !ok { apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) return } var ( - uuid string - email string - dpUUID string + uuid string + email string + dpUUID string + totpEnabled bool ) - if err := a.db.QueryRowContext(r.Context(), - `SELECT uuid, email, dp_uuid FROM users WHERE id = ? AND status = 'active'`, uid, - ).Scan(&uuid, &email, &dpUUID); err == sql.ErrNoRows { + if err := a.db.QueryRowContext(ctx, + `SELECT uuid, email, dp_uuid, totp_enabled FROM users WHERE id = ? AND status = 'active'`, uid, + ).Scan(&uuid, &email, &dpUUID, &totpEnabled); err == sql.ErrNoRows { apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound) return } else if err != nil { @@ -51,39 +66,105 @@ func (a *AccountAPI) GetMe(w http.ResponseWriter, r *http.Request) { return } - // Best active subscription. - var planCode string - var expiresAt sql.NullTime - err := a.db.QueryRowContext(r.Context(), ` - SELECT p.code, s.expires_at + // Best active subscription → plan code + limits + expiry. Falls back to free. + var ( + planCode string + maxDevices int + dailyMinutes sql.NullInt64 + expiresAt sql.NullTime + ) + err := a.db.QueryRowContext(ctx, ` + SELECT p.code, p.max_devices, p.daily_minutes, s.expires_at FROM subscriptions s JOIN plans p ON p.id = s.plan_id WHERE s.user_id = ? AND s.expires_at > UTC_TIMESTAMP() ORDER BY s.expires_at DESC LIMIT 1 - `, uid).Scan(&planCode, &expiresAt) + `, uid).Scan(&planCode, &maxDevices, &dailyMinutes, &expiresAt) if err == sql.ErrNoRows { planCode = "free" + if err := a.db.QueryRowContext(ctx, + `SELECT max_devices, daily_minutes FROM plans WHERE code = 'free'`, + ).Scan(&maxDevices, &dailyMinutes); err != nil && err != sql.ErrNoRows { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } } else if err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } + var devicesUsed int + _ = a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM devices WHERE user_id = ?`, uid).Scan(&devicesUsed) + + // Today's usage (UTC). Missing row → zero. + var todayBytes uint64 + var todayMinutes int + _ = a.db.QueryRowContext(ctx, ` + SELECT COALESCE(bytes_up, 0) + COALESCE(bytes_down, 0), COALESCE(minutes_used, 0) + FROM usage_daily WHERE user_id = ? AND date = UTC_DATE() + `, uid).Scan(&todayBytes, &todayMinutes) + resp := meResponse{ - UUID: uuid, - Email: email, - DpUUID: dpUUID, - Plan: planCode, + UUID: uuid, + Email: email, + DpUUID: dpUUID, + Plan: planCode, + DevicesUsed: devicesUsed, + DevicesMax: maxDevices, + DataTodayGB: float64(todayBytes) / bytesPerGB, + WeeklyGB: a.weeklyGB(ctx, uid), + TOTPEnabled: totpEnabled, } if expiresAt.Valid { s := expiresAt.Time.UTC().Format(time.RFC3339) resp.ExpireAt = &s + resp.ExpiresAt = &s + } + // quota_today_min: null when the plan is unlimited, else remaining minutes. + if dailyMinutes.Valid { + rem := int(dailyMinutes.Int64) - todayMinutes + if rem < 0 { + rem = 0 + } + resp.QuotaTodayMin = &rem } w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(resp) } +// weeklyGB returns total GB per day for the last 7 UTC days (oldest→newest), +// filling days with no usage row as 0. +func (a *AccountAPI) weeklyGB(ctx context.Context, uid int64) []float64 { + out := make([]float64, 7) + rows, err := a.db.QueryContext(ctx, ` + SELECT date, COALESCE(bytes_up, 0) + COALESCE(bytes_down, 0) + FROM usage_daily + WHERE user_id = ? AND date >= UTC_DATE() - INTERVAL 6 DAY + `, uid) + if err != nil { + return out + } + defer rows.Close() + + byDay := make(map[string]uint64, 7) + for rows.Next() { + var d time.Time + var b uint64 + if err := rows.Scan(&d, &b); err != nil { + return out + } + byDay[d.UTC().Format("2006-01-02")] = b + } + now := time.Now().UTC() + for i := 0; i < 7; i++ { + day := now.AddDate(0, 0, -(6 - i)).Format("2006-01-02") + out[i] = float64(byDay[day]) / bytesPerGB + } + return out +} + // ─── GET /v1/plans ─────────────────────────────────────────────────────────── type planResponse struct { diff --git a/server/migrations/000013_user_sub_token_totp.down.sql b/server/migrations/000013_user_sub_token_totp.down.sql new file mode 100644 index 0000000..c49981a --- /dev/null +++ b/server/migrations/000013_user_sub_token_totp.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE users + DROP COLUMN totp_enabled, + DROP COLUMN totp_secret_enc, + DROP COLUMN sub_token; diff --git a/server/migrations/000013_user_sub_token_totp.up.sql b/server/migrations/000013_user_sub_token_totp.up.sql new file mode 100644 index 0000000..335d323 --- /dev/null +++ b/server/migrations/000013_user_sub_token_totp.up.sql @@ -0,0 +1,10 @@ +-- Web 用户中心所需的用户级字段: +-- sub_token 个人订阅 URL 的不可猜测 token(GET /v1/me/subscription 返回, +-- /sub/{token} 据此渲染该用户的 sing-box 订阅配置)。可轮换。 +-- totp_secret_enc TOTP 密钥(AES-256-GCM 加密存储,复用 admin/crypto)。NULL=未设置。 +-- totp_enabled 是否已启用两步验证(verify 通过后置 true)。 +-- 全部可空/带默认,存量用户无需回填即生效(首次访问按需生成 sub_token)。 +ALTER TABLE users + ADD COLUMN sub_token CHAR(36) NULL UNIQUE AFTER dp_uuid, + ADD COLUMN totp_secret_enc VARBINARY(255) NULL AFTER sub_token, + ADD COLUMN totp_enabled BOOLEAN NOT NULL DEFAULT FALSE AFTER totp_secret_enc;