chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,775 @@
|
||||
# FastAPI Authentication & Authorization
|
||||
|
||||
Complete authentication system with JWT tokens, OAuth2, and role-based access control.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Install auth dependencies
|
||||
pip install python-jose[cryptography] passlib[bcrypt] python-multipart
|
||||
|
||||
# Generate secret key
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
## JWT Configuration
|
||||
|
||||
```python
|
||||
# app/core/security.py
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Union, Any
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from app.core.config import settings
|
||||
|
||||
# Password hashing
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
# JWT settings
|
||||
SECRET_KEY = settings.SECRET_KEY
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS = 7
|
||||
|
||||
def create_access_token(
|
||||
subject: Union[str, Any],
|
||||
expires_delta: Optional[timedelta] = None
|
||||
) -> str:
|
||||
"""Create JWT access token."""
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
to_encode = {"exp": expire, "sub": str(subject), "type": "access"}
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
def create_refresh_token(subject: Union[str, Any]) -> str:
|
||||
"""Create JWT refresh token."""
|
||||
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode = {"exp": expire, "sub": str(subject), "type": "refresh"}
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify password against hash."""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Generate password hash."""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
"""Decode and verify JWT token."""
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
```
|
||||
|
||||
## Authentication Dependencies
|
||||
|
||||
```python
|
||||
# app/api/dependencies/auth.py
|
||||
from typing import Optional
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer, HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.security import decode_token
|
||||
from app.db.database import get_db
|
||||
from app.models.user import User
|
||||
from app.repositories.user import UserRepository
|
||||
|
||||
# OAuth2 scheme
|
||||
oauth2_scheme = OAuth2PasswordBearer(
|
||||
tokenUrl="/api/v1/auth/login",
|
||||
scheme_name="JWT"
|
||||
)
|
||||
|
||||
# Bearer token scheme
|
||||
security = HTTPBearer()
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> User:
|
||||
"""Get current authenticated user."""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
payload = decode_token(token)
|
||||
if payload is None:
|
||||
raise credentials_exception
|
||||
|
||||
user_id: str = payload.get("sub")
|
||||
token_type: str = payload.get("type")
|
||||
|
||||
if user_id is None or token_type != "access":
|
||||
raise credentials_exception
|
||||
|
||||
user_repo = UserRepository(User, db)
|
||||
user = await user_repo.get(int(user_id))
|
||||
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Inactive user"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
async def get_current_active_user(
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> User:
|
||||
"""Get current active user."""
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Inactive user"
|
||||
)
|
||||
return current_user
|
||||
|
||||
async def get_current_superuser(
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> User:
|
||||
"""Get current superuser."""
|
||||
if not current_user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
return current_user
|
||||
|
||||
def require_permissions(*permissions: str):
|
||||
"""Decorator for permission-based access control."""
|
||||
async def permission_checker(
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
) -> User:
|
||||
# Check if user has required permissions
|
||||
user_permissions = set(current_user.permissions or [])
|
||||
required_permissions = set(permissions)
|
||||
|
||||
if not required_permissions.issubset(user_permissions) and not current_user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions"
|
||||
)
|
||||
|
||||
return current_user
|
||||
|
||||
return permission_checker
|
||||
|
||||
def require_roles(*roles: str):
|
||||
"""Decorator for role-based access control."""
|
||||
async def role_checker(
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
) -> User:
|
||||
user_roles = set(role.name for role in current_user.roles or [])
|
||||
required_roles = set(roles)
|
||||
|
||||
if not required_roles.issubset(user_roles) and not current_user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient role permissions"
|
||||
)
|
||||
|
||||
return current_user
|
||||
|
||||
return role_checker
|
||||
```
|
||||
|
||||
## Authentication Schemas
|
||||
|
||||
```python
|
||||
# app/schemas/auth.py
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
|
||||
class Token(BaseModel):
|
||||
"""Token response schema."""
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
"""Token payload schema."""
|
||||
sub: Optional[int] = None
|
||||
exp: Optional[int] = None
|
||||
type: Optional[str] = None
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
"""User login schema."""
|
||||
username: str
|
||||
password: str
|
||||
|
||||
class UserRegister(BaseModel):
|
||||
"""User registration schema."""
|
||||
username: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
|
||||
class PasswordReset(BaseModel):
|
||||
"""Password reset schema."""
|
||||
email: EmailStr
|
||||
|
||||
class PasswordResetConfirm(BaseModel):
|
||||
"""Password reset confirmation schema."""
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
class ChangePassword(BaseModel):
|
||||
"""Change password schema."""
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
class RefreshToken(BaseModel):
|
||||
"""Refresh token schema."""
|
||||
refresh_token: str
|
||||
```
|
||||
|
||||
## Authentication Endpoints
|
||||
|
||||
```python
|
||||
# app/api/v1/auth.py
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.db.database import get_db
|
||||
from app.models.user import User
|
||||
from app.repositories.user import UserRepository
|
||||
from app.schemas.auth import (
|
||||
Token, UserLogin, UserRegister, PasswordReset,
|
||||
PasswordResetConfirm, ChangePassword, RefreshToken
|
||||
)
|
||||
from app.schemas.user import UserCreate, UserResponse
|
||||
from app.core.security import (
|
||||
create_access_token, create_refresh_token, verify_password,
|
||||
decode_token, ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
from app.api.dependencies.auth import get_current_user, get_current_active_user
|
||||
from app.services.email import send_password_reset_email
|
||||
from app.services.auth import AuthService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
user_data: UserRegister,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> Any:
|
||||
"""Register new user."""
|
||||
user_repo = UserRepository(User, db)
|
||||
auth_service = AuthService(user_repo)
|
||||
|
||||
# Check if user already exists
|
||||
if await user_repo.get_by_email(user_data.email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered"
|
||||
)
|
||||
|
||||
if await user_repo.get_by_username(user_data.username):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Username already taken"
|
||||
)
|
||||
|
||||
# Create user
|
||||
user = await auth_service.create_user(user_data.dict())
|
||||
return user
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> Any:
|
||||
"""OAuth2 compatible token login."""
|
||||
user_repo = UserRepository(User, db)
|
||||
auth_service = AuthService(user_repo)
|
||||
|
||||
user = await auth_service.authenticate_user(
|
||||
form_data.username,
|
||||
form_data.password
|
||||
)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Inactive user"
|
||||
)
|
||||
|
||||
# Create tokens
|
||||
access_token = create_access_token(subject=user.id)
|
||||
refresh_token = create_refresh_token(subject=user.id)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
}
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
async def refresh_token(
|
||||
refresh_data: RefreshToken,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> Any:
|
||||
"""Refresh access token."""
|
||||
payload = decode_token(refresh_data.refresh_token)
|
||||
|
||||
if payload is None or payload.get("type") != "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
user_repo = UserRepository(User, db)
|
||||
user = await user_repo.get(int(user_id))
|
||||
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
# Create new tokens
|
||||
access_token = create_access_token(subject=user.id)
|
||||
new_refresh_token = create_refresh_token(subject=user.id)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": new_refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
}
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def read_users_me(
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
) -> Any:
|
||||
"""Get current user."""
|
||||
return current_user
|
||||
|
||||
@router.post("/change-password", status_code=status.HTTP_200_OK)
|
||||
async def change_password(
|
||||
password_data: ChangePassword,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> Any:
|
||||
"""Change user password."""
|
||||
if not verify_password(password_data.current_password, current_user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Incorrect current password"
|
||||
)
|
||||
|
||||
user_repo = UserRepository(User, db)
|
||||
auth_service = AuthService(user_repo)
|
||||
|
||||
await auth_service.change_password(current_user.id, password_data.new_password)
|
||||
|
||||
return {"message": "Password changed successfully"}
|
||||
|
||||
@router.post("/password-reset", status_code=status.HTTP_200_OK)
|
||||
async def password_reset(
|
||||
reset_data: PasswordReset,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> Any:
|
||||
"""Request password reset."""
|
||||
user_repo = UserRepository(User, db)
|
||||
user = await user_repo.get_by_email(reset_data.email)
|
||||
|
||||
if user:
|
||||
# Generate reset token
|
||||
reset_token = create_access_token(
|
||||
subject=user.id,
|
||||
expires_delta=timedelta(hours=1) # 1 hour expiry
|
||||
)
|
||||
|
||||
# Send email with reset token
|
||||
background_tasks.add_task(
|
||||
send_password_reset_email,
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
token=reset_token
|
||||
)
|
||||
|
||||
# Always return success to prevent email enumeration
|
||||
return {"message": "Password reset email sent if account exists"}
|
||||
|
||||
@router.post("/password-reset-confirm", status_code=status.HTTP_200_OK)
|
||||
async def password_reset_confirm(
|
||||
reset_data: PasswordResetConfirm,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> Any:
|
||||
"""Confirm password reset."""
|
||||
payload = decode_token(reset_data.token)
|
||||
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid or expired reset token"
|
||||
)
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid reset token"
|
||||
)
|
||||
|
||||
user_repo = UserRepository(User, db)
|
||||
auth_service = AuthService(user_repo)
|
||||
|
||||
user = await user_repo.get(int(user_id))
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid reset token"
|
||||
)
|
||||
|
||||
await auth_service.change_password(user.id, reset_data.new_password)
|
||||
|
||||
return {"message": "Password reset successful"}
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_200_OK)
|
||||
async def logout(
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> Any:
|
||||
"""Logout user (invalidate token on client side)."""
|
||||
# In a more sophisticated setup, you might want to blacklist the token
|
||||
return {"message": "Successfully logged out"}
|
||||
```
|
||||
|
||||
## Authentication Service
|
||||
|
||||
```python
|
||||
# app/services/auth.py
|
||||
from typing import Optional
|
||||
from app.models.user import User
|
||||
from app.repositories.user import UserRepository
|
||||
from app.core.security import verify_password, get_password_hash
|
||||
|
||||
class AuthService:
|
||||
"""Authentication service."""
|
||||
|
||||
def __init__(self, user_repository: UserRepository):
|
||||
self.user_repo = user_repository
|
||||
|
||||
async def authenticate_user(
|
||||
self,
|
||||
username_or_email: str,
|
||||
password: str
|
||||
) -> Optional[User]:
|
||||
"""Authenticate user by username/email and password."""
|
||||
# Try to get user by username first, then by email
|
||||
user = await self.user_repo.get_by_username(username_or_email)
|
||||
if not user:
|
||||
user = await self.user_repo.get_by_email(username_or_email)
|
||||
|
||||
if not user:
|
||||
return None
|
||||
|
||||
if not verify_password(password, user.hashed_password):
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
async def create_user(self, user_data: dict) -> User:
|
||||
"""Create new user."""
|
||||
# Hash password
|
||||
password = user_data.pop('password')
|
||||
hashed_password = get_password_hash(password)
|
||||
|
||||
# Create user data
|
||||
user_create_data = {
|
||||
**user_data,
|
||||
'hashed_password': hashed_password,
|
||||
'is_active': True,
|
||||
'is_superuser': False
|
||||
}
|
||||
|
||||
return await self.user_repo.create(user_create_data)
|
||||
|
||||
async def change_password(self, user_id: int, new_password: str) -> bool:
|
||||
"""Change user password."""
|
||||
hashed_password = get_password_hash(new_password)
|
||||
|
||||
result = await self.user_repo.update(user_id, {
|
||||
'hashed_password': hashed_password
|
||||
})
|
||||
|
||||
return result is not None
|
||||
|
||||
async def activate_user(self, user_id: int) -> bool:
|
||||
"""Activate user account."""
|
||||
result = await self.user_repo.update(user_id, {'is_active': True})
|
||||
return result is not None
|
||||
|
||||
async def deactivate_user(self, user_id: int) -> bool:
|
||||
"""Deactivate user account."""
|
||||
result = await self.user_repo.update(user_id, {'is_active': False})
|
||||
return result is not None
|
||||
```
|
||||
|
||||
## Role-Based Access Control
|
||||
|
||||
```python
|
||||
# app/models/rbac.py
|
||||
from sqlalchemy import Column, String, Text, ForeignKey, Table
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.models.base import BaseModel
|
||||
|
||||
# Association tables for many-to-many relationships
|
||||
user_roles = Table(
|
||||
'user_roles',
|
||||
Base.metadata,
|
||||
Column('user_id', Integer, ForeignKey('users.id')),
|
||||
Column('role_id', Integer, ForeignKey('roles.id'))
|
||||
)
|
||||
|
||||
role_permissions = Table(
|
||||
'role_permissions',
|
||||
Base.metadata,
|
||||
Column('role_id', Integer, ForeignKey('roles.id')),
|
||||
Column('permission_id', Integer, ForeignKey('permissions.id'))
|
||||
)
|
||||
|
||||
class Role(BaseModel):
|
||||
"""Role model for RBAC."""
|
||||
__tablename__ = "roles"
|
||||
|
||||
name = Column(String(50), unique=True, nullable=False, index=True)
|
||||
description = Column(Text)
|
||||
|
||||
# Relationships
|
||||
users = relationship("User", secondary=user_roles, back_populates="roles")
|
||||
permissions = relationship("Permission", secondary=role_permissions, back_populates="roles")
|
||||
|
||||
class Permission(BaseModel):
|
||||
"""Permission model for RBAC."""
|
||||
__tablename__ = "permissions"
|
||||
|
||||
name = Column(String(100), unique=True, nullable=False, index=True)
|
||||
description = Column(Text)
|
||||
resource = Column(String(50), nullable=False) # e.g., 'users', 'posts'
|
||||
action = Column(String(50), nullable=False) # e.g., 'create', 'read', 'update', 'delete'
|
||||
|
||||
# Relationships
|
||||
roles = relationship("Role", secondary=role_permissions, back_populates="permissions")
|
||||
|
||||
# Update User model to include roles
|
||||
class User(BaseModel):
|
||||
# ... existing fields ...
|
||||
|
||||
# Relationships
|
||||
roles = relationship("Role", secondary=user_roles, back_populates="users")
|
||||
|
||||
@property
|
||||
def permissions(self) -> list[str]:
|
||||
"""Get all permissions for user."""
|
||||
perms = set()
|
||||
for role in self.roles:
|
||||
for permission in role.permissions:
|
||||
perms.add(f"{permission.resource}:{permission.action}")
|
||||
return list(perms)
|
||||
```
|
||||
|
||||
## OAuth2 Integration
|
||||
|
||||
```python
|
||||
# app/api/v1/oauth.py
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security.utils import get_authorization_scheme_param
|
||||
from starlette.requests import Request
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# OAuth configuration
|
||||
oauth = OAuth()
|
||||
|
||||
# Google OAuth
|
||||
google = oauth.register(
|
||||
name='google',
|
||||
client_id=settings.GOOGLE_CLIENT_ID,
|
||||
client_secret=settings.GOOGLE_CLIENT_SECRET,
|
||||
server_metadata_url='https://accounts.google.com/.well-known/openid_configuration',
|
||||
client_kwargs={
|
||||
'scope': 'openid email profile'
|
||||
}
|
||||
)
|
||||
|
||||
# GitHub OAuth
|
||||
github = oauth.register(
|
||||
name='github',
|
||||
client_id=settings.GITHUB_CLIENT_ID,
|
||||
client_secret=settings.GITHUB_CLIENT_SECRET,
|
||||
access_token_url='https://github.com/login/oauth/access_token',
|
||||
access_token_params=None,
|
||||
authorize_url='https://github.com/login/oauth/authorize',
|
||||
authorize_params=None,
|
||||
api_base_url='https://api.github.com/',
|
||||
client_kwargs={'scope': 'user:email'},
|
||||
)
|
||||
|
||||
@router.get('/google')
|
||||
async def google_login(request: Request):
|
||||
"""Initiate Google OAuth login."""
|
||||
redirect_uri = request.url_for('google_callback')
|
||||
return await google.authorize_redirect(request, redirect_uri)
|
||||
|
||||
@router.get('/google/callback')
|
||||
async def google_callback(request: Request):
|
||||
"""Handle Google OAuth callback."""
|
||||
token = await google.authorize_access_token(request)
|
||||
user_info = token.get('userinfo')
|
||||
|
||||
if user_info:
|
||||
# Create or get user
|
||||
# Generate JWT token
|
||||
# Return token
|
||||
pass
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="OAuth authentication failed"
|
||||
)
|
||||
```
|
||||
|
||||
## API Key Authentication
|
||||
|
||||
```python
|
||||
# app/models/api_key.py
|
||||
from sqlalchemy import Column, String, Boolean, ForeignKey, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.models.base import BaseModel
|
||||
import secrets
|
||||
|
||||
class APIKey(BaseModel):
|
||||
"""API Key model."""
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
name = Column(String(100), nullable=False)
|
||||
key_hash = Column(String(255), unique=True, nullable=False, index=True)
|
||||
prefix = Column(String(10), nullable=False, index=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
expires_at = Column(DateTime)
|
||||
last_used_at = Column(DateTime)
|
||||
|
||||
# Foreign key
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="api_keys")
|
||||
|
||||
@classmethod
|
||||
def generate_key(cls) -> tuple[str, str]:
|
||||
"""Generate API key and return (key, hash)."""
|
||||
key = secrets.token_urlsafe(32)
|
||||
prefix = key[:8]
|
||||
key_hash = get_password_hash(key)
|
||||
return key, prefix, key_hash
|
||||
|
||||
def verify_key(self, key: str) -> bool:
|
||||
"""Verify API key."""
|
||||
return verify_password(key, self.key_hash)
|
||||
|
||||
# Add to User model
|
||||
class User(BaseModel):
|
||||
# ... existing fields ...
|
||||
|
||||
# Relationships
|
||||
api_keys = relationship("APIKey", back_populates="user", cascade="all, delete-orphan")
|
||||
```
|
||||
|
||||
## Testing Authentication
|
||||
|
||||
```python
|
||||
# tests/test_auth.py
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from app.core.security import create_access_token
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_user(client: AsyncClient):
|
||||
"""Test user registration."""
|
||||
user_data = {
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"password": "testpass123",
|
||||
"first_name": "Test",
|
||||
"last_name": "User"
|
||||
}
|
||||
|
||||
response = await client.post("/api/v1/auth/register", json=user_data)
|
||||
assert response.status_code == 201
|
||||
|
||||
data = response.json()
|
||||
assert data["username"] == user_data["username"]
|
||||
assert data["email"] == user_data["email"]
|
||||
assert "hashed_password" not in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_user(client: AsyncClient, test_user):
|
||||
"""Test user login."""
|
||||
login_data = {
|
||||
"username": test_user.username,
|
||||
"password": "testpass123"
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
data=login_data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_user(client: AsyncClient, test_user):
|
||||
"""Test get current user endpoint."""
|
||||
token = create_access_token(subject=test_user.id)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
response = await client.get("/api/v1/auth/me", headers=headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["username"] == test_user.username
|
||||
assert data["email"] == test_user.email
|
||||
```
|
||||
Reference in New Issue
Block a user