feat(auth): 后台登录、角色权限与会话访问策略
管理员与客服分角色登录,权限由后端判定;前端补齐登录、人员与组织管理页面。
This commit is contained in:
1 parent
9eb8613c05
commit
556b650c7a
123 files changed
+25293
No files matched your search
@@ -0,0 +1,527 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.widget import Widget
|
||||
from app.models.customer import Customer
|
||||
from app.core.config import settings
|
||||
from app.core.security import (
|
||||
verify_conversation_token,
|
||||
get_existing_valid_token_jti,
|
||||
revoke_token as security_revoke_token,
|
||||
)
|
||||
from app.services import conversation_token
|
||||
from app.repositories.widget_app import WidgetAppRepository
|
||||
from app.repositories.customer import CustomerRepository
|
||||
from pydantic import BaseModel, field_validator
|
||||
import json
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# custom_data is embedded in both the JWT and the customer's stored meta_data, so cap
|
||||
# its size to keep tokens small and prevent unbounded growth of the DB column.
|
||||
CUSTOM_DATA_MAX_KEYS = 20
|
||||
CUSTOM_DATA_MAX_BYTES = 4096
|
||||
|
||||
# ============================================================================
|
||||
# MODELS
|
||||
# ============================================================================
|
||||
|
||||
class GenerateTokenRequest(BaseModel):
|
||||
"""Request to generate widget token - requires API key in Authorization header"""
|
||||
widget_id: str
|
||||
customer_email: Optional[str] = None
|
||||
customer_name: Optional[str] = None
|
||||
custom_data: Optional[Dict[str, Any]] = None
|
||||
ttl_seconds: Optional[int] = 3600 # Default 1 hour
|
||||
|
||||
@field_validator('custom_data')
|
||||
@classmethod
|
||||
def validate_custom_data_size(cls, value):
|
||||
if value is None:
|
||||
return value
|
||||
if len(value) > CUSTOM_DATA_MAX_KEYS:
|
||||
raise ValueError(f"custom_data supports at most {CUSTOM_DATA_MAX_KEYS} keys")
|
||||
size = len(json.dumps(value))
|
||||
if size > CUSTOM_DATA_MAX_BYTES:
|
||||
raise ValueError(f"custom_data must be at most {CUSTOM_DATA_MAX_BYTES} bytes when serialized")
|
||||
return value
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""Request to rotate a still-valid conversation token"""
|
||||
widget_id: str
|
||||
|
||||
class RevokeTokenRequest(BaseModel):
|
||||
"""Request to revoke a token"""
|
||||
token: str
|
||||
reason: Optional[str] = None
|
||||
|
||||
class RevokeTokenResponse(BaseModel):
|
||||
"""Response for token revocation"""
|
||||
success: bool
|
||||
message: str
|
||||
revoked_at: Optional[str] = None
|
||||
|
||||
class TokenData(BaseModel):
|
||||
"""Token response data"""
|
||||
token: str
|
||||
widget_id: str
|
||||
expires_in: int
|
||||
expires_at: str
|
||||
created_at: str
|
||||
|
||||
class GenerateTokenResponse(BaseModel):
|
||||
"""Response with generated token"""
|
||||
success: bool
|
||||
data: TokenData
|
||||
message: Optional[str] = None
|
||||
|
||||
# ============================================================================
|
||||
# DEPENDENCIES
|
||||
# ============================================================================
|
||||
|
||||
def validate_api_key(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Validate API key from Authorization header (Bearer token) against the
|
||||
widget_apps table. A plain def, so FastAPI runs it in a worker thread:
|
||||
a key issued before the lookup column is bcrypt-checked, which must not
|
||||
hold up the event loop.
|
||||
"""
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
|
||||
if not auth_header.startswith("Bearer "):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing or invalid Authorization header. Use 'Bearer YOUR_API_KEY'",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
api_key = auth_header[len("Bearer "):]
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="API key cannot be empty",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
repo = WidgetAppRepository(db)
|
||||
widget_app = repo.validate_api_key(api_key)
|
||||
|
||||
if not widget_app:
|
||||
logger.warning(f"Invalid API key attempt")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid API key",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Store widget_app in request state for downstream use
|
||||
request.state.widget_app = widget_app
|
||||
|
||||
return api_key
|
||||
|
||||
# ============================================================================
|
||||
# ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/generate-token", response_model=GenerateTokenResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def generate_widget_token(
|
||||
body: GenerateTokenRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
api_key: str = Depends(validate_api_key)
|
||||
):
|
||||
"""
|
||||
Generate a JWT token for widget authentication.
|
||||
|
||||
Only calls with valid API key (in Authorization header) will succeed.
|
||||
Frontend receives this token and must pass it to the widget.
|
||||
|
||||
The token is signed with CONVERSATION_SECRET_KEY and can be verified by the widget.
|
||||
|
||||
Args:
|
||||
body: GenerateTokenRequest with widget_id, customer_email, customer_name, ttl_seconds
|
||||
request: FastAPI request object (contains widget_app from API key validation)
|
||||
db: Database session
|
||||
api_key: Validated API key from Authorization header
|
||||
|
||||
Returns:
|
||||
GenerateTokenResponse with JWT token
|
||||
|
||||
Raises:
|
||||
HTTPException 401: Invalid or missing API key
|
||||
HTTPException 404: Widget not found
|
||||
HTTPException 400: Invalid widget_id
|
||||
|
||||
Example:
|
||||
POST /api/v1/generate-token
|
||||
Authorization: Bearer YOUR_API_KEY
|
||||
{
|
||||
"widget_id": "widget-123",
|
||||
"customer_email": "user@example.com",
|
||||
"customer_name": "John Doe",
|
||||
"ttl_seconds": 3600
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"widget_id": "widget-123",
|
||||
"expires_in": 3600,
|
||||
"expires_at": "2024-11-09T15:00:00",
|
||||
"created_at": "2024-11-09T14:00:00"
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Get widget_app from request state (set by validate_api_key)
|
||||
widget_app = request.state.widget_app
|
||||
|
||||
# SECURITY: Validate widget exists AND belongs to same organization as the API key
|
||||
# This prevents cross-organization token generation
|
||||
widget = db.query(Widget).filter(
|
||||
Widget.id == body.widget_id,
|
||||
Widget.organization_id == widget_app.organization_id
|
||||
).first()
|
||||
|
||||
if not widget:
|
||||
logger.error(f"Widget not found or access denied: {body.widget_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Widget '{body.widget_id}' not found"
|
||||
)
|
||||
|
||||
# Create or update customer
|
||||
customer = None
|
||||
|
||||
if body.customer_email:
|
||||
# The embedding app supplied a known identity, so this is an
|
||||
# authenticated/integration customer (excluded from the People/leads
|
||||
# views), not an organic lead. Shared with the hosted support chat,
|
||||
# which identifies the signed-in dashboard user the same way.
|
||||
customer = CustomerRepository(db).identify(
|
||||
email=body.customer_email,
|
||||
organization_id=widget.organization_id,
|
||||
full_name=body.customer_name,
|
||||
meta_data=body.custom_data,
|
||||
)
|
||||
else:
|
||||
# No email provided - create anonymous customer
|
||||
# Generate a unique email for anonymous customers
|
||||
import time
|
||||
timestamp = int(time.time() * 1000)
|
||||
anonymous_email = f"anonymous-{timestamp}@{widget.organization_id}.local"
|
||||
|
||||
customer = Customer(
|
||||
email=anonymous_email,
|
||||
full_name=body.customer_name or "Anonymous",
|
||||
meta_data=body.custom_data,
|
||||
organization_id=widget.organization_id
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
db.refresh(customer)
|
||||
logger.info(f"Created anonymous customer: {customer.id}")
|
||||
|
||||
# Validate TTL
|
||||
if not conversation_token.is_ttl_supported(body.ttl_seconds):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(
|
||||
f"ttl_seconds must be between {conversation_token.MIN_TTL_SECONDS} and "
|
||||
f"{conversation_token.MAX_TTL_SECONDS} (1 minute to 24 hours) for security "
|
||||
"and performance reasons"
|
||||
)
|
||||
)
|
||||
|
||||
# Check for existing valid token for this customer/widget combination
|
||||
# This prevents token multiplication when user refreshes page
|
||||
customer_email = body.customer_email or customer.email
|
||||
existing_jti = get_existing_valid_token_jti(customer_email, body.widget_id, str(customer.id))
|
||||
if existing_jti:
|
||||
# Only the JTI is kept in Redis, so the token itself is re-signed - reusing
|
||||
# the ID keeps this customer to one revocable session per widget.
|
||||
logger.info(f"Found existing valid token for customer {customer.id} in widget {body.widget_id}, reusing JTI")
|
||||
|
||||
token, expires_at, ttl = conversation_token.mint(
|
||||
{
|
||||
"sub": str(customer.id), # Sub is now always the customer_id
|
||||
"widget_id": body.widget_id,
|
||||
"customer_email": customer_email,
|
||||
"customer_name": body.customer_name or customer.full_name,
|
||||
"customer_id": str(customer.id), # Explicit customer_id claim
|
||||
"custom_data": body.custom_data,
|
||||
},
|
||||
ttl_seconds=body.ttl_seconds,
|
||||
jti=existing_jti,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if not existing_jti:
|
||||
logger.info(f"Generated new widget token for widget_id={body.widget_id}, customer_email={customer_email}, expires_in={ttl}s")
|
||||
else:
|
||||
logger.info(f"Reused JTI and refreshed Redis TTL for widget_id={body.widget_id}, customer_email={customer_email}, jti={existing_jti[:8]}...")
|
||||
|
||||
return GenerateTokenResponse(
|
||||
success=True,
|
||||
data=TokenData(
|
||||
token=token,
|
||||
widget_id=body.widget_id,
|
||||
expires_in=ttl,
|
||||
expires_at=expires_at.isoformat(),
|
||||
created_at=now.isoformat()
|
||||
),
|
||||
message=f"Token generated successfully. Expires in {ttl} seconds."
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Error generating token: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to generate token"
|
||||
)
|
||||
|
||||
@router.post("/refresh-token", response_model=GenerateTokenResponse)
|
||||
async def refresh_widget_token(
|
||||
body: RefreshTokenRequest,
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""
|
||||
Rotate a still-valid conversation token, keeping the visitor's identity.
|
||||
|
||||
The widget calls this before its token lapses, so a page left open longer than
|
||||
`ttl_seconds` doesn't silently drop an identified visitor back to anonymous.
|
||||
|
||||
No API key: the request is authenticated by the conversation token it carries,
|
||||
which the caller already holds, so rotating it grants nothing new. An expired or
|
||||
revoked token is refused - only POST /generate-token (API key) can assert an
|
||||
identity - and rotation stops once the chain reaches its absolute ceiling.
|
||||
|
||||
Args:
|
||||
body: RefreshTokenRequest with the widget_id the token was issued for
|
||||
authorization: `Bearer <conversation token>`
|
||||
|
||||
Returns:
|
||||
GenerateTokenResponse with the replacement token
|
||||
|
||||
Raises:
|
||||
HTTPException 401: missing, invalid, expired or revoked conversation token
|
||||
"""
|
||||
token = authorization[len("Bearer "):].strip() if authorization and authorization.startswith("Bearer ") else None
|
||||
state = conversation_token.inspect(token, body.widget_id)
|
||||
|
||||
if not state.is_live:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={
|
||||
"code": (
|
||||
conversation_token.IDENTITY_EXPIRED_CODE if state.identity_lost
|
||||
else conversation_token.INVALID_TOKEN_CODE
|
||||
),
|
||||
"message": "Conversation token is no longer valid. Issue a new one with /generate-token.",
|
||||
},
|
||||
)
|
||||
|
||||
rotated = conversation_token.rotate(state.payload)
|
||||
if not rotated:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={
|
||||
"code": conversation_token.IDENTITY_EXPIRED_CODE,
|
||||
"message": "Conversation token has reached its maximum lifetime. Issue a new one with /generate-token.",
|
||||
},
|
||||
)
|
||||
|
||||
new_token, expires_at, ttl = rotated
|
||||
logger.info(f"Rotated conversation token for widget_id={body.widget_id}, expires_in={ttl}s")
|
||||
|
||||
return GenerateTokenResponse(
|
||||
success=True,
|
||||
data=TokenData(
|
||||
token=new_token,
|
||||
widget_id=body.widget_id,
|
||||
expires_in=ttl,
|
||||
expires_at=expires_at.isoformat(),
|
||||
created_at=datetime.now(timezone.utc).isoformat()
|
||||
),
|
||||
message=f"Token refreshed successfully. Expires in {ttl} seconds."
|
||||
)
|
||||
|
||||
|
||||
@router.post("/verify-token")
|
||||
async def verify_widget_token(
|
||||
token: str,
|
||||
widget_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
api_key: str = Depends(validate_api_key)
|
||||
):
|
||||
"""
|
||||
Verify a widget token (for backend validation).
|
||||
|
||||
Args:
|
||||
token: JWT token to verify
|
||||
widget_id: Widget ID to match against token
|
||||
db: Database session
|
||||
api_key: Validated API key from Authorization header
|
||||
|
||||
Returns:
|
||||
Token data if valid, error if invalid
|
||||
|
||||
Example:
|
||||
POST /api/v1/verify-token?token=eyJ...&widget_id=widget-123
|
||||
Authorization: Bearer YOUR_API_KEY
|
||||
"""
|
||||
try:
|
||||
# Verify JWT signature AND check Redis revocation status
|
||||
# This ensures revoked tokens are rejected
|
||||
payload = verify_conversation_token(token)
|
||||
|
||||
if not payload:
|
||||
logger.warning(f"Token invalid or revoked for widget_id={widget_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Token is invalid or has been revoked"
|
||||
)
|
||||
|
||||
# Verify widget_id matches
|
||||
if payload.get("widget_id") != widget_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Token widget_id does not match provided widget_id"
|
||||
)
|
||||
|
||||
logger.info(f"Token verified successfully for widget_id={widget_id}")
|
||||
|
||||
return {
|
||||
"valid": True,
|
||||
"token_data": payload
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Error verifying token: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to verify token"
|
||||
)
|
||||
|
||||
@router.post("/revoke-token", response_model=RevokeTokenResponse, status_code=status.HTTP_200_OK)
|
||||
async def revoke_widget_token(
|
||||
body: RevokeTokenRequest,
|
||||
db: Session = Depends(get_db),
|
||||
api_key: str = Depends(validate_api_key)
|
||||
):
|
||||
"""
|
||||
Revoke a widget authentication token immediately.
|
||||
|
||||
Once revoked, a token becomes invalid even if it hasn't expired naturally.
|
||||
|
||||
Use cases:
|
||||
- User logout: Revoke tokens when user logs out
|
||||
- Permission changes: Invalidate tokens when user role/permissions change
|
||||
- Security incident: Revoke compromised tokens
|
||||
- Manual revocation: Admin/portal revokes a specific token
|
||||
|
||||
Args:
|
||||
body: RevokeTokenRequest with token and optional reason
|
||||
db: Database session
|
||||
api_key: Validated API key from Authorization header
|
||||
|
||||
Returns:
|
||||
RevokeTokenResponse indicating success/failure
|
||||
|
||||
Example:
|
||||
POST /api/v1/revoke-token
|
||||
Authorization: Bearer YOUR_API_KEY
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"reason": "User logged out"
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"message": "Token revoked successfully",
|
||||
"revoked_at": "2024-11-09T14:05:00"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
token = body.token
|
||||
reason = body.reason or "Manual revocation via API"
|
||||
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Token cannot be empty"
|
||||
)
|
||||
|
||||
# Expired tokens stay revocable: only the signature has to hold up here.
|
||||
payload = conversation_token.claims_ignoring_expiry(token)
|
||||
if payload is None:
|
||||
logger.warning("Invalid token format for revocation")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid token format"
|
||||
)
|
||||
|
||||
# Extract token information
|
||||
jti = payload.get("jti")
|
||||
|
||||
if not jti:
|
||||
logger.warning("Token does not have JTI claim, cannot revoke")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Token does not support revocation (missing JTI claim)"
|
||||
)
|
||||
|
||||
# Revoke the token
|
||||
security_revoke_token(jti)
|
||||
|
||||
revoked_at = datetime.utcnow()
|
||||
|
||||
logger.info(
|
||||
f"Token revoked: jti={jti[:8]}... reason={reason}"
|
||||
)
|
||||
|
||||
return RevokeTokenResponse(
|
||||
success=True,
|
||||
message=f"Token revoked successfully. Reason: {reason}",
|
||||
revoked_at=revoked_at.isoformat()
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Error revoking token: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to revoke token"
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user