Files
geo/server/internal/tenant/app/auth_service.go
T
root 794a2d89db feat(server/membership): add ai_points plan policy fields
Introduces ai_points_monthly and ai_point_base_chars on the membership
plan policy, exposes them through TenantPlanAccess and MembershipInfo,
and seeds Pro with a 1500-point monthly allowance across the local,
server, and deploy configs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:33:30 +08:00

307 lines
9.2 KiB
Go

package app
import (
"context"
"fmt"
"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
}
func NewAuthService(
repo repository.AuthRepository,
plans repository.TenantPlanRepository,
kolProfiles repository.KolProfileRepository,
jwt *auth.Manager,
sessions *auth.SessionStore,
) *AuthService {
return &AuthService{
repo: repo,
plans: plans,
kolProfiles: kolProfiles,
jwt: jwt,
sessions: sessions,
}
}
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
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"`
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"`
}
func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
user, err := s.repo.GetUserByEmail(ctx, req.Email)
if err != nil {
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect")
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect")
}
membership, err := s.repo.GetTenantMembership(ctx, user.ID)
if err != nil {
return nil, response.ErrForbidden(40301, "no_tenant", "user has no active tenant membership")
}
pair, err := s.jwt.Issue(user.ID, membership.TenantID, membership.PrimaryWorkspaceID, membership.TenantRole)
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
}
return &LoginResponse{
AccessToken: pair.AccessToken,
RefreshToken: pair.RefreshToken,
ExpiresAt: pair.ExpiresAt,
User: UserInfo{
ID: user.ID,
Email: user.Email,
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
}
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"`
}
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")
}
pair, err := s.jwt.Issue(claims.UserID, claims.TenantID, claims.PrimaryWorkspaceID, claims.Role)
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) 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
}
return &UserInfo{
ID: user.ID,
Email: user.Email,
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 (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
}