mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
28aa466107
- 新增 6 位临时密码生成与验证接口,支持 TV 及客户端快速登录 - 媒体及剧集详情补充 People 演职员信息解析与返回 - 优化剧集背景图与海报标签继承机制及播放时长兜底逻辑 - 增加临时密码登录相关的单元测试
371 lines
10 KiB
Go
371 lines
10 KiB
Go
// Package service — authentication / user management.
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"github.com/truewhile/MeBox/internal/config"
|
|
"github.com/truewhile/MeBox/internal/helper"
|
|
"github.com/truewhile/MeBox/internal/model"
|
|
"github.com/truewhile/MeBox/internal/repository"
|
|
)
|
|
|
|
type temporaryPasswordEntry struct {
|
|
userID string
|
|
username string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
// AuthService handles registration, login, and JWT issuance.
|
|
type AuthService struct {
|
|
cfg *config.Config
|
|
log *zap.Logger
|
|
repo *repository.Container
|
|
tokenSvc *TokenService
|
|
permissionSvc *PermissionService
|
|
|
|
tempPassMu sync.RWMutex
|
|
tempPasswords map[string]temporaryPasswordEntry
|
|
}
|
|
|
|
// NewAuthService is the constructor.
|
|
func NewAuthService(cfg *config.Config, log *zap.Logger, repo *repository.Container, tokenSvc *TokenService, permissionSvc *PermissionService) *AuthService {
|
|
return &AuthService{
|
|
cfg: cfg,
|
|
log: log,
|
|
repo: repo,
|
|
tokenSvc: tokenSvc,
|
|
permissionSvc: permissionSvc,
|
|
tempPasswords: make(map[string]temporaryPasswordEntry),
|
|
}
|
|
}
|
|
|
|
// Common service-level errors.
|
|
var (
|
|
ErrInvalidCredentials = errors.New("invalid username or password")
|
|
ErrUsernameTaken = errors.New("username already taken")
|
|
ErrUserInactive = errors.New("user account is inactive")
|
|
ErrUserLimitReached = errors.New("user limit reached")
|
|
ErrUserExpired = errors.New("user account has expired")
|
|
)
|
|
|
|
// MaxUsers is kept for tests that seed up to the default cap.
|
|
const MaxUsers = DefaultMaxUsers
|
|
|
|
// SeedAdmin makes sure at least one admin user exists. It mirrors the
|
|
// legacy default behaviour: if no admin row is found we create
|
|
// `admin / admin123` (overridable through ADMIN_INITIAL_PASSWORD) and warn.
|
|
func (s *AuthService) SeedAdmin(ctx context.Context) error {
|
|
n, err := s.repo.User.CountAdmins(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n > 0 {
|
|
return nil
|
|
}
|
|
pwd := os.Getenv("ADMIN_INITIAL_PASSWORD")
|
|
if pwd == "" {
|
|
pwd = "admin123"
|
|
}
|
|
hash, err := hashPassword(pwd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
user := &model.User{
|
|
Username: "admin",
|
|
PasswordHash: hash,
|
|
Role: "admin",
|
|
Tier: "plus",
|
|
HideAdult: true,
|
|
ForcePasswordReset: pwd == "admin123",
|
|
}
|
|
if err := s.repo.User.Create(ctx, user); err != nil {
|
|
return err
|
|
}
|
|
// 确保管理员有权限记录
|
|
_, _ = s.permissionSvc.EnsureForUser(ctx, user.ID)
|
|
s.log.Warn("default admin created — change the password after first login",
|
|
zap.String("username", "admin"),
|
|
zap.String("password_source", "ADMIN_INITIAL_PASSWORD or admin123"),
|
|
)
|
|
return nil
|
|
}
|
|
|
|
// Register creates a new user. The first registered user is auto-promoted to
|
|
// admin to support fresh installs that did not run SeedAdmin.
|
|
func (s *AuthService) Register(ctx context.Context, username, password string) (*model.User, *TokenPair, error) {
|
|
username = strings.TrimSpace(username)
|
|
if username == "" || password == "" {
|
|
return nil, nil, fmt.Errorf("username and password required")
|
|
}
|
|
if existing, err := s.repo.User.FindByUsername(ctx, username); err != nil {
|
|
return nil, nil, err
|
|
} else if existing != nil {
|
|
return nil, nil, ErrUsernameTaken
|
|
}
|
|
if err := s.repo.User.ReleaseDeletedUsername(ctx, username); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
limit, err := LoadMaxUsers(ctx, s.repo)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if n, err := s.repo.User.Count(ctx); err != nil {
|
|
return nil, nil, err
|
|
} else if n >= int64(limit) {
|
|
return nil, nil, ErrUserLimitReached
|
|
}
|
|
hash, err := hashPassword(password)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
role := "user"
|
|
if n, err := s.repo.User.CountAdmins(ctx); err == nil && n == 0 {
|
|
role = "admin"
|
|
}
|
|
u := &model.User{
|
|
Username: username,
|
|
PasswordHash: hash,
|
|
Role: role,
|
|
Tier: "free",
|
|
HideAdult: true,
|
|
}
|
|
if err := s.repo.User.Create(ctx, u); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
// 自动为新用户创建默认权限
|
|
_, _ = s.permissionSvc.EnsureForUser(ctx, u.ID)
|
|
// 签发令牌对
|
|
tokens, err := s.tokenSvc.IssuePair(ctx, u.ID, u.Role, u.Tier)
|
|
if err != nil {
|
|
return u, nil, nil // 用户已创建,令牌签发失败不影响注册成功
|
|
}
|
|
return u, tokens, nil
|
|
}
|
|
|
|
// LoginResponse 登录响应结构。
|
|
type LoginResponse struct {
|
|
User *model.User `json:"user"`
|
|
Tokens *TokenPair `json:"tokens"`
|
|
}
|
|
|
|
// Login validates credentials and returns the user + a fresh JWT token pair.
|
|
func (s *AuthService) Login(ctx context.Context, username, password string) (*LoginResponse, error) {
|
|
u, err := s.repo.User.FindByUsername(ctx, username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if u == nil {
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
// 检查用户是否激活
|
|
if !u.IsActive {
|
|
return nil, ErrUserInactive
|
|
}
|
|
// 账号到期则停用登录,直到管理员或兑换码续期。
|
|
if u.ExpiredAt != nil && time.Now().After(*u.ExpiredAt) {
|
|
return nil, ErrUserExpired
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
// 签发令牌对
|
|
tokens, err := s.tokenSvc.IssuePairBestEffort(ctx, u.ID, u.Role, u.Tier)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.touchLoginBestEffort(u.ID)
|
|
return &LoginResponse{User: u, Tokens: tokens}, nil
|
|
}
|
|
|
|
func (s *AuthService) touchLoginBestEffort(userID string) {
|
|
if s == nil || s.repo == nil || s.repo.User == nil || strings.TrimSpace(userID) == "" {
|
|
return
|
|
}
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
helper.Run(s.log, "auth.touchLogin", func() {
|
|
if err := s.repo.User.TouchLogin(ctx, userID); err != nil && s.log != nil {
|
|
s.log.Debug("touch login delayed", zap.String("user_id", userID), zap.Error(err))
|
|
}
|
|
})
|
|
}()
|
|
}
|
|
|
|
// ChangePassword updates the user password if the old one matches.
|
|
func (s *AuthService) ChangePassword(ctx context.Context, userID, oldPwd, newPwd string) error {
|
|
if strings.TrimSpace(newPwd) == "" || len(newPwd) < 6 {
|
|
return errors.New("new password must be at least 6 characters")
|
|
}
|
|
u, err := s.repo.User.FindByID(ctx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if u == nil {
|
|
return ErrInvalidCredentials
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(oldPwd)); err != nil {
|
|
return ErrInvalidCredentials
|
|
}
|
|
hash, err := hashPassword(newPwd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.repo.User.UpdatePassword(ctx, userID, hash)
|
|
}
|
|
|
|
// ResetPassword lets an administrator set a new password without knowing the
|
|
// user's old password.
|
|
func (s *AuthService) ResetPassword(ctx context.Context, userID, newPwd string) error {
|
|
if strings.TrimSpace(userID) == "" {
|
|
return errors.New("missing user id")
|
|
}
|
|
if strings.TrimSpace(newPwd) == "" || len(newPwd) < 6 {
|
|
return errors.New("new password must be at least 6 characters")
|
|
}
|
|
u, err := s.repo.User.FindByID(ctx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if u == nil {
|
|
return errors.New("user not found")
|
|
}
|
|
hash, err := hashPassword(newPwd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.repo.User.UpdatePassword(ctx, userID, hash)
|
|
}
|
|
|
|
// VerifyPassword checks a user's current password without mutating account
|
|
// state. It is used for sensitive self-service actions such as hiding adult
|
|
// libraries or deleting play profiles.
|
|
func (s *AuthService) VerifyPassword(ctx context.Context, userID, password string) error {
|
|
u, err := s.repo.User.FindByID(ctx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if u == nil || strings.TrimSpace(password) == "" {
|
|
return ErrInvalidCredentials
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
|
|
return ErrInvalidCredentials
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func hashPassword(p string) (string, error) {
|
|
h, err := bcrypt.GenerateFromPassword([]byte(p), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(h), nil
|
|
}
|
|
|
|
const temporaryPasswordTTL = 5 * time.Minute
|
|
|
|
// CreateTemporaryPassword 为指定用户生成一个 6 位数字的临时登录密码(有效期 5 分钟),
|
|
// 供 Emby 电视端/客户端进行无键盘或快速输入登录。
|
|
func (s *AuthService) CreateTemporaryPassword(ctx context.Context, userID string) (string, int, error) {
|
|
if s == nil || s.repo == nil {
|
|
return "", 0, errors.New("auth service unavailable")
|
|
}
|
|
user, err := s.repo.User.FindByID(ctx, userID)
|
|
if err != nil || user == nil {
|
|
return "", 0, ErrInvalidCredentials
|
|
}
|
|
if !user.IsActive {
|
|
return "", 0, ErrUserInactive
|
|
}
|
|
if user.ExpiredAt != nil && time.Now().After(*user.ExpiredAt) {
|
|
return "", 0, ErrUserExpired
|
|
}
|
|
|
|
n, err := rand.Int(rand.Reader, big.NewInt(900000))
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
code := fmt.Sprintf("%06d", n.Int64()+100000)
|
|
|
|
s.tempPassMu.Lock()
|
|
defer s.tempPassMu.Unlock()
|
|
now := time.Now()
|
|
for k, v := range s.tempPasswords {
|
|
if now.After(v.expiresAt) {
|
|
delete(s.tempPasswords, k)
|
|
}
|
|
}
|
|
s.tempPasswords[code] = temporaryPasswordEntry{
|
|
userID: user.ID,
|
|
username: user.Username,
|
|
expiresAt: now.Add(temporaryPasswordTTL),
|
|
}
|
|
|
|
return code, int(temporaryPasswordTTL.Seconds()), nil
|
|
}
|
|
|
|
// VerifyAndConsumeTemporaryPassword 校验并消费临时登录密码(阅后即焚)。
|
|
func (s *AuthService) VerifyAndConsumeTemporaryPassword(ctx context.Context, username, code string) (*model.User, bool) {
|
|
if s == nil || s.repo == nil {
|
|
return nil, false
|
|
}
|
|
code = strings.TrimSpace(code)
|
|
if len(code) != 6 {
|
|
return nil, false
|
|
}
|
|
s.tempPassMu.Lock()
|
|
entry, ok := s.tempPasswords[code]
|
|
if ok {
|
|
delete(s.tempPasswords, code)
|
|
}
|
|
s.tempPassMu.Unlock()
|
|
|
|
if !ok || time.Now().After(entry.expiresAt) {
|
|
return nil, false
|
|
}
|
|
|
|
if strings.TrimSpace(username) != "" && !strings.EqualFold(strings.TrimSpace(username), entry.username) {
|
|
return nil, false
|
|
}
|
|
|
|
user, err := s.repo.User.FindByID(ctx, entry.userID)
|
|
if err != nil || user == nil || !user.IsActive {
|
|
return nil, false
|
|
}
|
|
if user.ExpiredAt != nil && time.Now().After(*user.ExpiredAt) {
|
|
return nil, false
|
|
}
|
|
return user, true
|
|
}
|
|
|
|
// LoginWithTemporaryPassword 尝试使用 6 位数字临时登录密码 (OTP) 进行登录。
|
|
func (s *AuthService) LoginWithTemporaryPassword(ctx context.Context, username, code string) (*LoginResponse, error) {
|
|
user, ok := s.VerifyAndConsumeTemporaryPassword(ctx, username, code)
|
|
if !ok || user == nil {
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
if s.tokenSvc == nil {
|
|
return nil, errors.New("token service unavailable")
|
|
}
|
|
tokens, err := s.tokenSvc.IssuePair(ctx, user.ID, user.Role, user.Tier)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &LoginResponse{
|
|
User: user,
|
|
Tokens: tokens,
|
|
}, nil
|
|
}
|