This commit is contained in:
2026-03-09 14:05:00 +08:00
commit 754e720ba7
105 changed files with 5890 additions and 0 deletions

View File

View File

@@ -0,0 +1,50 @@
"""
Configuration settings for Authentication Service
Loads environment variables and provides configuration object
"""
import os
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
"""Application settings using Pydantic BaseSettings"""
# Database settings
DATABASE_URL: str = os.getenv(
"DATABASE_URL",
# If DATABASE_URL is not set, raise an error to force proper configuration
# For development, you can create a .env file with DATABASE_URL=mysql+aiomysql://user:password@host/dbname
)
# JWT settings
JWT_SECRET_KEY: str = os.getenv(
"JWT_SECRET_KEY",
# If JWT_SECRET_KEY is not set, raise an error to force proper configuration
# For development, you can create a .env file with JWT_SECRET_KEY=your-secret-key
)
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRATION_HOURS: int = 24
# Security settings
BCRYPT_ROUNDS: int = 12
# Application settings
APP_NAME: str = "Weibo-HotSign Authentication Service"
DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true"
HOST: str = os.getenv("HOST", "0.0.0.0")
PORT: int = int(os.getenv("PORT", 8000))
# CORS settings
ALLOWED_ORIGINS: list = [
"http://localhost:3000",
"http://localhost:80",
"http://127.0.0.1:3000"
]
class Config:
case_sensitive = True
env_file = ".env"
# Create global settings instance
settings = Settings()

View File

@@ -0,0 +1,223 @@
"""
Weibo-HotSign Authentication Service
Main FastAPI application entry point
"""
from fastapi import FastAPI, Depends, HTTPException, status, Security
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import uvicorn
import os
import logging
from shared.models import get_db, User
from auth_service.app.models.database import create_tables
from auth_service.app.schemas.user import (
UserCreate, UserLogin, UserResponse, Token, TokenData, RefreshTokenRequest,
)
from auth_service.app.services.auth_service import AuthService
from auth_service.app.utils.security import (
verify_password, create_access_token, decode_access_token,
create_refresh_token, verify_refresh_token, revoke_refresh_token,
)
# Configure logger
logger = logging.getLogger(__name__)
# Initialize FastAPI app
app = FastAPI(
title="Weibo-HotSign Authentication Service",
description="Handles user authentication and authorization for Weibo-HotSign system",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc"
)
# CORS middleware configuration
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://localhost:80"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Security scheme for JWT
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Security(security),
db: AsyncSession = Depends(get_db)
) -> UserResponse:
"""
Dependency to get current user from JWT token
"""
token = credentials.credentials
payload = decode_access_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
headers={"WWW-Authenticate": "Bearer"},
)
auth_service = AuthService(db)
user = await auth_service.get_user_by_id(user_id)
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is deactivated",
)
return UserResponse.from_orm(user)
@app.on_event("startup")
async def startup_event():
"""Initialize database tables on startup"""
await create_tables()
@app.get("/")
async def root():
return {
"service": "Weibo-HotSign Authentication Service",
"status": "running",
"version": "1.0.0"
}
@app.get("/health")
async def health_check():
return {"status": "healthy"}
@app.post("/auth/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register_user(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
"""
Register a new user account
"""
auth_service = AuthService(db)
# Check if user already exists - optimized with single query
email_user, username_user = await auth_service.check_user_exists(user_data.email, user_data.username)
if email_user:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="User with this email already exists"
)
if username_user:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Username already taken"
)
# Create new user
try:
user = await auth_service.create_user(user_data)
return UserResponse.from_orm(user)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create user: {str(e)}"
)
@app.post("/auth/login", response_model=Token)
async def login_user(login_data: UserLogin, db: AsyncSession = Depends(get_db)):
"""
Authenticate user and return JWT token
"""
auth_service = AuthService(db)
# Find user by email
user = await auth_service.get_user_by_email(login_data.email)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password"
)
# Verify password
if not verify_password(login_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password"
)
# Check if user is active
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is deactivated"
)
# Create access token
access_token = create_access_token(data={"sub": str(user.id), "username": user.username})
# Create refresh token (stored in Redis)
refresh_token = await create_refresh_token(str(user.id))
return Token(
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer",
expires_in=3600 # 1 hour
)
@app.post("/auth/refresh", response_model=Token)
async def refresh_token(body: RefreshTokenRequest, db: AsyncSession = Depends(get_db)):
"""
Exchange a valid refresh token for a new access + refresh token pair (Token Rotation).
The old refresh token is revoked immediately.
"""
# Verify the incoming refresh token
user_id = await verify_refresh_token(body.refresh_token)
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired refresh token",
)
# Ensure the user still exists and is active
auth_service = AuthService(db)
user = await auth_service.get_user_by_id(user_id)
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or deactivated",
)
# Revoke old token, issue new pair
await revoke_refresh_token(body.refresh_token)
new_access = create_access_token(data={"sub": str(user.id), "username": user.username})
new_refresh = await create_refresh_token(str(user.id))
return Token(
access_token=new_access,
refresh_token=new_refresh,
token_type="bearer",
expires_in=3600,
)
@app.get("/auth/me", response_model=UserResponse)
async def get_current_user_info(current_user: UserResponse = Depends(get_current_user)):
"""
Get current user information
"""
return current_user

View File

@@ -0,0 +1,15 @@
"""
Database models and connection management for Authentication Service.
Re-exports shared module components for backward compatibility.
"""
# Re-export everything from the shared module
from shared.models import Base, get_db, engine, AsyncSessionLocal, User
__all__ = ["Base", "get_db", "engine", "AsyncSessionLocal", "User"]
async def create_tables():
"""Create all tables in the database."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

View File

@@ -0,0 +1,57 @@
"""
Pydantic schemas for User-related data structures
Defines request/response models for authentication endpoints
"""
from pydantic import BaseModel, EmailStr, Field
from typing import Optional
from datetime import datetime
from uuid import UUID
class UserBase(BaseModel):
"""Base schema for user data"""
username: str = Field(..., min_length=3, max_length=50, description="Unique username")
email: EmailStr = Field(..., description="Valid email address")
class UserCreate(UserBase):
"""Schema for user registration request"""
password: str = Field(..., min_length=8, description="Password (min 8 characters)")
class UserLogin(BaseModel):
"""Schema for user login request"""
email: EmailStr = Field(..., description="User's email address")
password: str = Field(..., description="User's password")
class UserUpdate(BaseModel):
"""Schema for user profile updates"""
username: Optional[str] = Field(None, min_length=3, max_length=50)
email: Optional[EmailStr] = None
is_active: Optional[bool] = None
class UserResponse(UserBase):
"""Schema for user response data"""
id: UUID
created_at: datetime
is_active: bool
class Config:
from_attributes = True # Enable ORM mode
class Token(BaseModel):
"""Schema for JWT token response (login / refresh)"""
access_token: str = Field(..., description="JWT access token")
refresh_token: str = Field(..., description="Opaque refresh token")
token_type: str = Field(default="bearer", description="Token type")
expires_in: int = Field(..., description="Access token expiration time in seconds")
class RefreshTokenRequest(BaseModel):
"""Schema for token refresh request"""
refresh_token: str = Field(..., description="The refresh token to exchange")
class TokenData(BaseModel):
"""Schema for decoded token payload"""
sub: str = Field(..., description="Subject (user ID)")
username: str = Field(..., description="Username")
exp: Optional[int] = None

View File

@@ -0,0 +1,191 @@
"""
Authentication service business logic
Handles user registration, login, and user management operations
"""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException, status
import logging
from typing import Optional
from shared.models import User
from ..schemas.user import UserCreate, UserLogin
from ..utils.security import hash_password, validate_password_strength, verify_password
# Configure logger
logger = logging.getLogger(__name__)
class AuthService:
"""Service class for authentication and user management"""
def __init__(self, db: AsyncSession):
self.db = db
async def get_user_by_email(self, email: str) -> Optional[User]:
"""Find user by email address"""
try:
stmt = select(User).where(User.email == email)
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
except Exception as e:
logger.error(f"Error fetching user by email {email}: {e}")
return None
async def get_user_by_username(self, username: str) -> Optional[User]:
"""Find user by username"""
try:
stmt = select(User).where(User.username == username)
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
except Exception as e:
logger.error(f"Error fetching user by username {username}: {e}")
return None
async def get_user_by_id(self, user_id: str) -> Optional[User]:
"""Find user by UUID"""
try:
# For MySQL, user_id is already a string, no need to convert to UUID
stmt = select(User).where(User.id == user_id)
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
except Exception as e:
logger.error(f"Error fetching user by ID {user_id}: {e}")
return None
async def create_user(self, user_data: UserCreate) -> User:
"""Create a new user account with validation"""
# Validate password strength
is_strong, message = validate_password_strength(user_data.password)
if not is_strong:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Password too weak: {message}"
)
# Hash password
hashed_password = hash_password(user_data.password)
# Create user instance
user = User(
username=user_data.username,
email=user_data.email,
hashed_password=hashed_password,
is_active=True
)
try:
self.db.add(user)
await self.db.commit()
await self.db.refresh(user)
logger.info(f"Successfully created user: {user.username} ({user.email})")
return user
except IntegrityError as e:
await self.db.rollback()
logger.error(f"Integrity error creating user {user_data.username}: {e}")
# Check which constraint was violated
if "users_username_key" in str(e.orig):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Username already exists"
)
elif "users_email_key" in str(e.orig):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Email already registered"
)
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create user due to database constraint"
)
except Exception as e:
await self.db.rollback()
logger.error(f"Unexpected error creating user {user_data.username}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error during user creation"
)
async def check_user_exists(self, email: str, username: str) -> tuple[Optional[User], Optional[User]]:
"""Check if user exists by email or username in a single query"""
try:
stmt = select(User).where(or_(User.email == email, User.username == username))
result = await self.db.execute(stmt)
users = result.scalars().all()
email_user = None
username_user = None
for user in users:
if user.email == email:
email_user = user
if user.username == username:
username_user = user
return email_user, username_user
except Exception as e:
logger.error(f"Error checking user existence: {e}")
return None, None
async def authenticate_user(self, login_data: UserLogin) -> Optional[User]:
"""Authenticate user credentials"""
user = await self.get_user_by_email(login_data.email)
if not user:
return None
# Verify password
if not verify_password(login_data.password, user.hashed_password):
return None
# Check if user is active
if not user.is_active:
logger.warning(f"Login attempt for deactivated user: {user.email}")
return None
logger.info(f"Successful authentication for user: {user.username}")
return user
async def update_user_status(self, user_id: str, is_active: bool) -> Optional[User]:
"""Update user active status"""
user = await self.get_user_by_id(user_id)
if not user:
return None
user.is_active = is_active
try:
await self.db.commit()
await self.db.refresh(user)
logger.info(f"Updated user {user.username} status to: {is_active}")
return user
except Exception as e:
await self.db.rollback()
logger.error(f"Error updating user status: {e}")
return None
async def get_all_users(self, skip: int = 0, limit: int = 100) -> list[User]:
"""Get list of all users (admin function)"""
try:
stmt = select(User).offset(skip).limit(limit)
result = await self.db.execute(stmt)
return result.scalars().all()
except Exception as e:
logger.error(f"Error fetching users list: {e}")
return []
async def check_database_health(self) -> bool:
"""Check if database connection is healthy"""
try:
stmt = select(User).limit(1)
await self.db.execute(stmt)
return True
except Exception as e:
logger.error(f"Database health check failed: {e}")
return False

View File

@@ -0,0 +1,148 @@
"""
Security utilities for password hashing and JWT token management
"""
import bcrypt
import hashlib
import jwt
import secrets
from datetime import datetime, timedelta
from typing import Optional
import redis.asyncio as aioredis
from shared.config import shared_settings
# Auth-specific defaults
BCRYPT_ROUNDS = 12
REFRESH_TOKEN_TTL = 7 * 24 * 3600 # 7 days in seconds
# Lazy-initialised async Redis client
_redis_client: Optional[aioredis.Redis] = None
async def get_redis() -> aioredis.Redis:
"""Return a shared async Redis connection."""
global _redis_client
if _redis_client is None:
_redis_client = aioredis.from_url(
shared_settings.REDIS_URL, decode_responses=True
)
return _redis_client
def hash_password(password: str) -> str:
"""
Hash a password using bcrypt
Returns the hashed password as a string
"""
salt = bcrypt.gensalt(rounds=BCRYPT_ROUNDS)
hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
return hashed.decode('utf-8')
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify a plain text password against a hashed password
Returns True if passwords match, False otherwise
"""
try:
return bcrypt.checkpw(
plain_password.encode('utf-8'),
hashed_password.encode('utf-8')
)
except Exception:
return False
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""
Create a JWT access token
"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(hours=shared_settings.JWT_EXPIRATION_HOURS)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, shared_settings.JWT_SECRET_KEY, algorithm=shared_settings.JWT_ALGORITHM)
return encoded_jwt
def decode_access_token(token: str) -> Optional[dict]:
"""
Decode and validate a JWT access token
Returns the payload if valid, None otherwise
"""
try:
payload = jwt.decode(token, shared_settings.JWT_SECRET_KEY, algorithms=[shared_settings.JWT_ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
def generate_password_reset_token(email: str) -> str:
"""
Generate a secure token for password reset
"""
data = {"email": email, "type": "password_reset"}
return create_access_token(data, timedelta(hours=1))
# Password strength validation
def validate_password_strength(password: str) -> tuple[bool, str]:
"""
Validate password meets strength requirements
Returns (is_valid, error_message)
"""
if len(password) < 8:
return False, "Password must be at least 8 characters long"
if not any(c.isupper() for c in password):
return False, "Password must contain at least one uppercase letter"
if not any(c.islower() for c in password):
return False, "Password must contain at least one lowercase letter"
if not any(c.isdigit() for c in password):
return False, "Password must contain at least one digit"
if not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in password):
return False, "Password must contain at least one special character"
return True, "Password is strong"
# --------------- Refresh Token helpers ---------------
def _hash_token(token: str) -> str:
"""SHA-256 hash of a refresh token for safe Redis key storage."""
return hashlib.sha256(token.encode("utf-8")).hexdigest()
async def create_refresh_token(user_id: str) -> str:
"""
Generate a cryptographically random refresh token, store its hash in Redis
with a 7-day TTL, and return the raw token string.
"""
token = secrets.token_urlsafe(48)
token_hash = _hash_token(token)
r = await get_redis()
await r.setex(f"refresh_token:{token_hash}", REFRESH_TOKEN_TTL, user_id)
return token
async def verify_refresh_token(token: str) -> Optional[str]:
"""
Verify a refresh token by looking up its hash in Redis.
Returns the associated user_id if valid, None otherwise.
"""
token_hash = _hash_token(token)
r = await get_redis()
user_id = await r.get(f"refresh_token:{token_hash}")
return user_id
async def revoke_refresh_token(token: str) -> None:
"""Delete a refresh token from Redis (used during rotation)."""
token_hash = _hash_token(token)
r = await get_redis()
await r.delete(f"refresh_token:{token_hash}")