From ea9845622ce2ba32b9dee17571aa76077981645f Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:34:54 +0800 Subject: [PATCH] split auth token issuance helpers --- internal/service/auth.go | 102 ---------------------------- internal/service/auth_tokens.go | 114 ++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 102 deletions(-) create mode 100644 internal/service/auth_tokens.go diff --git a/internal/service/auth.go b/internal/service/auth.go index 297643c..9029a6c 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -9,7 +9,6 @@ import ( "strings" "time" - "github.com/golang-jwt/jwt/v5" "go.uber.org/zap" "golang.org/x/crypto/bcrypt" @@ -242,107 +241,6 @@ func (s *AuthService) VerifyPassword(ctx context.Context, userID, password strin return nil } -// IssueToken signs a JWT for the given user (60min validity, includes tier). -func (s *AuthService) IssueToken(u *model.User) (string, error) { - claims := Claims{ - UserID: u.ID, - Role: u.Role, - Tier: u.Tier, - RegisteredClaims: jwt.RegisteredClaims{ - IssuedAt: jwt.NewNumericDate(time.Now()), - ExpiresAt: jwt.NewNumericDate(time.Now().Add(60 * time.Minute)), - Issuer: "mediastationgo", - Subject: u.ID, - }, - } - t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return t.SignedString([]byte(s.cfg.Secrets.JWTSecret)) -} - -const ( - ExternalPlaybackTokenPurpose = "external_play" - ExternalPlaybackTokenMinDuration = 15 * time.Minute - ExternalPlaybackTokenGraceDuration = 30 * time.Minute - ExternalPlaybackTokenUnknownDuration = 6 * time.Hour - ExternalPlaybackTokenMaxDuration = 24 * time.Hour -) - -func ExternalPlaybackTokenDurationForMedia(durationSec int) time.Duration { - if durationSec <= 0 { - return ExternalPlaybackTokenUnknownDuration - } - duration := time.Duration(durationSec)*time.Second + ExternalPlaybackTokenGraceDuration - if duration < ExternalPlaybackTokenMinDuration { - return ExternalPlaybackTokenMinDuration - } - if duration > ExternalPlaybackTokenMaxDuration { - return ExternalPlaybackTokenMaxDuration - } - return duration -} - -// IssueExternalPlaybackToken signs a short-lived, media-scoped JWT for URLs -// that are handed to third-party players. It must not be accepted as a -// reusable account/session token for arbitrary media playback. -func (s *AuthService) IssueExternalPlaybackToken(u *model.User, mediaID string, durationSec int) (string, error) { - mediaID = strings.TrimSpace(mediaID) - if mediaID == "" { - return "", errors.New("media id required") - } - claims := Claims{ - UserID: u.ID, - Role: u.Role, - Tier: u.Tier, - Purpose: ExternalPlaybackTokenPurpose, - MediaID: mediaID, - RegisteredClaims: jwt.RegisteredClaims{ - IssuedAt: jwt.NewNumericDate(time.Now()), - ExpiresAt: jwt.NewNumericDate(time.Now().Add(ExternalPlaybackTokenDurationForMedia(durationSec))), - Issuer: "mediastationgo", - Subject: u.ID, - }, - } - t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return t.SignedString([]byte(s.cfg.Secrets.JWTSecret)) -} - -// EmbyTokenDuration 是第三方 Emby/Jellyfin 客户端访问令牌的有效期。 -// Emby 协议没有 refresh token 机制——客户端登录一次后把 AccessToken -// 长期保存并反复使用,直到用户主动登出。若给它们签发 60 分钟的普通 -// access token,客户端每小时就会掉登录、无法播放、媒体库无法刷新。 -// 因此为这些设备签发长期令牌(与 refresh token 一致的 30 天),匹配 -// Emby 持久化令牌的语义。 -const EmbyTokenDuration = 30 * 24 * time.Hour - -// IssueEmbyToken 为第三方客户端(Emby/Jellyfin 兼容层)签发一个长期 -// JWT。它与普通 access token 使用相同的密钥与 Claims,因此沿用现有的 -// EmbyAuthRequired 校验逻辑,只是有效期更长。 -func (s *AuthService) IssueEmbyToken(u *model.User) (string, error) { - claims := Claims{ - UserID: u.ID, - Role: u.Role, - Tier: u.Tier, - RegisteredClaims: jwt.RegisteredClaims{ - IssuedAt: jwt.NewNumericDate(time.Now()), - ExpiresAt: jwt.NewNumericDate(time.Now().Add(EmbyTokenDuration)), - Issuer: "mediastationgo", - Subject: u.ID, - }, - } - t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return t.SignedString([]byte(s.cfg.Secrets.JWTSecret)) -} - -// RefreshTokens 使用刷新令牌获取新的令牌对。 -func (s *AuthService) RefreshTokens(ctx context.Context, refreshToken string) (*TokenPair, error) { - return s.tokenSvc.Refresh(ctx, refreshToken) -} - -// Logout 撤销用户的所有刷新令牌。 -func (s *AuthService) Logout(ctx context.Context, userID string) error { - return s.tokenSvc.RevokeAll(ctx, userID) -} - func hashPassword(p string) (string, error) { h, err := bcrypt.GenerateFromPassword([]byte(p), bcrypt.DefaultCost) if err != nil { diff --git a/internal/service/auth_tokens.go b/internal/service/auth_tokens.go new file mode 100644 index 0000000..24c6b20 --- /dev/null +++ b/internal/service/auth_tokens.go @@ -0,0 +1,114 @@ +// Package service — authentication token issuance helpers. +package service + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +// IssueToken signs a JWT for the given user (60min validity, includes tier). +func (s *AuthService) IssueToken(u *model.User) (string, error) { + claims := Claims{ + UserID: u.ID, + Role: u.Role, + Tier: u.Tier, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(60 * time.Minute)), + Issuer: "mediastationgo", + Subject: u.ID, + }, + } + t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return t.SignedString([]byte(s.cfg.Secrets.JWTSecret)) +} + +const ( + ExternalPlaybackTokenPurpose = "external_play" + ExternalPlaybackTokenMinDuration = 15 * time.Minute + ExternalPlaybackTokenGraceDuration = 30 * time.Minute + ExternalPlaybackTokenUnknownDuration = 6 * time.Hour + ExternalPlaybackTokenMaxDuration = 24 * time.Hour +) + +func ExternalPlaybackTokenDurationForMedia(durationSec int) time.Duration { + if durationSec <= 0 { + return ExternalPlaybackTokenUnknownDuration + } + duration := time.Duration(durationSec)*time.Second + ExternalPlaybackTokenGraceDuration + if duration < ExternalPlaybackTokenMinDuration { + return ExternalPlaybackTokenMinDuration + } + if duration > ExternalPlaybackTokenMaxDuration { + return ExternalPlaybackTokenMaxDuration + } + return duration +} + +// IssueExternalPlaybackToken signs a short-lived, media-scoped JWT for URLs +// that are handed to third-party players. It must not be accepted as a +// reusable account/session token for arbitrary media playback. +func (s *AuthService) IssueExternalPlaybackToken(u *model.User, mediaID string, durationSec int) (string, error) { + mediaID = strings.TrimSpace(mediaID) + if mediaID == "" { + return "", errors.New("media id required") + } + claims := Claims{ + UserID: u.ID, + Role: u.Role, + Tier: u.Tier, + Purpose: ExternalPlaybackTokenPurpose, + MediaID: mediaID, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(ExternalPlaybackTokenDurationForMedia(durationSec))), + Issuer: "mediastationgo", + Subject: u.ID, + }, + } + t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return t.SignedString([]byte(s.cfg.Secrets.JWTSecret)) +} + +// EmbyTokenDuration 是第三方 Emby/Jellyfin 客户端访问令牌的有效期。 +// Emby 协议没有 refresh token 机制——客户端登录一次后把 AccessToken +// 长期保存并反复使用,直到用户主动登出。若给它们签发 60 分钟的普通 +// access token,客户端每小时就会掉登录、无法播放、媒体库无法刷新。 +// 因此为这些设备签发长期令牌(与 refresh token 一致的 30 天),匹配 +// Emby 持久化令牌的语义。 +const EmbyTokenDuration = 30 * 24 * time.Hour + +// IssueEmbyToken 为第三方客户端(Emby/Jellyfin 兼容层)签发一个长期 +// JWT。它与普通 access token 使用相同的密钥与 Claims,因此沿用现有的 +// EmbyAuthRequired 校验逻辑,只是有效期更长。 +func (s *AuthService) IssueEmbyToken(u *model.User) (string, error) { + claims := Claims{ + UserID: u.ID, + Role: u.Role, + Tier: u.Tier, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(EmbyTokenDuration)), + Issuer: "mediastationgo", + Subject: u.ID, + }, + } + t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return t.SignedString([]byte(s.cfg.Secrets.JWTSecret)) +} + +// RefreshTokens 使用刷新令牌获取新的令牌对。 +func (s *AuthService) RefreshTokens(ctx context.Context, refreshToken string) (*TokenPair, error) { + return s.tokenSvc.Refresh(ctx, refreshToken) +} + +// Logout 撤销用户的所有刷新令牌。 +func (s *AuthService) Logout(ctx context.Context, userID string) error { + return s.tokenSvc.RevokeAll(ctx, userID) +}