e045e00fbf
Add self-service password change that re-hashes the credential and bumps a per-user session version so all existing access/refresh tokens are rejected. Session version is tracked in Redis with an in-memory fallback and enforced in middleware and refresh. Scope prompt rules and rule groups to a brand: new brand_id column (migrated to a "未归属" fallback brand for existing rows), brand-filtered queries/indexes, and brand-aware admin-web tabs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
537 lines
17 KiB
Go
537 lines
17 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
|
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
|
)
|
|
|
|
type AuthService struct {
|
|
repo repository.AuthRepository
|
|
plans repository.TenantPlanRepository
|
|
kolProfiles repository.KolProfileRepository
|
|
jwt *auth.Manager
|
|
sessions *auth.SessionStore
|
|
loginGuard *auth.LoginGuard
|
|
cipher *auth.PasswordCipher
|
|
}
|
|
|
|
func NewAuthService(
|
|
repo repository.AuthRepository,
|
|
plans repository.TenantPlanRepository,
|
|
kolProfiles repository.KolProfileRepository,
|
|
jwt *auth.Manager,
|
|
sessions *auth.SessionStore,
|
|
loginGuards ...*auth.LoginGuard,
|
|
) *AuthService {
|
|
var loginGuard *auth.LoginGuard
|
|
if len(loginGuards) > 0 {
|
|
loginGuard = loginGuards[0]
|
|
}
|
|
return &AuthService{
|
|
repo: repo,
|
|
plans: plans,
|
|
kolProfiles: kolProfiles,
|
|
jwt: jwt,
|
|
sessions: sessions,
|
|
loginGuard: loginGuard,
|
|
}
|
|
}
|
|
|
|
func (s *AuthService) WithPasswordCipher(cipher *auth.PasswordCipher) *AuthService {
|
|
s.cipher = cipher
|
|
return s
|
|
}
|
|
|
|
func (s *AuthService) sessionVersion(ctx context.Context, userID int64) (int64, error) {
|
|
if s == nil || s.sessions == nil {
|
|
return 0, nil
|
|
}
|
|
return s.sessions.SessionVersion(ctx, userID)
|
|
}
|
|
|
|
func (s *AuthService) revokeUserSessions(ctx context.Context, userID int64) error {
|
|
if s == nil || s.sessions == nil {
|
|
return nil
|
|
}
|
|
_, err := s.sessions.BumpSessionVersion(ctx, userID)
|
|
return err
|
|
}
|
|
|
|
type LoginRequest struct {
|
|
Identifier string `json:"identifier"`
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
EncryptedPassword string `json:"encrypted_password"`
|
|
PasswordKeyID string `json:"password_key_id"`
|
|
IP string `json:"-"`
|
|
}
|
|
|
|
type LoginResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
ExpiresAt int64 `json:"expires_at"`
|
|
User UserInfo `json:"user"`
|
|
}
|
|
|
|
type UserInfo struct {
|
|
ID int64 `json:"id"`
|
|
Email *string `json:"email"`
|
|
Phone string `json:"phone"`
|
|
Name *string `json:"name"`
|
|
AvatarURL *string `json:"avatar_url"`
|
|
TenantID int64 `json:"tenant_id"`
|
|
PrimaryWorkspaceID int64 `json:"primary_workspace_id"`
|
|
TenantRole string `json:"tenant_role"`
|
|
Permissions []string `json:"permissions"`
|
|
Membership *MembershipInfo `json:"membership"`
|
|
KolProfile *KolProfileBrief `json:"kol_profile"`
|
|
}
|
|
|
|
type MembershipInfo struct {
|
|
PlanCode string `json:"plan_code"`
|
|
PlanName string `json:"plan_name"`
|
|
Status string `json:"status"`
|
|
Blocked bool `json:"blocked"`
|
|
BlockedReason *string `json:"blocked_reason"`
|
|
StartAt *string `json:"start_at"`
|
|
EndAt *string `json:"end_at"`
|
|
ArticleGenerationLimit int `json:"article_generation_limit"`
|
|
ArticleQuotaCycle string `json:"article_quota_cycle"`
|
|
AIPointsMonthly int `json:"ai_points_monthly"`
|
|
AIPointBaseChars int `json:"ai_point_base_chars"`
|
|
CompanyLimit int `json:"company_limit"`
|
|
ImageStorageBytes int64 `json:"image_storage_bytes"`
|
|
ContactAdminOnExpiry bool `json:"contact_admin_on_expiry"`
|
|
}
|
|
|
|
type KolProfileBrief struct {
|
|
ID int64 `json:"id"`
|
|
DisplayName string `json:"display_name"`
|
|
Status string `json:"status"`
|
|
MarketEnabled bool `json:"market_enabled"`
|
|
}
|
|
|
|
const userBlockedReasonDisabled = "user_disabled"
|
|
|
|
func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
|
identifier := strings.TrimSpace(req.Identifier)
|
|
if identifier == "" {
|
|
identifier = strings.TrimSpace(req.Email)
|
|
}
|
|
if identifier == "" {
|
|
return nil, response.ErrBadRequest(40001, "invalid_params", "login identifier is required")
|
|
}
|
|
password, err := s.loginPassword(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
permit, err := s.acquireLoginPermit(ctx, identifier, req.IP)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
credentialsVerified := false
|
|
defer func() {
|
|
if permit == nil {
|
|
return
|
|
}
|
|
if credentialsVerified {
|
|
permit.RecordSuccess(ctx)
|
|
return
|
|
}
|
|
permit.Release(ctx)
|
|
}()
|
|
|
|
user, err := s.repo.GetUserByLoginIdentifier(ctx, identifier)
|
|
if err != nil {
|
|
permit.RecordFailure(ctx)
|
|
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect")
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
|
permit.RecordFailure(ctx)
|
|
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect")
|
|
}
|
|
credentialsVerified = true
|
|
|
|
membership, err := s.repo.GetTenantMembership(ctx, user.ID)
|
|
if err != nil {
|
|
return nil, response.ErrForbidden(40301, "no_tenant", "user has no active tenant membership")
|
|
}
|
|
|
|
sessionVersion, err := s.sessionVersion(ctx, user.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load session version: %w", err)
|
|
}
|
|
pair, err := s.jwt.IssueWithSessionVersion(user.ID, membership.TenantID, membership.PrimaryWorkspaceID, membership.TenantRole, sessionVersion)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("issue token: %w", err)
|
|
}
|
|
|
|
tokenHash := auth.HashToken(pair.RefreshToken)
|
|
if err := s.sessions.SaveRefresh(ctx, pair.RefreshJTI, tokenHash, s.jwt.RefreshTTL()); err != nil {
|
|
return nil, fmt.Errorf("save refresh: %w", err)
|
|
}
|
|
|
|
kolProfile, err := s.loadKolProfileBrief(ctx, membership.TenantID, user.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
memberInfo, err := s.loadMembershipInfo(ctx, membership.TenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
memberInfo = applyUserDisabledMembership(memberInfo, user.Status)
|
|
|
|
return &LoginResponse{
|
|
AccessToken: pair.AccessToken,
|
|
RefreshToken: pair.RefreshToken,
|
|
ExpiresAt: pair.ExpiresAt,
|
|
User: UserInfo{
|
|
ID: user.ID,
|
|
Email: user.Email,
|
|
Phone: user.Phone,
|
|
Name: user.Name,
|
|
AvatarURL: user.AvatarURL,
|
|
TenantID: membership.TenantID,
|
|
PrimaryWorkspaceID: membership.PrimaryWorkspaceID,
|
|
TenantRole: membership.TenantRole,
|
|
Permissions: auth.PermissionsForRole(membership.TenantRole),
|
|
Membership: memberInfo,
|
|
KolProfile: kolProfile,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (s *AuthService) PasswordPublicKey() *auth.PasswordCipherPublicKey {
|
|
if s == nil || s.cipher == nil {
|
|
return nil
|
|
}
|
|
publicKey := s.cipher.PublicKey()
|
|
return &publicKey
|
|
}
|
|
|
|
func (s *AuthService) loginPassword(req LoginRequest) (string, error) {
|
|
if strings.TrimSpace(req.EncryptedPassword) != "" {
|
|
if s.cipher == nil {
|
|
return "", response.ErrBadRequest(40002, "password_cipher_unavailable", "encrypted password is not supported by this server")
|
|
}
|
|
password, err := s.cipher.Decrypt(req.EncryptedPassword, req.PasswordKeyID)
|
|
if err != nil {
|
|
return "", response.ErrBadRequest(40003, "invalid_encrypted_password", "encrypted password is invalid")
|
|
}
|
|
if password == "" {
|
|
return "", response.ErrBadRequest(40001, "invalid_params", "password is required")
|
|
}
|
|
return password, nil
|
|
}
|
|
if req.Password == "" {
|
|
return "", response.ErrBadRequest(40001, "invalid_params", "password is required")
|
|
}
|
|
return req.Password, nil
|
|
}
|
|
|
|
func (s *AuthService) acquireLoginPermit(ctx context.Context, identifier, ip string) (*auth.LoginPermit, error) {
|
|
if s.loginGuard == nil {
|
|
return nil, nil
|
|
}
|
|
permit, err := s.loginGuard.Acquire(ctx, auth.LoginGuardSubject{
|
|
Identifier: identifier,
|
|
IP: ip,
|
|
})
|
|
if err == nil {
|
|
return permit, nil
|
|
}
|
|
|
|
var blocked *auth.LoginGuardBlockedError
|
|
if errors.As(err, &blocked) {
|
|
detail := fmt.Sprintf("请 %d 秒后重试", blocked.RetryAfterSeconds())
|
|
switch blocked.Reason {
|
|
case auth.LoginGuardBlockedInProgress:
|
|
return nil, response.ErrTooManyRequests(42912, "login_in_progress", detail)
|
|
case auth.LoginGuardBlockedLocked:
|
|
return nil, response.ErrTooManyRequests(42911, "login_locked", detail)
|
|
default:
|
|
return nil, response.ErrTooManyRequests(42910, "login_rate_limited", detail)
|
|
}
|
|
}
|
|
if errors.Is(err, auth.ErrLoginGuardUnavailable) {
|
|
return nil, response.ErrServiceUnavailable(50305, "login_guard_unavailable", "login protection is temporarily unavailable")
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
type RefreshRequest struct {
|
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
|
}
|
|
|
|
type RefreshResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
ExpiresAt int64 `json:"expires_at"`
|
|
}
|
|
|
|
type ChangePasswordRequest struct {
|
|
OldPassword string `json:"old_password"`
|
|
NewPassword string `json:"new_password"`
|
|
EncryptedOldPassword string `json:"encrypted_old_password"`
|
|
EncryptedNewPassword string `json:"encrypted_new_password"`
|
|
PasswordKeyID string `json:"password_key_id"`
|
|
}
|
|
|
|
func (s *AuthService) Refresh(ctx context.Context, req RefreshRequest) (*RefreshResponse, error) {
|
|
claims, err := s.jwt.Parse(req.RefreshToken)
|
|
if err != nil || claims.Subject != "refresh" {
|
|
return nil, response.ErrUnauthorized(40120, "invalid_refresh_token", "refresh token is invalid or expired")
|
|
}
|
|
|
|
membership, err := s.repo.GetTenantMembershipByTenantAndUser(ctx, claims.TenantID, claims.UserID)
|
|
if err != nil {
|
|
return nil, response.ErrUnauthorized(40123, "membership_revoked", "tenant membership is no longer active")
|
|
}
|
|
|
|
currentVersion, err := s.sessionVersion(ctx, claims.UserID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load session version: %w", err)
|
|
}
|
|
if claims.SessionVersion < currentVersion {
|
|
return nil, response.ErrUnauthorized(40124, "session_revoked", "session has been revoked")
|
|
}
|
|
|
|
pair, err := s.jwt.IssueWithSessionVersion(membership.UserID, membership.TenantID, membership.PrimaryWorkspaceID, membership.TenantRole, currentVersion)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("issue token: %w", err)
|
|
}
|
|
|
|
if err := s.sessions.RotateRefresh(
|
|
ctx,
|
|
claims.ID,
|
|
auth.HashToken(req.RefreshToken),
|
|
pair.RefreshJTI,
|
|
auth.HashToken(pair.RefreshToken),
|
|
s.jwt.RefreshTTL(),
|
|
); err != nil {
|
|
switch err {
|
|
case auth.ErrRefreshSessionNotFound:
|
|
return nil, response.ErrUnauthorized(40121, "refresh_session_expired", "refresh session not found")
|
|
case auth.ErrRefreshTokenMismatch:
|
|
return nil, response.ErrUnauthorized(40122, "refresh_token_mismatch", "refresh token does not match")
|
|
default:
|
|
return nil, fmt.Errorf("rotate refresh: %w", err)
|
|
}
|
|
}
|
|
|
|
return &RefreshResponse{
|
|
AccessToken: pair.AccessToken,
|
|
RefreshToken: pair.RefreshToken,
|
|
ExpiresAt: pair.ExpiresAt,
|
|
}, nil
|
|
}
|
|
|
|
func (s *AuthService) ChangePassword(ctx context.Context, actor auth.Actor, req ChangePasswordRequest) error {
|
|
oldPassword, newPassword, err := s.changePasswordValues(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if oldPassword == "" {
|
|
return response.ErrBadRequest(40001, "invalid_params", "old password is required")
|
|
}
|
|
if len(newPassword) < 8 {
|
|
return response.ErrBadRequest(40011, "weak_password", "新密码至少 8 位")
|
|
}
|
|
|
|
user, err := s.repo.GetUserByID(ctx, actor.UserID)
|
|
if err != nil {
|
|
return response.ErrNotFound(40401, "user_not_found", "user not found")
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(oldPassword)); err != nil {
|
|
return response.ErrUnauthorized(40111, "invalid_old_password", "原密码错误")
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.repo.UpdateUserPassword(ctx, actor.UserID, string(hash)); err != nil {
|
|
return fmt.Errorf("update user password: %w", err)
|
|
}
|
|
if err := s.revokeUserSessions(ctx, actor.UserID); err != nil {
|
|
return fmt.Errorf("revoke user sessions: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *AuthService) changePasswordValues(req ChangePasswordRequest) (string, string, error) {
|
|
if strings.TrimSpace(req.EncryptedOldPassword) != "" || strings.TrimSpace(req.EncryptedNewPassword) != "" {
|
|
if strings.TrimSpace(req.EncryptedOldPassword) == "" || strings.TrimSpace(req.EncryptedNewPassword) == "" {
|
|
return "", "", response.ErrBadRequest(40001, "invalid_params", "old password and new password are required")
|
|
}
|
|
if s.cipher == nil {
|
|
return "", "", response.ErrBadRequest(40002, "password_cipher_unavailable", "encrypted password is not supported by this server")
|
|
}
|
|
oldPassword, err := s.cipher.Decrypt(req.EncryptedOldPassword, req.PasswordKeyID)
|
|
if err != nil {
|
|
return "", "", response.ErrBadRequest(40003, "invalid_encrypted_password", "encrypted password is invalid")
|
|
}
|
|
newPassword, err := s.cipher.Decrypt(req.EncryptedNewPassword, req.PasswordKeyID)
|
|
if err != nil {
|
|
return "", "", response.ErrBadRequest(40003, "invalid_encrypted_password", "encrypted password is invalid")
|
|
}
|
|
return oldPassword, newPassword, nil
|
|
}
|
|
return req.OldPassword, req.NewPassword, nil
|
|
}
|
|
|
|
func (s *AuthService) Me(ctx context.Context, actor auth.Actor) (*UserInfo, error) {
|
|
user, err := s.repo.GetUserByID(ctx, actor.UserID)
|
|
if err != nil {
|
|
return nil, response.ErrNotFound(40401, "user_not_found", "user not found")
|
|
}
|
|
|
|
kolProfile, err := s.loadKolProfileBrief(ctx, actor.TenantID, actor.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
memberInfo, err := s.loadMembershipInfo(ctx, actor.TenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
memberInfo = applyUserDisabledMembership(memberInfo, user.Status)
|
|
|
|
return &UserInfo{
|
|
ID: user.ID,
|
|
Email: user.Email,
|
|
Phone: user.Phone,
|
|
Name: user.Name,
|
|
AvatarURL: user.AvatarURL,
|
|
TenantID: actor.TenantID,
|
|
PrimaryWorkspaceID: actor.PrimaryWorkspaceID,
|
|
TenantRole: actor.Role,
|
|
Permissions: auth.PermissionsForRole(actor.Role),
|
|
Membership: memberInfo,
|
|
KolProfile: kolProfile,
|
|
}, nil
|
|
}
|
|
|
|
func applyUserDisabledMembership(info *MembershipInfo, userStatus string) *MembershipInfo {
|
|
if strings.EqualFold(strings.TrimSpace(userStatus), "active") {
|
|
return info
|
|
}
|
|
|
|
reason := userBlockedReasonDisabled
|
|
if info == nil {
|
|
return &MembershipInfo{
|
|
Status: "disabled",
|
|
Blocked: true,
|
|
BlockedReason: &reason,
|
|
}
|
|
}
|
|
|
|
next := *info
|
|
next.Status = "disabled"
|
|
next.Blocked = true
|
|
next.BlockedReason = &reason
|
|
next.ContactAdminOnExpiry = true
|
|
return &next
|
|
}
|
|
|
|
func (s *AuthService) loadKolProfileBrief(ctx context.Context, tenantID, userID int64) (*KolProfileBrief, error) {
|
|
if s.kolProfiles == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
profile, err := s.kolProfiles.GetByUser(ctx, tenantID, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get kol profile: %w", err)
|
|
}
|
|
if profile == nil || profile.Status != "active" {
|
|
return nil, nil
|
|
}
|
|
|
|
return &KolProfileBrief{
|
|
ID: profile.ID,
|
|
DisplayName: profile.DisplayName,
|
|
Status: profile.Status,
|
|
MarketEnabled: profile.MarketEnabled,
|
|
}, nil
|
|
}
|
|
|
|
func (s *AuthService) loadMembershipInfo(ctx context.Context, tenantID int64) (*MembershipInfo, error) {
|
|
if s.plans == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
access, err := s.plans.GetTenantPlanAccess(ctx, tenantID, now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if access == nil {
|
|
reason := "subscription_required"
|
|
return &MembershipInfo{
|
|
Status: "inactive",
|
|
Blocked: true,
|
|
BlockedReason: &reason,
|
|
}, nil
|
|
}
|
|
|
|
status := "active"
|
|
if !access.HasActiveAccess(now) {
|
|
status = "expired"
|
|
}
|
|
blockedReason := access.BlockedReason(now)
|
|
var blockedReasonPtr *string
|
|
if blockedReason != "" {
|
|
blockedReasonPtr = &blockedReason
|
|
}
|
|
|
|
return &MembershipInfo{
|
|
PlanCode: access.PlanCode,
|
|
PlanName: access.PlanName,
|
|
Status: status,
|
|
Blocked: !access.HasActiveAccess(now),
|
|
BlockedReason: blockedReasonPtr,
|
|
StartAt: formatTimeValuePtr(access.StartAt),
|
|
EndAt: formatTimeValuePtr(access.EndAt),
|
|
ArticleGenerationLimit: access.ArticleGenerationLimit(),
|
|
ArticleQuotaCycle: access.ArticleQuotaCycle(),
|
|
AIPointsMonthly: access.AIPointsMonthlyLimit(),
|
|
AIPointBaseChars: access.AIPointBaseChars(),
|
|
CompanyLimit: access.BrandLimit(),
|
|
ImageStorageBytes: access.ImageStorageBytes(),
|
|
ContactAdminOnExpiry: access.Policy.ContactAdminOnExpiry,
|
|
}, nil
|
|
}
|
|
|
|
func formatTimeValuePtr(value time.Time) *string {
|
|
if value.IsZero() {
|
|
return nil
|
|
}
|
|
formatted := value.UTC().Format(time.RFC3339)
|
|
return &formatted
|
|
}
|
|
|
|
func (s *AuthService) Logout(ctx context.Context, accessToken string) error {
|
|
claims, err := s.jwt.Parse(accessToken)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
if claims.Subject == "access" && claims.ExpiresAt != nil {
|
|
ttl := time.Until(claims.ExpiresAt.Time)
|
|
if ttl > 0 {
|
|
_ = s.sessions.Blacklist(ctx, claims.ID, ttl)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|