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"` 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(), 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 }