build: 打通 FastAPI 与 Vue 最小工程、依赖与数据库连接
后端入口、配置、Redis 与 Alembic 迁移就位;前端 Vite 工程可安装可构建。
This commit is contained in:
1 parent
ec5835ca77
commit
977783c126
167 files changed
+26082
No files matched your search
@@ -0,0 +1,489 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, Dict
|
||||
import re
|
||||
import uuid
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from app.core.config import settings
|
||||
from app.core.encryption import CURRENT_KEY_ID, get_keys
|
||||
from app.core.logger import get_logger
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Constants
|
||||
SECRET_KEY = settings.SECRET_KEY
|
||||
CONVERSATION_SECRET_KEY = settings.CONVERSATION_SECRET_KEY
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS = 7
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
# Password policy for passwords one person sets on behalf of another (today:
|
||||
# an admin resetting an agent's password). Mirrors the strength meter the UI
|
||||
# draws — minimum length plus three of the four character classes — so the
|
||||
# form and the API agree on what "strong enough" means.
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
MIN_PASSWORD_CHARACTER_CLASSES = 3
|
||||
|
||||
_PASSWORD_CHARACTER_CLASSES = (r"[A-Z]", r"[a-z]", r"[0-9]", r"[^A-Za-z0-9]")
|
||||
|
||||
|
||||
def validate_password_strength(password: str) -> str:
|
||||
"""Return the password, or raise ValueError describing what it is missing."""
|
||||
if len(password or "") < MIN_PASSWORD_LENGTH:
|
||||
raise ValueError(
|
||||
f"Password must be at least {MIN_PASSWORD_LENGTH} characters long"
|
||||
)
|
||||
|
||||
classes = sum(1 for pattern in _PASSWORD_CHARACTER_CLASSES if re.search(pattern, password))
|
||||
if classes < MIN_PASSWORD_CHARACTER_CLASSES:
|
||||
raise ValueError(
|
||||
f"Password must include at least {MIN_PASSWORD_CHARACTER_CLASSES} of: "
|
||||
"uppercase letter, lowercase letter, number, special character"
|
||||
)
|
||||
|
||||
return password
|
||||
|
||||
|
||||
def _fernet():
|
||||
"""The shared encryption-at-rest key, loaded by app.core.encryption.
|
||||
|
||||
API keys predate the ``enc:v1:`` wire format and stay bare Fernet tokens, so
|
||||
they are encrypted here rather than through encrypt_value/decrypt_value — but
|
||||
the key itself has exactly one owner.
|
||||
"""
|
||||
return get_keys()[CURRENT_KEY_ID]
|
||||
|
||||
|
||||
def create_access_token(data: dict) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire, "type": "access"})
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(data: dict) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def verify_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
def encrypt_api_key(api_key: str) -> str:
|
||||
"""Encrypt API key before storing in database"""
|
||||
try:
|
||||
return _fernet().encrypt(api_key.encode()).decode()
|
||||
except Exception as e:
|
||||
logger.error(f"Encryption error: {str(e)}")
|
||||
raise ValueError("Failed to encrypt API key")
|
||||
|
||||
|
||||
def decrypt_credentials(encrypted: str) -> dict:
|
||||
"""Decrypt a JSON credential blob stored with encrypt_api_key."""
|
||||
return json.loads(decrypt_api_key(encrypted))
|
||||
|
||||
|
||||
def decrypt_api_key(encrypted_key: str) -> str:
|
||||
"""Decrypt API key from database"""
|
||||
try:
|
||||
return _fernet().decrypt(encrypted_key.encode()).decode()
|
||||
except Exception as e:
|
||||
logger.error(f"Decryption error: {str(e)}")
|
||||
raise ValueError("Failed to decrypt API key")
|
||||
|
||||
|
||||
def create_conversation_token(widget_id: str, customer_id: Optional[str] = None, customer_email: Optional[str] = None, **extra_data) -> str:
|
||||
"""
|
||||
Create a JWT token for widget conversations with revocation support via Redis
|
||||
|
||||
Includes a JTI (JWT ID) claim for revocation. Token is stored in Redis with TTL.
|
||||
Optionally stores customer email for session tracking and bulk revocation.
|
||||
|
||||
Args:
|
||||
widget_id: The widget ID
|
||||
customer_id: Optional customer ID
|
||||
customer_email: Optional customer email for session management
|
||||
**extra_data: Additional data to include in token
|
||||
"""
|
||||
jti = str(uuid.uuid4())
|
||||
data = {
|
||||
"widget_id": widget_id,
|
||||
"type": "conversation",
|
||||
"jti": jti
|
||||
}
|
||||
|
||||
# Only include `sub` when a customer_id is present. RFC 7519 (and python-jose)
|
||||
# require `sub` to be a string — encoding it as JSON null causes decode to fail
|
||||
# with JWTError("Subject must be a string") for anonymous widget visitors
|
||||
# (e.g. Explore flow), which silently drops all downstream claims (like `source`).
|
||||
if customer_id is not None:
|
||||
data["sub"] = customer_id
|
||||
|
||||
# Add email to token if provided
|
||||
if customer_email:
|
||||
data["email"] = customer_email
|
||||
|
||||
# Add any extra data to the token
|
||||
data.update(extra_data)
|
||||
|
||||
# Set expiration to 30 days
|
||||
now = datetime.now(timezone.utc)
|
||||
expire = now + timedelta(days=30)
|
||||
data.update({"exp": expire})
|
||||
|
||||
token = jwt.encode(data, CONVERSATION_SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
# Store token JTI in Redis with TTL and optional email mapping (with widget_id)
|
||||
_store_token_in_redis(jti, int((expire - now).total_seconds()), customer_email, widget_id)
|
||||
|
||||
return token
|
||||
|
||||
|
||||
def verify_conversation_token(token: str) -> Optional[dict]:
|
||||
"""
|
||||
Verify conversation token signature and check if revoked
|
||||
|
||||
Returns:
|
||||
Token payload dict if valid and not revoked, None otherwise
|
||||
"""
|
||||
try:
|
||||
# verify_sub=False tolerates existing tokens (already in browsers' localStorage)
|
||||
# that were issued with `sub: null`. New tokens from create_conversation_token
|
||||
# omit `sub` entirely when anonymous, so this option only matters for in-flight
|
||||
# tokens until the old 30-day TTLs expire.
|
||||
payload = jwt.decode(token, CONVERSATION_SECRET_KEY,
|
||||
algorithms=[ALGORITHM],
|
||||
options={"verify_sub": False})
|
||||
|
||||
if payload.get("type") != "conversation":
|
||||
return None
|
||||
|
||||
# Check if token JTI exists in Redis (if revoked, it won't exist)
|
||||
jti = payload.get("jti")
|
||||
if jti and not _is_token_in_redis(jti):
|
||||
logger.warning(f"Token revoked or expired: jti={jti[:8]}...")
|
||||
return None
|
||||
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against its hash"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Hash a password"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TOKEN STORAGE IN REDIS - Minimal approach
|
||||
# ============================================================================
|
||||
|
||||
def _store_token_in_redis(jti: str, ttl_seconds: int, email: Optional[str] = None, widget_id: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Store token JTI in Redis with TTL and optionally store email/widget_id mapping
|
||||
|
||||
Args:
|
||||
jti: JWT ID for the token
|
||||
ttl_seconds: Time to live in seconds
|
||||
email: Optional email to store for session tracking
|
||||
widget_id: Optional widget ID for widget-specific session tracking
|
||||
"""
|
||||
try:
|
||||
from app.core.redis import redis_client
|
||||
if redis_client:
|
||||
# Store the token JTI
|
||||
redis_client.setex(f"token:{jti}", ttl_seconds, "1")
|
||||
|
||||
# Store email mapping if provided (key: user_sessions:email, value: set of JTIs)
|
||||
if email:
|
||||
redis_client.sadd(f"user_sessions:{email}", jti)
|
||||
# Set same TTL for email->JTI mapping
|
||||
redis_client.expire(f"user_sessions:{email}", ttl_seconds)
|
||||
|
||||
# If widget_id provided, also store widget-specific sessions
|
||||
if widget_id:
|
||||
widget_key = f"user_sessions:{email}:widget:{widget_id}"
|
||||
redis_client.sadd(widget_key, jti)
|
||||
redis_client.expire(widget_key, ttl_seconds)
|
||||
|
||||
# Maintain reverse index: track all widget keys for this email
|
||||
# for efficient bulk deletion without scanning
|
||||
redis_client.sadd(f"user_sessions:{email}:widget_keys", widget_key)
|
||||
redis_client.expire(f"user_sessions:{email}:widget_keys", ttl_seconds)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to store token in Redis: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def _is_token_in_redis(jti: str) -> bool:
|
||||
"""Check if token JTI exists in Redis"""
|
||||
try:
|
||||
from app.core.redis import redis_client
|
||||
if redis_client:
|
||||
return redis_client.exists(f"token:{jti}") > 0
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to check token in Redis: {str(e)}")
|
||||
# If Redis unavailable, assume valid (don't block requests)
|
||||
return True
|
||||
|
||||
|
||||
def revoke_token(jti: str) -> bool:
|
||||
"""Revoke token by deleting JTI from Redis"""
|
||||
try:
|
||||
from app.core.redis import redis_client
|
||||
if redis_client:
|
||||
redis_client.delete(f"token:{jti}")
|
||||
logger.info(f"Token revoked: {jti[:8]}...")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to revoke token: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def revoke_user_sessions(email: str) -> bool:
|
||||
"""
|
||||
Revoke all sessions for a user by email.
|
||||
|
||||
Args:
|
||||
email: The user's email address
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
from app.core.redis import redis_client
|
||||
if redis_client:
|
||||
# Get all JTIs for this email across all widgets
|
||||
jti_set = redis_client.smembers(f"user_sessions:{email}")
|
||||
|
||||
if jti_set:
|
||||
# Delete each JTI
|
||||
for jti in jti_set:
|
||||
redis_client.delete(f"token:{jti.decode() if isinstance(jti, bytes) else jti}")
|
||||
|
||||
# Delete the email mapping
|
||||
redis_client.delete(f"user_sessions:{email}")
|
||||
|
||||
# Also delete all widget-specific mappings using the reverse index set
|
||||
widget_keys_set = f"user_sessions:{email}:widget_keys"
|
||||
widget_keys = redis_client.smembers(widget_keys_set)
|
||||
if widget_keys:
|
||||
for key in widget_keys:
|
||||
redis_client.delete(key.decode() if isinstance(key, bytes) else key)
|
||||
redis_client.delete(widget_keys_set)
|
||||
# NOTE: When creating or deleting widget-specific session keys elsewhere,
|
||||
# be sure to add/remove the key from the widget_keys_set for this user.
|
||||
|
||||
logger.info(f"Revoked {len(jti_set)} sessions for user: {email}")
|
||||
return True
|
||||
else:
|
||||
logger.info(f"No active sessions found for user: {email}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to revoke user sessions: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def revoke_user_sessions_by_widget(email: str, widget_id: str) -> bool:
|
||||
"""
|
||||
Revoke all sessions for a user in a specific widget.
|
||||
|
||||
Args:
|
||||
email: The user's email address
|
||||
widget_id: The widget ID to revoke sessions for
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
from app.core.redis import redis_client
|
||||
if redis_client:
|
||||
# Get all JTIs for this email in this specific widget
|
||||
jti_set = redis_client.smembers(f"user_sessions:{email}:widget:{widget_id}")
|
||||
|
||||
if jti_set:
|
||||
# Delete each JTI
|
||||
for jti in jti_set:
|
||||
jti_str = jti.decode() if isinstance(jti, bytes) else jti
|
||||
redis_client.delete(f"token:{jti_str}")
|
||||
# Also remove from general user_sessions set
|
||||
redis_client.srem(f"user_sessions:{email}", jti_str)
|
||||
|
||||
# Delete the widget-specific mapping
|
||||
redis_client.delete(f"user_sessions:{email}:widget:{widget_id}")
|
||||
|
||||
logger.info(f"Revoked {len(jti_set)} sessions for user: {email} in widget: {widget_id}")
|
||||
return True
|
||||
else:
|
||||
logger.info(f"No active sessions found for user: {email} in widget: {widget_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to revoke user sessions by widget: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def get_user_active_sessions(email: str, widget_id: Optional[str] = None) -> list:
|
||||
"""
|
||||
Get all active session JTIs for a user by email.
|
||||
|
||||
Args:
|
||||
email: The user's email address
|
||||
widget_id: Optional widget ID to get sessions only for that widget
|
||||
|
||||
Returns:
|
||||
List of active JTI strings for the user (filtered by widget if provided)
|
||||
"""
|
||||
try:
|
||||
from app.core.redis import redis_client
|
||||
if redis_client:
|
||||
if widget_id:
|
||||
# Get sessions only for this specific widget
|
||||
jti_set = redis_client.smembers(f"user_sessions:{email}:widget:{widget_id}")
|
||||
else:
|
||||
# Get all sessions for this user across all widgets
|
||||
jti_set = redis_client.smembers(f"user_sessions:{email}")
|
||||
|
||||
return [jti.decode() if isinstance(jti, bytes) else jti for jti in jti_set]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get user sessions: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
def get_existing_valid_token_jti(email: str, widget_id: str, customer_id: str) -> Optional[str]:
|
||||
"""
|
||||
Check if there's an existing valid token JTI for this customer/widget combination.
|
||||
|
||||
Returns the JTI (JWT ID) of an existing valid token, not the token itself.
|
||||
This prevents token multiplication when the same user refreshes the page multiple times.
|
||||
|
||||
Args:
|
||||
email: Customer email
|
||||
widget_id: Widget ID
|
||||
customer_id: Customer ID
|
||||
|
||||
Returns:
|
||||
The JTI (JWT ID) string of an existing valid token if found, None otherwise
|
||||
"""
|
||||
try:
|
||||
from app.core.redis import redis_client
|
||||
if not redis_client:
|
||||
return None
|
||||
|
||||
# Get all active JTIs for this email in this widget
|
||||
jti_set = redis_client.smembers(f"user_sessions:{email}:widget:{widget_id}")
|
||||
|
||||
if not jti_set:
|
||||
logger.debug(f"No existing sessions found for {email} in widget {widget_id}")
|
||||
return None
|
||||
|
||||
# If we have active tokens, return the first valid one
|
||||
# In a typical flow, there should only be one per widget per user
|
||||
for jti in jti_set:
|
||||
jti_str = jti.decode() if isinstance(jti, bytes) else jti
|
||||
# Check if the token still exists in Redis (not revoked)
|
||||
if redis_client.exists(f"token:{jti_str}") > 0:
|
||||
logger.info(f"Found existing valid token for {email} in widget {widget_id}, reusing it")
|
||||
return jti_str
|
||||
|
||||
logger.debug(f"Active sessions found for {email} in widget {widget_id}, but none are valid in Redis")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking existing tokens: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# WIDGET API KEY MANAGEMENT
|
||||
# ============================================================================
|
||||
|
||||
def generate_widget_api_key() -> str:
|
||||
"""
|
||||
Generate a secure random API key with 'wak_' prefix.
|
||||
|
||||
Format: wak_<base64url> (e.g., wak_Xu8f3kLp9mN2...)
|
||||
Entropy: 256 bits (32 random bytes)
|
||||
Length: ~47 characters total (4 + 43)
|
||||
|
||||
Returns:
|
||||
str: API key in format 'wak_<base64url>'
|
||||
"""
|
||||
import secrets
|
||||
|
||||
# Generate 32 random bytes (256 bits of entropy)
|
||||
random_bytes = secrets.token_bytes(32)
|
||||
# Encode as URL-safe base64 without padding
|
||||
key_suffix = base64.urlsafe_b64encode(random_bytes).decode('utf-8').rstrip('=')
|
||||
return f"wak_{key_suffix}"
|
||||
|
||||
|
||||
def hash_widget_api_key(api_key: str) -> str:
|
||||
"""
|
||||
Hash API key using bcrypt (same as password hashing).
|
||||
|
||||
Args:
|
||||
api_key: Plain text API key
|
||||
|
||||
Returns:
|
||||
str: bcrypt hash of the API key
|
||||
"""
|
||||
return get_password_hash(api_key)
|
||||
|
||||
|
||||
def verify_widget_api_key(plain_key: str, hashed_key: str) -> bool:
|
||||
"""
|
||||
Verify API key against its hash.
|
||||
|
||||
Args:
|
||||
plain_key: Plain text API key from request
|
||||
hashed_key: Stored bcrypt hash
|
||||
|
||||
Returns:
|
||||
bool: True if key matches hash
|
||||
"""
|
||||
return verify_password(plain_key, hashed_key)
|
||||
|
||||
|
||||
def widget_api_key_lookup(api_key: str) -> str:
|
||||
"""
|
||||
Indexed lookup value for a widget API key: its SHA-256.
|
||||
|
||||
A fast hash is safe here because the key is 256 random bits, nothing to
|
||||
brute-force; it lets a request find its app in one query instead of
|
||||
bcrypt-checking every app.
|
||||
"""
|
||||
return hashlib.sha256(api_key.encode()).hexdigest()
|
||||
Reference in new issue
Block a user