123
This commit is contained in:
34
backend/auth_service/Dockerfile
Normal file
34
backend/auth_service/Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
||||
# Weibo-HotSign Authentication Service Dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
default-libmysqlclient-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY ../requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY app/ ./app/
|
||||
|
||||
# Create non-root user for security
|
||||
RUN groupadd -r appuser && useradd -r -g appuser appuser
|
||||
USER appuser
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
# Start application
|
||||
CMD ["python", "-m", "app.main"]
|
||||
0
backend/auth_service/__init__.py
Normal file
0
backend/auth_service/__init__.py
Normal file
BIN
backend/auth_service/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/auth_service/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
0
backend/auth_service/app/__init__.py
Normal file
0
backend/auth_service/app/__init__.py
Normal file
BIN
backend/auth_service/app/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/auth_service/app/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/auth_service/app/__pycache__/main.cpython-311.pyc
Normal file
BIN
backend/auth_service/app/__pycache__/main.cpython-311.pyc
Normal file
Binary file not shown.
50
backend/auth_service/app/config.py
Normal file
50
backend/auth_service/app/config.py
Normal 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()
|
||||
223
backend/auth_service/app/main.py
Normal file
223
backend/auth_service/app/main.py
Normal 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
|
||||
0
backend/auth_service/app/models/__init__.py
Normal file
0
backend/auth_service/app/models/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
15
backend/auth_service/app/models/database.py
Normal file
15
backend/auth_service/app/models/database.py
Normal 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)
|
||||
0
backend/auth_service/app/schemas/__init__.py
Normal file
0
backend/auth_service/app/schemas/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
57
backend/auth_service/app/schemas/user.py
Normal file
57
backend/auth_service/app/schemas/user.py
Normal 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
|
||||
0
backend/auth_service/app/services/__init__.py
Normal file
0
backend/auth_service/app/services/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
191
backend/auth_service/app/services/auth_service.py
Normal file
191
backend/auth_service/app/services/auth_service.py
Normal 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
|
||||
0
backend/auth_service/app/utils/__init__.py
Normal file
0
backend/auth_service/app/utils/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
148
backend/auth_service/app/utils/security.py
Normal file
148
backend/auth_service/app/utils/security.py
Normal 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}")
|
||||
31
backend/auth_service/requirements.txt
Normal file
31
backend/auth_service/requirements.txt
Normal file
@@ -0,0 +1,31 @@
|
||||
# Weibo-HotSign Authentication Service Requirements
|
||||
# Web Framework
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
pydantic-settings==2.0.3
|
||||
|
||||
# Database
|
||||
sqlalchemy==2.0.23
|
||||
aiomysql==0.2.0
|
||||
PyMySQL==1.1.0
|
||||
|
||||
# Security
|
||||
bcrypt==4.1.2
|
||||
PyJWT[crypto]==2.8.0
|
||||
|
||||
# Validation and Serialization
|
||||
pydantic==2.5.0
|
||||
python-multipart==0.0.6
|
||||
|
||||
# Utilities
|
||||
python-dotenv==1.0.0
|
||||
requests==2.31.0
|
||||
|
||||
# Logging and Monitoring
|
||||
structlog==23.2.0
|
||||
|
||||
# Development tools (optional)
|
||||
# pytest==7.4.3
|
||||
# pytest-asyncio==0.21.1
|
||||
# black==23.11.0
|
||||
# flake8==6.1.0
|
||||
Reference in New Issue
Block a user