- Add UserRepository and LinkedAccountRepository protocols to protocols.py - Add UserEntry and LinkedAccountEntry DTOs for service layer decoupling - Implement PostgresUserRepository and PostgresLinkedAccountRepository - Refactor UserService to use constructor-injected repositories - Add get_user_service factory and UserServiceDep to API deps - Update auth.py and users.py endpoints to use UserServiceDep - Rewrite tests to use FastAPI dependency overrides (no monkey patching) This follows the established repository pattern used by DeckService and CollectionService, enabling future offline fork support. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
662 lines
21 KiB
Python
662 lines
21 KiB
Python
"""Authentication API router for Mantimon TCG.
|
|
|
|
This module provides endpoints for OAuth authentication:
|
|
- OAuth login redirects (Google, Discord)
|
|
- OAuth callbacks (token exchange)
|
|
- Token refresh
|
|
- Logout
|
|
|
|
OAuth Flow:
|
|
1. Client calls GET /auth/{provider} to get redirect URL
|
|
2. Client redirects user to OAuth provider
|
|
3. Provider redirects back to GET /auth/{provider}/callback
|
|
4. Server exchanges code for tokens, creates/fetches user
|
|
5. Server returns JWT access + refresh tokens
|
|
|
|
Example:
|
|
# Start OAuth flow
|
|
GET /api/auth/google?redirect_uri=https://play.mantimon.com/login/callback
|
|
|
|
# After OAuth callback, refresh tokens
|
|
POST /api/auth/refresh
|
|
{"refresh_token": "..."}
|
|
|
|
# Logout
|
|
POST /api/auth/logout
|
|
{"refresh_token": "..."}
|
|
"""
|
|
|
|
import secrets
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, status
|
|
from fastapi.responses import RedirectResponse
|
|
|
|
from app.api.deps import CurrentUser, UserServiceDep
|
|
from app.config import settings
|
|
from app.db.redis import get_redis
|
|
from app.schemas.auth import RefreshTokenRequest, TokenResponse
|
|
from app.services.jwt_service import (
|
|
create_access_token,
|
|
create_refresh_token,
|
|
get_refresh_token_expiration,
|
|
get_token_expiration_seconds,
|
|
verify_refresh_token,
|
|
)
|
|
from app.services.oauth.discord import DiscordOAuthError, discord_oauth
|
|
from app.services.oauth.google import GoogleOAuthError, google_oauth
|
|
from app.services.token_store import token_store
|
|
from app.services.user_service import AccountLinkingError
|
|
|
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
|
|
# OAuth state TTL (5 minutes)
|
|
OAUTH_STATE_TTL = 300
|
|
|
|
|
|
async def _store_oauth_state(state: str, provider: str, redirect_uri: str) -> None:
|
|
"""Store OAuth state in Redis for CSRF validation."""
|
|
async with get_redis() as redis:
|
|
key = f"oauth_state:{state}"
|
|
value = f"{provider}:{redirect_uri}"
|
|
await redis.setex(key, OAUTH_STATE_TTL, value)
|
|
|
|
|
|
async def _validate_oauth_state(state: str, provider: str) -> str | None:
|
|
"""Validate and consume OAuth state, returning redirect_uri if valid."""
|
|
async with get_redis() as redis:
|
|
key = f"oauth_state:{state}"
|
|
value = await redis.get(key)
|
|
if not value:
|
|
return None
|
|
|
|
# Delete state (one-time use)
|
|
await redis.delete(key)
|
|
|
|
# Parse and validate
|
|
stored_provider, redirect_uri = value.split(":", 1)
|
|
if stored_provider != provider:
|
|
return None
|
|
|
|
return redirect_uri
|
|
|
|
|
|
async def _create_tokens_for_user(user_id) -> TokenResponse:
|
|
"""Create access and refresh tokens for a user."""
|
|
from uuid import UUID
|
|
|
|
if isinstance(user_id, str):
|
|
user_id = UUID(user_id)
|
|
|
|
access_token = create_access_token(user_id)
|
|
refresh_token, jti = create_refresh_token(user_id)
|
|
|
|
# Store refresh token in Redis for revocation tracking
|
|
expires_at = get_refresh_token_expiration()
|
|
await token_store.store_refresh_token(user_id, jti, expires_at)
|
|
|
|
return TokenResponse(
|
|
access_token=access_token,
|
|
refresh_token=refresh_token,
|
|
expires_in=get_token_expiration_seconds(),
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Google OAuth
|
|
# =============================================================================
|
|
|
|
|
|
@router.get("/google")
|
|
async def google_auth_redirect(
|
|
redirect_uri: str = Query(..., description="URI to redirect to after OAuth completes"),
|
|
) -> RedirectResponse:
|
|
"""Start Google OAuth flow.
|
|
|
|
Redirects the user to Google's OAuth consent screen.
|
|
|
|
Args:
|
|
redirect_uri: Where to redirect after successful authentication.
|
|
This is YOUR app's callback, not the OAuth callback.
|
|
|
|
Returns:
|
|
Redirect to Google OAuth authorization URL.
|
|
|
|
Raises:
|
|
HTTPException: 501 if Google OAuth is not configured.
|
|
"""
|
|
if not google_oauth.is_configured():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
|
detail="Google OAuth is not configured",
|
|
)
|
|
|
|
# Generate state for CSRF protection
|
|
state = secrets.token_urlsafe(32)
|
|
await _store_oauth_state(state, "google", redirect_uri)
|
|
|
|
# Build OAuth callback URL (our server endpoint)
|
|
# The redirect_uri param here is where Google sends the code
|
|
# Must be an absolute URL for OAuth providers
|
|
oauth_callback = f"{settings.base_url}/api/auth/google/callback"
|
|
|
|
# Get authorization URL
|
|
auth_url = google_oauth.get_authorization_url(
|
|
redirect_uri=oauth_callback,
|
|
state=state,
|
|
)
|
|
|
|
return RedirectResponse(url=auth_url, status_code=status.HTTP_302_FOUND)
|
|
|
|
|
|
@router.get("/google/callback")
|
|
async def google_auth_callback(
|
|
user_service: UserServiceDep,
|
|
code: str = Query(..., description="Authorization code from Google"),
|
|
state: str = Query(..., description="State parameter for CSRF validation"),
|
|
) -> TokenResponse:
|
|
"""Handle Google OAuth callback.
|
|
|
|
Exchanges the authorization code for tokens and creates/fetches the user.
|
|
|
|
Args:
|
|
code: Authorization code from Google.
|
|
state: State parameter for CSRF validation.
|
|
|
|
Returns:
|
|
JWT access and refresh tokens.
|
|
|
|
Raises:
|
|
HTTPException: 400 if state is invalid or OAuth fails.
|
|
"""
|
|
# Validate state
|
|
redirect_uri = await _validate_oauth_state(state, "google")
|
|
if redirect_uri is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid or expired state parameter",
|
|
)
|
|
|
|
try:
|
|
# Exchange code for user info
|
|
oauth_callback = f"{settings.base_url}/api/auth/google/callback"
|
|
user_info = await google_oauth.get_user_info(code, oauth_callback)
|
|
|
|
# Get or create user
|
|
user, _created = await user_service.get_or_create_from_oauth(user_info)
|
|
|
|
# Update last login
|
|
await user_service.update_last_login(user.id)
|
|
|
|
# Create tokens
|
|
return await _create_tokens_for_user(user.id)
|
|
|
|
except GoogleOAuthError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Google OAuth failed: {e}",
|
|
) from None
|
|
|
|
|
|
# =============================================================================
|
|
# Discord OAuth
|
|
# =============================================================================
|
|
|
|
|
|
@router.get("/discord")
|
|
async def discord_auth_redirect(
|
|
redirect_uri: str = Query(..., description="URI to redirect to after OAuth completes"),
|
|
) -> RedirectResponse:
|
|
"""Start Discord OAuth flow.
|
|
|
|
Redirects the user to Discord's OAuth consent screen.
|
|
|
|
Args:
|
|
redirect_uri: Where to redirect after successful authentication.
|
|
|
|
Returns:
|
|
Redirect to Discord OAuth authorization URL.
|
|
|
|
Raises:
|
|
HTTPException: 501 if Discord OAuth is not configured.
|
|
"""
|
|
if not discord_oauth.is_configured():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
|
detail="Discord OAuth is not configured",
|
|
)
|
|
|
|
# Generate state for CSRF protection
|
|
state = secrets.token_urlsafe(32)
|
|
await _store_oauth_state(state, "discord", redirect_uri)
|
|
|
|
# Build OAuth callback URL (must be absolute for OAuth providers)
|
|
oauth_callback = f"{settings.base_url}/api/auth/discord/callback"
|
|
|
|
# Get authorization URL
|
|
auth_url = discord_oauth.get_authorization_url(
|
|
redirect_uri=oauth_callback,
|
|
state=state,
|
|
)
|
|
|
|
return RedirectResponse(url=auth_url, status_code=status.HTTP_302_FOUND)
|
|
|
|
|
|
@router.get("/discord/callback")
|
|
async def discord_auth_callback(
|
|
user_service: UserServiceDep,
|
|
code: str = Query(..., description="Authorization code from Discord"),
|
|
state: str = Query(..., description="State parameter for CSRF validation"),
|
|
) -> RedirectResponse:
|
|
"""Handle Discord OAuth callback.
|
|
|
|
Exchanges the authorization code for tokens and creates/fetches the user.
|
|
Redirects to the frontend with tokens in URL fragment.
|
|
|
|
Args:
|
|
code: Authorization code from Discord.
|
|
state: State parameter for CSRF validation.
|
|
|
|
Returns:
|
|
Redirect to frontend with access and refresh tokens.
|
|
|
|
Raises:
|
|
HTTPException: 400 if state is invalid or OAuth fails.
|
|
"""
|
|
# Validate state
|
|
redirect_uri = await _validate_oauth_state(state, "discord")
|
|
if redirect_uri is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid or expired state parameter",
|
|
)
|
|
|
|
try:
|
|
# Exchange code for user info
|
|
oauth_callback = f"{settings.base_url}/api/auth/discord/callback"
|
|
user_info = await discord_oauth.get_user_info(code, oauth_callback)
|
|
|
|
# Get or create user
|
|
user, _created = await user_service.get_or_create_from_oauth(user_info)
|
|
|
|
# Update last login
|
|
await user_service.update_last_login(user.id)
|
|
|
|
# Create tokens
|
|
tokens = await _create_tokens_for_user(user.id)
|
|
|
|
# Redirect to frontend with tokens in URL fragment (not query params for security)
|
|
# Fragment is not sent to server, only accessible by frontend JavaScript
|
|
fragment = f"access_token={tokens.access_token}&refresh_token={tokens.refresh_token}&expires_in={tokens.expires_in}"
|
|
return RedirectResponse(
|
|
url=f"{redirect_uri}#{fragment}",
|
|
status_code=status.HTTP_302_FOUND,
|
|
)
|
|
|
|
except DiscordOAuthError as e:
|
|
# Redirect to frontend with error
|
|
return RedirectResponse(
|
|
url=f"{redirect_uri}?error=oauth_failed&message={e}",
|
|
status_code=status.HTTP_302_FOUND,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Token Management
|
|
# =============================================================================
|
|
|
|
|
|
@router.post("/refresh", response_model=TokenResponse)
|
|
async def refresh_tokens(
|
|
user_service: UserServiceDep,
|
|
request: RefreshTokenRequest,
|
|
) -> TokenResponse:
|
|
"""Refresh access token using refresh token.
|
|
|
|
Validates the refresh token and issues a new access token.
|
|
The refresh token itself is NOT rotated (same token can be used
|
|
until it expires or is revoked).
|
|
|
|
Args:
|
|
request: Contains the refresh token.
|
|
|
|
Returns:
|
|
New JWT access token (same refresh token).
|
|
|
|
Raises:
|
|
HTTPException: 401 if refresh token is invalid or revoked.
|
|
"""
|
|
# Verify refresh token
|
|
result = verify_refresh_token(request.refresh_token)
|
|
if result is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid refresh token",
|
|
)
|
|
|
|
user_id, jti = result
|
|
|
|
# Check if token is revoked
|
|
is_valid = await token_store.is_token_valid(user_id, jti)
|
|
if not is_valid:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Refresh token has been revoked",
|
|
)
|
|
|
|
# Verify user still exists
|
|
user = await user_service.get_by_id(user_id)
|
|
if user is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="User not found",
|
|
)
|
|
|
|
# Create new access token (keep same refresh token)
|
|
access_token = create_access_token(user_id)
|
|
|
|
return TokenResponse(
|
|
access_token=access_token,
|
|
refresh_token=request.refresh_token, # Return same refresh token
|
|
expires_in=get_token_expiration_seconds(),
|
|
)
|
|
|
|
|
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def logout(
|
|
request: RefreshTokenRequest,
|
|
) -> None:
|
|
"""Logout by revoking the refresh token.
|
|
|
|
The access token will continue to work until it expires,
|
|
but the refresh token cannot be used to get new tokens.
|
|
|
|
Args:
|
|
request: Contains the refresh token to revoke.
|
|
"""
|
|
# Verify refresh token to get user_id and jti
|
|
result = verify_refresh_token(request.refresh_token)
|
|
if result is None:
|
|
# Token is invalid, but that's fine - user is effectively logged out
|
|
return
|
|
|
|
user_id, jti = result
|
|
|
|
# Revoke the token
|
|
await token_store.revoke_token(user_id, jti)
|
|
|
|
|
|
@router.post("/logout-all", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def logout_all(
|
|
user: CurrentUser,
|
|
) -> None:
|
|
"""Logout from all devices by revoking all refresh tokens.
|
|
|
|
Requires authentication (uses current access token).
|
|
All refresh tokens for the user will be revoked.
|
|
|
|
Args:
|
|
user: Current authenticated user.
|
|
"""
|
|
from uuid import UUID
|
|
|
|
user_id = UUID(user.id) if isinstance(user.id, str) else user.id
|
|
await token_store.revoke_all_user_tokens(user_id)
|
|
|
|
|
|
# =============================================================================
|
|
# Account Linking
|
|
# =============================================================================
|
|
|
|
|
|
async def _store_link_state(state: str, provider: str, user_id: str, redirect_uri: str) -> None:
|
|
"""Store OAuth state for account linking (includes user_id)."""
|
|
async with get_redis() as redis:
|
|
key = f"oauth_link_state:{state}"
|
|
value = f"{provider}:{user_id}:{redirect_uri}"
|
|
await redis.setex(key, OAUTH_STATE_TTL, value)
|
|
|
|
|
|
async def _validate_link_state(state: str, provider: str) -> tuple[str, str] | None:
|
|
"""Validate and consume link state, returning (user_id, redirect_uri) if valid."""
|
|
async with get_redis() as redis:
|
|
key = f"oauth_link_state:{state}"
|
|
value = await redis.get(key)
|
|
if not value:
|
|
return None
|
|
|
|
# Delete state (one-time use)
|
|
await redis.delete(key)
|
|
|
|
# Parse and validate
|
|
parts = value.split(":", 2)
|
|
if len(parts) != 3:
|
|
return None
|
|
|
|
stored_provider, user_id, redirect_uri = parts
|
|
if stored_provider != provider:
|
|
return None
|
|
|
|
return user_id, redirect_uri
|
|
|
|
|
|
@router.get("/link/google")
|
|
async def google_link_redirect(
|
|
user: CurrentUser,
|
|
redirect_uri: str = Query(..., description="URI to redirect to after linking"),
|
|
) -> RedirectResponse:
|
|
"""Start Google OAuth flow for account linking.
|
|
|
|
Requires authentication. Links Google account to the current user.
|
|
|
|
Args:
|
|
redirect_uri: Where to redirect after linking completes.
|
|
|
|
Returns:
|
|
Redirect to Google OAuth authorization URL.
|
|
|
|
Raises:
|
|
HTTPException: 501 if Google OAuth is not configured.
|
|
HTTPException: 400 if Google is already the primary provider.
|
|
"""
|
|
if not google_oauth.is_configured():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
|
detail="Google OAuth is not configured",
|
|
)
|
|
|
|
# Check if Google is already their primary provider
|
|
if user.oauth_provider == "google":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Google is already your primary login provider",
|
|
)
|
|
|
|
# Generate state for CSRF protection
|
|
state = secrets.token_urlsafe(32)
|
|
await _store_link_state(state, "google", str(user.id), redirect_uri)
|
|
|
|
# Build OAuth callback URL for linking
|
|
oauth_callback = f"{settings.base_url}/api/auth/link/google/callback"
|
|
|
|
# Get authorization URL
|
|
auth_url = google_oauth.get_authorization_url(
|
|
redirect_uri=oauth_callback,
|
|
state=state,
|
|
)
|
|
|
|
return RedirectResponse(url=auth_url, status_code=status.HTTP_302_FOUND)
|
|
|
|
|
|
@router.get("/link/google/callback")
|
|
async def google_link_callback(
|
|
user_service: UserServiceDep,
|
|
code: str = Query(..., description="Authorization code from Google"),
|
|
state: str = Query(..., description="State parameter for CSRF validation"),
|
|
) -> RedirectResponse:
|
|
"""Handle Google OAuth callback for account linking.
|
|
|
|
Exchanges the authorization code and links the Google account to the user.
|
|
|
|
Args:
|
|
code: Authorization code from Google.
|
|
state: State parameter for CSRF validation.
|
|
|
|
Returns:
|
|
Redirect to the original redirect_uri with success/error query params.
|
|
"""
|
|
# Validate state
|
|
result = await _validate_link_state(state, "google")
|
|
if result is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid or expired state parameter",
|
|
)
|
|
|
|
user_id_str, redirect_uri = result
|
|
|
|
try:
|
|
# Exchange code for user info
|
|
oauth_callback = f"{settings.base_url}/api/auth/link/google/callback"
|
|
oauth_info = await google_oauth.get_user_info(code, oauth_callback)
|
|
|
|
# Get the user
|
|
from uuid import UUID
|
|
|
|
user_id = UUID(user_id_str)
|
|
user = await user_service.get_by_id(user_id)
|
|
if user is None:
|
|
return RedirectResponse(
|
|
url=f"{redirect_uri}?error=user_not_found",
|
|
status_code=status.HTTP_302_FOUND,
|
|
)
|
|
|
|
# Link the account
|
|
await user_service.link_oauth_account(user.id, user.oauth_provider, oauth_info)
|
|
|
|
return RedirectResponse(
|
|
url=f"{redirect_uri}?linked=google",
|
|
status_code=status.HTTP_302_FOUND,
|
|
)
|
|
|
|
except GoogleOAuthError as e:
|
|
return RedirectResponse(
|
|
url=f"{redirect_uri}?error=oauth_failed&message={e}",
|
|
status_code=status.HTTP_302_FOUND,
|
|
)
|
|
except AccountLinkingError as e:
|
|
return RedirectResponse(
|
|
url=f"{redirect_uri}?error=linking_failed&message={e}",
|
|
status_code=status.HTTP_302_FOUND,
|
|
)
|
|
|
|
|
|
@router.get("/link/discord")
|
|
async def discord_link_redirect(
|
|
user: CurrentUser,
|
|
redirect_uri: str = Query(..., description="URI to redirect to after linking"),
|
|
) -> RedirectResponse:
|
|
"""Start Discord OAuth flow for account linking.
|
|
|
|
Requires authentication. Links Discord account to the current user.
|
|
|
|
Args:
|
|
redirect_uri: Where to redirect after linking completes.
|
|
|
|
Returns:
|
|
Redirect to Discord OAuth authorization URL.
|
|
|
|
Raises:
|
|
HTTPException: 501 if Discord OAuth is not configured.
|
|
HTTPException: 400 if Discord is already the primary provider.
|
|
"""
|
|
if not discord_oauth.is_configured():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
|
detail="Discord OAuth is not configured",
|
|
)
|
|
|
|
# Check if Discord is already their primary provider
|
|
if user.oauth_provider == "discord":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Discord is already your primary login provider",
|
|
)
|
|
|
|
# Generate state for CSRF protection
|
|
state = secrets.token_urlsafe(32)
|
|
await _store_link_state(state, "discord", str(user.id), redirect_uri)
|
|
|
|
# Build OAuth callback URL for linking
|
|
oauth_callback = f"{settings.base_url}/api/auth/link/discord/callback"
|
|
|
|
# Get authorization URL
|
|
auth_url = discord_oauth.get_authorization_url(
|
|
redirect_uri=oauth_callback,
|
|
state=state,
|
|
)
|
|
|
|
return RedirectResponse(url=auth_url, status_code=status.HTTP_302_FOUND)
|
|
|
|
|
|
@router.get("/link/discord/callback")
|
|
async def discord_link_callback(
|
|
user_service: UserServiceDep,
|
|
code: str = Query(..., description="Authorization code from Discord"),
|
|
state: str = Query(..., description="State parameter for CSRF validation"),
|
|
) -> RedirectResponse:
|
|
"""Handle Discord OAuth callback for account linking.
|
|
|
|
Exchanges the authorization code and links the Discord account to the user.
|
|
|
|
Args:
|
|
code: Authorization code from Discord.
|
|
state: State parameter for CSRF validation.
|
|
|
|
Returns:
|
|
Redirect to the original redirect_uri with success/error query params.
|
|
"""
|
|
# Validate state
|
|
result = await _validate_link_state(state, "discord")
|
|
if result is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid or expired state parameter",
|
|
)
|
|
|
|
user_id_str, redirect_uri = result
|
|
|
|
try:
|
|
# Exchange code for user info
|
|
oauth_callback = f"{settings.base_url}/api/auth/link/discord/callback"
|
|
oauth_info = await discord_oauth.get_user_info(code, oauth_callback)
|
|
|
|
# Get the user
|
|
from uuid import UUID
|
|
|
|
user_id = UUID(user_id_str)
|
|
user = await user_service.get_by_id(user_id)
|
|
if user is None:
|
|
return RedirectResponse(
|
|
url=f"{redirect_uri}?error=user_not_found",
|
|
status_code=status.HTTP_302_FOUND,
|
|
)
|
|
|
|
# Link the account
|
|
await user_service.link_oauth_account(user.id, user.oauth_provider, oauth_info)
|
|
|
|
return RedirectResponse(
|
|
url=f"{redirect_uri}?linked=discord",
|
|
status_code=status.HTTP_302_FOUND,
|
|
)
|
|
|
|
except DiscordOAuthError as e:
|
|
return RedirectResponse(
|
|
url=f"{redirect_uri}?error=oauth_failed&message={e}",
|
|
status_code=status.HTTP_302_FOUND,
|
|
)
|
|
except AccountLinkingError as e:
|
|
return RedirectResponse(
|
|
url=f"{redirect_uri}?error=linking_failed&message={e}",
|
|
status_code=status.HTTP_302_FOUND,
|
|
)
|