de30497f59
- Implemented tenant and user management features including: - Tenant creation and management with associated migrations. - User creation and management with associated migrations. - Tenant membership management with associated migrations. - Platform user roles management with associated migrations. - Quota management with associated migrations. - Article and template management with associated migrations. - Added HTTP handlers for templates and workspaces. - Created tests for protected and public routes. - Introduced a script to check tenant scope in SQL queries. - Documented task plan for backend completion and frontend foundation.
166 lines
4.8 KiB
Go
166 lines
4.8 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
|
|
jwt *auth.Manager
|
|
sessions *auth.SessionStore
|
|
}
|
|
|
|
func NewAuthService(repo repository.AuthRepository, jwt *auth.Manager, sessions *auth.SessionStore) *AuthService {
|
|
return &AuthService{repo: repo, 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"`
|
|
TenantRole string `json:"tenant_role"`
|
|
Permissions []string `json:"permissions"`
|
|
}
|
|
|
|
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.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)
|
|
}
|
|
|
|
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,
|
|
TenantRole: membership.TenantRole,
|
|
Permissions: auth.PermissionsForRole(membership.TenantRole),
|
|
},
|
|
}, 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.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")
|
|
}
|
|
|
|
return &UserInfo{
|
|
ID: actor.UserID,
|
|
Email: user.Email,
|
|
Name: user.Name,
|
|
AvatarURL: user.AvatarURL,
|
|
TenantID: actor.TenantID,
|
|
TenantRole: actor.Role,
|
|
Permissions: auth.PermissionsForRole(actor.Role),
|
|
}, nil
|
|
}
|
|
|
|
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
|
|
}
|