Updated README.md

This commit is contained in:
2025-10-18 22:42:22 +02:00
parent 79a31ecf40
commit ece8af0678
24 changed files with 2782 additions and 755 deletions

View File

379
src/my_auth/api/routes.py Normal file
View File

@@ -0,0 +1,379 @@
"""
FastAPI routes for authentication module.
This module provides ready-to-use FastAPI routes for all authentication
operations. Routes are organized in an APIRouter with /auth prefix.
"""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from ..core.auth import AuthService
from ..exceptions import AuthError
from ..models.email_verification import (
EmailVerificationRequest,
PasswordResetRequest,
PasswordResetConfirm
)
from ..models.token import AccessTokenResponse, RefreshTokenRequest
from ..models.user import UserCreate, UserResponse
# OAuth2 scheme for token authentication
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def create_auth_router(auth_service: AuthService) -> APIRouter:
"""
Create and configure the authentication router.
This factory function creates an APIRouter with all authentication
endpoints configured. The router includes automatic exception handling
for authentication errors.
Args:
auth_service: Configured authentication service instance.
Returns:
Configured APIRouter ready to be included in a FastAPI app.
Example:
>>> from fastapi import FastAPI
>>> from auth_module.api.routes import create_auth_router
>>>
>>> app = FastAPI()
>>> auth_router = create_auth_router(auth_service)
>>> app.include_router(auth_router)
"""
router = APIRouter(prefix="/auth", tags=["authentication"])
def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> UserResponse:
"""
Dependency to extract and validate the current user from access token.
This dependency can be used in any route that requires authentication.
It extracts the Bearer token from the Authorization header and validates it.
Args:
token: JWT access token from Authorization header.
Returns:
The current authenticated user (as UserResponse).
Raises:
HTTPException: 401 if token is invalid or expired.
Example:
>>> @app.get("/protected")
>>> def protected_route(user: UserResponse = Depends(get_current_user)):
>>> return {"user_id": user.id}
"""
try:
user = auth_service.get_current_user(token)
return UserResponse(
id=user.id,
email=user.email,
username=user.username,
roles=user.roles,
user_settings=user.user_settings,
created_at=user.created_at,
updated_at=user.updated_at
)
except AuthError as e:
raise HTTPException(
status_code=e.status_code,
detail=e.message
)
@router.post(
"/register",
response_model=UserResponse,
status_code=status.HTTP_201_CREATED,
summary="Register a new user",
description="Create a new user account with email, username, and password."
)
def register(user_data: UserCreate) -> UserResponse:
"""
Register a new user.
Creates a new user account with the provided credentials. The password
is automatically hashed before storage. Email verification is optional
and the account is created with is_verified=False.
Args:
user_data: User registration data including email, username, and password.
Returns:
The created user information (without password).
Raises:
HTTPException: 409 if email already exists.
HTTPException: 422 if validation fails (password strength, etc.).
"""
try:
user = auth_service.register(user_data)
return UserResponse(
id=user.id,
email=user.email,
username=user.username,
roles=user.roles,
user_settings=user.user_settings,
created_at=user.created_at,
updated_at=user.updated_at
)
except AuthError as e:
raise HTTPException(
status_code=e.status_code,
detail=e.message
)
@router.post(
"/login",
response_model=AccessTokenResponse,
summary="Login with email and password",
description="Authenticate a user and receive access and refresh tokens."
)
def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]) -> AccessTokenResponse:
"""
Authenticate a user and generate tokens.
This endpoint accepts form data with username (which should contain the email)
and password. It returns both access and refresh tokens upon successful authentication.
Note: OAuth2PasswordRequestForm uses 'username' field, but we treat it as email.
Args:
form_data: OAuth2 form with username (email) and password fields.
Returns:
Access token (JWT) and refresh token with token_type="bearer".
Raises:
HTTPException: 401 if credentials are invalid.
HTTPException: 403 if account is disabled.
"""
try:
# OAuth2PasswordRequestForm uses 'username' but we treat it as email
user, tokens = auth_service.login(form_data.username, form_data.password)
return tokens
except AuthError as e:
raise HTTPException(
status_code=e.status_code,
detail=e.message
)
@router.post(
"/logout",
status_code=status.HTTP_204_NO_CONTENT,
summary="Logout user",
description="Revoke the refresh token to logout the user."
)
def logout(request: RefreshTokenRequest) -> None:
"""
Logout a user by revoking their refresh token.
This prevents the refresh token from being used to obtain new access tokens.
The current access token remains valid until it expires naturally (30 minutes).
Args:
request: Request body containing the refresh token to revoke.
Returns:
204 No Content on success.
"""
auth_service.logout(request.refresh_token)
return None
@router.post(
"/refresh",
response_model=AccessTokenResponse,
summary="Refresh access token",
description="Exchange a refresh token for new access and refresh tokens."
)
def refresh_token(request: RefreshTokenRequest) -> AccessTokenResponse:
"""
Obtain a new access token using a refresh token.
This endpoint allows clients to obtain a new access token without
requiring the user to re-enter their password. The old refresh token
is revoked and a new one is issued.
Args:
request: Request body containing the refresh token.
Returns:
New access token and refresh token.
Raises:
HTTPException: 401 if refresh token is invalid, expired, or revoked.
"""
try:
tokens = auth_service.refresh_access_token(request.refresh_token)
return tokens
except AuthError as e:
raise HTTPException(
status_code=e.status_code,
detail=e.message
)
@router.post(
"/password-reset-request",
status_code=status.HTTP_200_OK,
summary="Request password reset",
description="Generate a password reset token and return it (to be sent via email)."
)
def request_password_reset(request: PasswordResetRequest) -> dict:
"""
Request a password reset token.
This endpoint generates a secure token that can be used to reset the password.
In production, this token should be sent via email. For this module, the token
is returned in the response so the consuming application can handle email delivery.
Args:
request: Request body containing the email address.
Returns:
Dictionary with the reset token and a message.
Raises:
HTTPException: 404 if email is not registered.
"""
try:
token = auth_service.request_password_reset(request.email)
return {
"message": "Password reset token generated",
"token": token
}
except AuthError as e:
raise HTTPException(
status_code=e.status_code,
detail=e.message
)
@router.post(
"/password-reset",
status_code=status.HTTP_200_OK,
summary="Reset password with token",
description="Reset user password using a valid reset token."
)
def reset_password(request: PasswordResetConfirm) -> dict:
"""
Reset a user's password using a reset token.
This endpoint validates the reset token and updates the user's password.
All existing refresh tokens are revoked for security.
Args:
request: Request body containing the reset token and new password.
Returns:
Success message.
Raises:
HTTPException: 401 if token is invalid, expired, or already used.
HTTPException: 422 if new password doesn't meet requirements.
"""
try:
auth_service.reset_password(request.token, request.new_password)
return {"message": "Password reset successfully"}
except AuthError as e:
raise HTTPException(
status_code=e.status_code,
detail=e.message
)
@router.post(
"/verify-email-request",
status_code=status.HTTP_200_OK,
summary="Request email verification",
description="Generate an email verification token and return it (to be sent via email)."
)
def request_email_verification(request: EmailVerificationRequest) -> dict:
"""
Request an email verification token.
This endpoint generates a JWT token for email verification. In production,
this token should be sent via email with a verification link. For this module,
the token is returned in the response so the consuming application can handle
email delivery.
Args:
request: Request body containing the email address.
Returns:
Dictionary with the verification token and a message.
Raises:
HTTPException: 404 if email is not registered.
"""
try:
token = auth_service.request_email_verification(request.email)
return {
"message": "Email verification token generated",
"token": token
}
except AuthError as e:
raise HTTPException(
status_code=e.status_code,
detail=e.message
)
@router.get(
"/verify-email",
status_code=status.HTTP_200_OK,
summary="Verify email with token",
description="Verify user email using a verification token from query parameter."
)
def verify_email(token: str) -> dict:
"""
Verify a user's email address.
This endpoint validates the verification token and marks the user's
email as verified. It uses a query parameter so it can be easily
accessed via a link in an email.
Args:
token: Email verification token (JWT) from query parameter.
Returns:
Success message.
Raises:
HTTPException: 401 if token is invalid or expired.
"""
try:
auth_service.verify_email(token)
return {"message": "Email verified successfully"}
except AuthError as e:
raise HTTPException(
status_code=e.status_code,
detail=e.message
)
@router.get(
"/me",
response_model=UserResponse,
summary="Get current user",
description="Get information about the currently authenticated user."
)
def get_me(current_user: Annotated[UserResponse, Depends(get_current_user)]) -> UserResponse:
"""
Get current authenticated user information.
This is a protected route that requires a valid access token in the
Authorization header (Bearer token).
Args:
current_user: The authenticated user (injected by dependency).
Returns:
Current user information.
Raises:
HTTPException: 401 if token is invalid or expired.
"""
return current_user
return router

View File

@@ -7,439 +7,440 @@ password reset, and email verification.
"""
from datetime import datetime
from typing import Optional
from .password import PasswordManager
from .token import TokenManager
from ..exceptions import (
InvalidCredentialsError,
UserNotFoundError,
AccountDisabledError,
ExpiredTokenError,
InvalidTokenError,
RevokedTokenError
)
from ..models.token import AccessTokenResponse, TokenData
from ..models.user import UserCreate, UserInDB, UserUpdate
from ..persistence.base import UserRepository, TokenRepository
from ..models.user import UserCreate, UserInDB, UserUpdate
from ..models.token import AccessTokenResponse, TokenData
from ..email.base import EmailService
from ..exceptions import (
InvalidCredentialsError,
UserNotFoundError,
AccountDisabledError,
ExpiredTokenError,
InvalidTokenError,
RevokedTokenError
)
class AuthService:
"""
Main authentication service.
This service orchestrates all authentication-related operations by
coordinating between password management, token management, and
persistence layers.
Attributes:
user_repository: Repository for user persistence operations.
token_repository: Repository for token persistence operations.
password_manager: Manager for password hashing and verification.
token_manager: Manager for token creation and validation.
"""
def __init__(
self,
user_repository: UserRepository,
token_repository: TokenRepository,
password_manager: PasswordManager,
token_manager: TokenManager
):
"""
Initialize the authentication service.
Args:
user_repository: Repository for user persistence.
token_repository: Repository for token persistence.
jwt_secret: Secret key for JWT signing.
jwt_algorithm: JWT algorithm (default: HS256).
access_token_expire_minutes: Access token validity (default: 30).
refresh_token_expire_days: Refresh token validity (default: 7).
password_reset_token_expire_minutes: Reset token validity (default: 15).
password_hash_rounds: Bcrypt rounds (default: 12).
Main authentication service.
This service orchestrates all authentication-related operations by
coordinating between password management, token management, and
persistence layers.
Attributes:
user_repository: Repository for user persistence operations.
token_repository: Repository for token persistence operations.
password_manager: Manager for password hashing and verification.
token_manager: Manager for token creation and validation.
email_service: Optional service for sending emails.
"""
self.user_repository = user_repository
self.token_repository = token_repository
self.password_manager = password_manager
self.token_manager = token_manager
def register(self, user_data: UserCreate) -> UserInDB:
"""
Register a new user.
This method creates a new user account with hashed password.
The user's email is initially unverified.
Args:
user_data: User registration data including password.
Returns:
The created user (without password).
Raises:
UserAlreadyExistsError: If email is already registered.
Example:
>>> user_data = UserCreate(
... email="user@example.com",
... username="john_doe",
... password="SecurePass123!"
... )
>>> user = auth_service.register(user_data)
"""
hashed_password = self.password_manager.hash_password(user_data.password)
user = self.user_repository.create_user(user_data, hashed_password)
return user
def login(self, email: str, password: str) -> tuple[UserInDB, AccessTokenResponse]:
"""
Authenticate a user and create tokens.
This method verifies credentials, checks account status, and generates
both access and refresh tokens.
Args:
email: User's email address.
password: User's plain text password.
Returns:
Tuple of (user, tokens) where tokens contains access_token and refresh_token.
Raises:
InvalidCredentialsError: If email or password is incorrect.
AccountDisabledError: If user account is disabled.
Example:
>>> user, tokens = auth_service.login("user@example.com", "password")
>>> print(tokens.access_token)
"""
# Get user by email
user = self.user_repository.get_user_by_email(email)
if not user:
raise InvalidCredentialsError()
# Verify password
if not self.password_manager.verify_password(password, user.hashed_password):
raise InvalidCredentialsError()
def __init__(
self,
user_repository: UserRepository,
token_repository: TokenRepository,
password_manager: PasswordManager,
token_manager: TokenManager,
email_service: Optional[EmailService] = None
):
"""
Initialize the authentication service.
Args:
user_repository: Repository for user persistence.
token_repository: Repository for token persistence.
password_manager: Manager for password hashing and verification.
token_manager: Manager for token creation and validation.
email_service: Optional service for sending emails (password reset, verification).
"""
self.user_repository = user_repository
self.token_repository = token_repository
self.password_manager = password_manager
self.token_manager = token_manager
self.email_service = email_service
# Check if account is active
if not user.is_active:
raise AccountDisabledError()
def register(self, user_data: UserCreate) -> UserInDB:
"""
Register a new user.
This method creates a new user account with hashed password.
The user's email is initially unverified.
Args:
user_data: User registration data including password.
Returns:
The created user (without password).
Raises:
UserAlreadyExistsError: If email is already registered.
Example:
>>> user_data = UserCreate(
... email="user@example.com",
... username="john_doe",
... password="SecurePass123!"
... )
>>> user = auth_service.register(user_data)
"""
hashed_password = self.password_manager.hash_password(user_data.password)
user = self.user_repository.create_user(user_data, hashed_password)
return user
# Create tokens
access_token = self.token_manager.create_access_token(user)
refresh_token = self.token_manager.create_refresh_token()
def login(self, email: str, password: str) -> tuple[UserInDB, AccessTokenResponse]:
"""
Authenticate a user and create tokens.
This method verifies credentials, checks account status, and generates
both access and refresh tokens.
Args:
email: User's email address.
password: User's plain text password.
Returns:
Tuple of (user, tokens) where tokens contains access_token and refresh_token.
Raises:
InvalidCredentialsError: If email or password is incorrect.
AccountDisabledError: If user account is disabled.
Example:
>>> user, tokens = auth_service.login("user@example.com", "password")
>>> print(tokens.access_token)
"""
# Get user by email
user = self.user_repository.get_user_by_email(email)
if not user:
raise InvalidCredentialsError()
# Verify password
if not self.password_manager.verify_password(password, user.hashed_password):
raise InvalidCredentialsError()
# Check if account is active
if not user.is_active:
raise AccountDisabledError()
# Create tokens
access_token = self.token_manager.create_access_token(user)
refresh_token = self.token_manager.create_refresh_token()
# Store refresh token in database
token_data = TokenData(
token=refresh_token,
token_type="refresh",
user_id=user.id,
expires_at=self.token_manager.get_refresh_token_expiration(),
created_at=datetime.utcnow(),
is_revoked=False
)
self.token_repository.save_token(token_data)
tokens = AccessTokenResponse(
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer"
)
return user, tokens
# Store refresh token in database
token_data = TokenData(
token=refresh_token,
token_type="refresh",
user_id=user.id,
expires_at=self.token_manager.get_refresh_token_expiration(),
created_at=datetime.now(),
is_revoked=False
)
self.token_repository.save_token(token_data)
def refresh_access_token(self, refresh_token: str) -> AccessTokenResponse:
"""
Create a new access token using a refresh token.
This method validates the refresh token and generates a new access token
without requiring the user to re-enter their password.
Args:
refresh_token: The refresh token to exchange.
Returns:
New access and refresh tokens.
Raises:
InvalidTokenError: If refresh token is invalid.
ExpiredTokenError: If refresh token has expired.
RevokedTokenError: If refresh token has been revoked.
UserNotFoundError: If user no longer exists.
AccountDisabledError: If user account is disabled.
Example:
>>> tokens = auth_service.refresh_access_token(old_refresh_token)
>>> print(tokens.access_token)
"""
# Validate refresh token
token_data = self.token_repository.get_token(refresh_token, "refresh")
if not token_data:
raise InvalidTokenError("Invalid refresh token")
if token_data.is_revoked:
raise RevokedTokenError()
if token_data.expires_at < datetime.utcnow():
raise ExpiredTokenError()
# Get user
user = self.user_repository.get_user_by_id(token_data.user_id)
if not user:
raise UserNotFoundError()
if not user.is_active:
raise AccountDisabledError()
# Create new tokens
access_token = self.token_manager.create_access_token(user)
new_refresh_token = self.token_manager.create_refresh_token()
# Revoke old refresh token
self.token_repository.revoke_token(refresh_token)
# Store new refresh token
new_token_data = TokenData(
token=new_refresh_token,
token_type="refresh",
user_id=user.id,
expires_at=self.token_manager.get_refresh_token_expiration(),
created_at=datetime.utcnow(),
is_revoked=False
)
self.token_repository.save_token(new_token_data)
return AccessTokenResponse(
access_token=access_token,
refresh_token=new_refresh_token,
token_type="bearer"
)
tokens = AccessTokenResponse(
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer"
)
def logout(self, refresh_token: str) -> bool:
"""
Logout a user by revoking their refresh token.
This prevents the refresh token from being used to obtain new
access tokens. The current access token will remain valid until
it expires naturally.
Args:
refresh_token: The refresh token to revoke.
Returns:
True if logout was successful, False if token not found.
Example:
>>> auth_service.logout(refresh_token)
True
"""
return self.token_repository.revoke_token(refresh_token)
return user, tokens
def refresh_access_token(self, refresh_token: str) -> AccessTokenResponse:
"""
Create a new access token using a refresh token.
This method validates the refresh token and generates a new access token
without requiring the user to re-enter their password.
Args:
refresh_token: The refresh token to exchange.
Returns:
New access and refresh tokens.
Raises:
InvalidTokenError: If refresh token is invalid.
ExpiredTokenError: If refresh token has expired.
RevokedTokenError: If refresh token has been revoked.
UserNotFoundError: If user no longer exists.
AccountDisabledError: If user account is disabled.
Example:
>>> tokens = auth_service.refresh_access_token(old_refresh_token)
>>> print(tokens.access_token)
"""
# Validate refresh token
token_data = self.token_repository.get_token(refresh_token, "refresh")
if not token_data:
raise InvalidTokenError("Invalid refresh token")
def request_password_reset(self, email: str) -> str:
"""
Generate a password reset token for a user.
This method creates a secure token that can be sent to the user
via email to reset their password. If an email service is configured,
the email is sent automatically. Otherwise, the token is returned
for manual handling.
Args:
email: User's email address.
Returns:
The password reset token to be sent via email.
Raises:
UserNotFoundError: If email is not registered.
Example:
>>> token = auth_service.request_password_reset("user@example.com")
>>> # Token is automatically sent via email if service is configured
"""
user = self.user_repository.get_user_by_email(email)
if not user:
raise UserNotFoundError(f"No user found with email {email}")
# Create reset token
reset_token = self.token_manager.create_password_reset_token()
# Store token in database
token_data = TokenData(
token=reset_token,
token_type="password_reset",
user_id=user.id,
expires_at=self.token_manager.get_password_reset_token_expiration(),
created_at=datetime.utcnow(),
is_revoked=False
)
self.token_repository.save_token(token_data)
# Send email if service is configured
if self.email_service:
self.email_service.send_password_reset_email(email, reset_token)
return reset_token
if token_data.is_revoked:
raise RevokedTokenError()
def reset_password(self, token: str, new_password: str) -> bool:
"""
Reset a user's password using a reset token.
This method validates the reset token and updates the user's password.
All existing refresh tokens for the user are revoked for security.
Args:
token: Password reset token.
new_password: New plain text password (will be hashed).
Returns:
True if password was reset successfully.
Raises:
InvalidTokenError: If reset token is invalid.
ExpiredTokenError: If reset token has expired.
RevokedTokenError: If reset token has been used.
UserNotFoundError: If user no longer exists.
Example:
>>> auth_service.reset_password(token, "NewSecurePass123!")
True
"""
# Validate reset token
token_data = self.token_repository.get_token(token, "password_reset")
if not token_data:
raise InvalidTokenError("Invalid password reset token")
if token_data.is_revoked:
raise RevokedTokenError("Password reset token has already been used")
if token_data.expires_at < datetime.utcnow():
raise ExpiredTokenError("Password reset token has expired")
# Get user
user = self.user_repository.get_user_by_id(token_data.user_id)
if not user:
raise UserNotFoundError()
# Hash new password
hashed_password = self.password_manager.hash_password(new_password)
# Update user password
updates = UserUpdate(password=hashed_password)
self.user_repository.update_user(user.id, updates)
# Revoke the reset token
self.token_repository.revoke_token(token)
# Revoke all user's refresh tokens for security
self.token_repository.revoke_all_user_tokens(user.id, "refresh")
return True
if token_data.expires_at < datetime.now():
raise ExpiredTokenError()
def request_email_verification(self, email: str) -> str:
"""
Generate an email verification token for a user.
This method creates a JWT token that can be sent to the user
to verify their email address. If an email service is configured,
the email is sent automatically. Otherwise, the token is returned
for manual handling.
Args:
email: User's email address.
Returns:
The email verification token (JWT) to be sent via email.
Raises:
UserNotFoundError: If email is not registered.
Example:
>>> token = auth_service.request_email_verification("user@example.com")
>>> # Token is automatically sent via email if service is configured
"""
user = self.user_repository.get_user_by_email(email)
if not user:
raise UserNotFoundError(f"No user found with email {email}")
verification_token = self.token_manager.create_email_verification_token(email)
# Send email if service is configured
if self.email_service:
self.email_service.send_verification_email(email, verification_token)
return verification_token
# Get user
user = self.user_repository.get_user_by_id(token_data.user_id)
if not user:
raise UserNotFoundError()
def verify_email(self, token: str) -> bool:
"""
Verify a user's email address using a verification token.
This method decodes the JWT token, extracts the email, and marks
the user's email as verified.
Args:
token: Email verification token (JWT).
Returns:
True if email was verified successfully.
Raises:
InvalidTokenError: If verification token is invalid.
ExpiredTokenError: If verification token has expired.
UserNotFoundError: If user no longer exists.
Example:
>>> auth_service.verify_email(token)
True
"""
# Decode and validate token
email = self.token_manager.decode_email_verification_token(token)
# Get user
user = self.user_repository.get_user_by_email(email)
if not user:
raise UserNotFoundError(f"No user found with email {email}")
# Update user's verified status
updates = UserUpdate(is_verified=True)
self.user_repository.update_user(user.id, updates)
return True
if not user.is_active:
raise AccountDisabledError()
# Create new tokens
access_token = self.token_manager.create_access_token(user)
new_refresh_token = self.token_manager.create_refresh_token()
# Revoke old refresh token
self.token_repository.revoke_token(refresh_token)
# Store new refresh token
new_token_data = TokenData(
token=new_refresh_token,
token_type="refresh",
user_id=user.id,
expires_at=self.token_manager.get_refresh_token_expiration(),
created_at=datetime.now(),
is_revoked=False
)
self.token_repository.save_token(new_token_data)
return AccessTokenResponse(
access_token=access_token,
refresh_token=new_refresh_token,
token_type="bearer"
)
def logout(self, refresh_token: str) -> bool:
"""
Logout a user by revoking their refresh token.
This prevents the refresh token from being used to obtain new
access tokens. The current access token will remain valid until
it expires naturally.
Args:
refresh_token: The refresh token to revoke.
Returns:
True if logout was successful, False if token not found.
Example:
>>> auth_service.logout(refresh_token)
True
"""
return self.token_repository.revoke_token(refresh_token)
def request_password_reset(self, email: str) -> str:
"""
Generate a password reset token for a user.
This method creates a secure token that can be sent to the user
via email to reset their password.
Args:
email: User's email address.
Returns:
The password reset token to be sent via email.
Raises:
UserNotFoundError: If email is not registered.
Example:
>>> token = auth_service.request_password_reset("user@example.com")
>>> # Send token via email service
"""
user = self.user_repository.get_user_by_email(email)
if not user:
raise UserNotFoundError(f"No user found with email {email}")
# Create reset token
reset_token = self.token_manager.create_password_reset_token()
# Store token in database
token_data = TokenData(
token=reset_token,
token_type="password_reset",
user_id=user.id,
expires_at=self.token_manager.get_password_reset_token_expiration(),
created_at=datetime.now(),
is_revoked=False
)
self.token_repository.save_token(token_data)
return reset_token
def reset_password(self, token: str, new_password: str) -> bool:
"""
Reset a user's password using a reset token.
This method validates the reset token and updates the user's password.
All existing refresh tokens for the user are revoked for security.
Args:
token: Password reset token.
new_password: New plain text password (will be hashed).
Returns:
True if password was reset successfully.
Raises:
InvalidTokenError: If reset token is invalid.
ExpiredTokenError: If reset token has expired.
RevokedTokenError: If reset token has been used.
UserNotFoundError: If user no longer exists.
Example:
>>> auth_service.reset_password(token, "NewSecurePass123!")
True
"""
# Validate reset token
token_data = self.token_repository.get_token(token, "password_reset")
if not token_data:
raise InvalidTokenError("Invalid password reset token")
if token_data.is_revoked:
raise RevokedTokenError("Password reset token has already been used")
if token_data.expires_at < datetime.now():
raise ExpiredTokenError("Password reset token has expired")
# Get user
user = self.user_repository.get_user_by_id(token_data.user_id)
if not user:
raise UserNotFoundError()
# Hash new password
hashed_password = self.password_manager.hash_password(new_password)
# Update user password
updates = UserUpdate(password=hashed_password)
self.user_repository.update_user(user.id, updates)
# Revoke the reset token
self.token_repository.revoke_token(token)
# Revoke all user's refresh tokens for security
self.token_repository.revoke_all_user_tokens(user.id, "refresh")
return True
def request_email_verification(self, email: str) -> str:
"""
Generate an email verification token for a user.
This method creates a JWT token that can be sent to the user
to verify their email address.
Args:
email: User's email address.
Returns:
The email verification token (JWT) to be sent via email.
Raises:
UserNotFoundError: If email is not registered.
Example:
>>> token = auth_service.request_email_verification("user@example.com")
>>> # Send token via email service
"""
user = self.user_repository.get_user_by_email(email)
if not user:
raise UserNotFoundError(f"No user found with email {email}")
return self.token_manager.create_email_verification_token(email)
def verify_email(self, token: str) -> bool:
"""
Verify a user's email address using a verification token.
This method decodes the JWT token, extracts the email, and marks
the user's email as verified.
Args:
token: Email verification token (JWT).
Returns:
True if email was verified successfully.
Raises:
InvalidTokenError: If verification token is invalid.
ExpiredTokenError: If verification token has expired.
UserNotFoundError: If user no longer exists.
Example:
>>> auth_service.verify_email(token)
True
"""
# Decode and validate token
email = self.token_manager.decode_email_verification_token(token)
# Get user
user = self.user_repository.get_user_by_email(email)
if not user:
raise UserNotFoundError(f"No user found with email {email}")
# Update user's verified status
updates = UserUpdate(is_verified=True)
self.user_repository.update_user(user.id, updates)
return True
def get_current_user(self, access_token: str) -> UserInDB:
"""
Retrieve the current user from an access token.
This method decodes and validates the JWT access token and
retrieves the corresponding user from the database.
Args:
access_token: JWT access token.
Returns:
The user associated with the token.
Raises:
InvalidTokenError: If access token is invalid.
ExpiredTokenError: If access token has expired.
UserNotFoundError: If user no longer exists.
AccountDisabledError: If user account is disabled.
Example:
>>> user = auth_service.get_current_user(access_token)
>>> print(user.email)
"""
# Decode and validate token
token_payload = self.token_manager.decode_access_token(access_token)
# Get user
user = self.user_repository.get_user_by_id(token_payload.sub)
if not user:
raise UserNotFoundError()
if not user.is_active:
raise AccountDisabledError()
return user
def get_default_sqlite_auth_service(db_path: str, jwt_secret: str) -> AuthService:
from my_auth.persistence.sqlite import SQLiteUserRepository
from my_auth.persistence.sqlite import SQLiteTokenRepository
user_repository = SQLiteUserRepository(db_path=db_path)
token_repository = SQLiteTokenRepository(db_path=db_path)
password_manager = PasswordManager()
token_manager = TokenManager(jwt_secret=jwt_secret)
return AuthService(
user_repository=user_repository,
token_repository=token_repository,
password_manager=password_manager,
token_manager=token_manager
)
def get_current_user(self, access_token: str) -> UserInDB:
"""
Retrieve the current user from an access token.
This method decodes and validates the JWT access token and
retrieves the corresponding user from the database.
Args:
access_token: JWT access token.
Returns:
The user associated with the token.
Raises:
InvalidTokenError: If access token is invalid.
ExpiredTokenError: If access token has expired.
UserNotFoundError: If user no longer exists.
AccountDisabledError: If user account is disabled.
Example:
>>> user = auth_service.get_current_user(access_token)
>>> print(user.email)
"""
# Decode and validate token
token_payload = self.token_manager.decode_access_token(access_token)
# Get user
user = self.user_repository.get_user_by_id(token_payload.sub)
if not user:
raise UserNotFoundError()
if not user.is_active:
raise AccountDisabledError()
return user

View File

64
src/my_auth/email/base.py Normal file
View File

@@ -0,0 +1,64 @@
"""
Abstract base class for email service.
This module defines the interface that all email service implementations
must follow. It provides methods for sending authentication-related emails.
"""
from abc import ABC, abstractmethod
class EmailService(ABC):
"""
Abstract base class for email service operations.
This interface defines all methods required for sending authentication-related
emails. Concrete implementations can use different email providers (SMTP,
SendGrid, AWS SES, etc.).
"""
@abstractmethod
def send_verification_email(self, email: str, token: str) -> None:
"""
Send an email verification link to the user.
This method should send an email containing a verification link or token
that the user can use to verify their email address.
Args:
email: The recipient's email address.
token: The verification token (JWT) to include in the email.
Raises:
Exception: If email sending fails (implementation-specific).
Example:
>>> email_service.send_verification_email(
... "user@example.com",
... "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
... )
"""
pass
@abstractmethod
def send_password_reset_email(self, email: str, token: str) -> None:
"""
Send a password reset link to the user.
This method should send an email containing a password reset link or token
that the user can use to reset their password.
Args:
email: The recipient's email address.
token: The password reset token to include in the email.
Raises:
Exception: If email sending fails (implementation-specific).
Example:
>>> email_service.send_password_reset_email(
... "user@example.com",
... "a1b2c3d4e5f6..."
... )
"""
pass

211
src/my_auth/email/smtp.py Normal file
View File

@@ -0,0 +1,211 @@
"""
SMTP email service implementation.
This module provides an SMTP-based implementation of the email service
for sending authentication-related emails.
"""
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Optional
from .base import EmailService
# Default email templates
DEFAULT_VERIFICATION_TEMPLATE = """
<html>
<body>
<h2>Email Verification</h2>
<p>Please click the link below to verify your email address:</p>
<p><a href="{verification_link}">Verify Email</a></p>
<p>Or copy and paste this link into your browser:</p>
<p>{verification_link}</p>
<p>This link will expire in 7 days.</p>
<p>If you did not request this verification, please ignore this email.</p>
</body>
</html>
"""
DEFAULT_PASSWORD_RESET_TEMPLATE = """
<html>
<body>
<h2>Password Reset</h2>
<p>You have requested to reset your password. Please click the link below:</p>
<p><a href="{reset_link}">Reset Password</a></p>
<p>Or copy and paste this link into your browser:</p>
<p>{reset_link}</p>
<p>This link will expire in 15 minutes.</p>
<p>If you did not request a password reset, please ignore this email.</p>
</body>
</html>
"""
class SMTPEmailService(EmailService):
"""
SMTP implementation of EmailService.
This implementation uses standard SMTP protocol to send emails.
It supports both TLS and SSL connections and allows custom HTML
templates for email content.
Attributes:
host: SMTP server hostname.
port: SMTP server port (587 for TLS, 465 for SSL).
username: SMTP authentication username.
password: SMTP authentication password.
use_tls: Whether to use TLS (default: True).
from_email: Email address to use as sender.
from_name: Display name for sender (optional).
base_url: Base URL for constructing verification/reset links.
verification_template: HTML template for verification emails.
password_reset_template: HTML template for password reset emails.
"""
def __init__(
self,
host: str,
port: int,
username: str,
password: str,
from_email: str,
base_url: str,
use_tls: bool = True,
from_name: Optional[str] = None,
verification_template: Optional[str] = None,
password_reset_template: Optional[str] = None
):
"""
Initialize SMTP email service.
Args:
host: SMTP server hostname (e.g., "smtp.gmail.com").
port: SMTP server port (587 for TLS, 465 for SSL).
username: SMTP authentication username.
password: SMTP authentication password.
from_email: Email address to use as sender.
base_url: Base URL for your application (e.g., "https://myapp.com").
use_tls: Whether to use TLS encryption (default: True).
from_name: Display name for sender (default: None).
verification_template: Custom HTML template for verification emails
(must include {verification_link} placeholder).
password_reset_template: Custom HTML template for reset emails
(must include {reset_link} placeholder).
Example:
>>> email_service = SMTPEmailService(
... host="smtp.gmail.com",
... port=587,
... username="noreply@myapp.com",
... password="app_password",
... from_email="noreply@myapp.com",
... base_url="https://myapp.com",
... from_name="My Application"
... )
"""
self.host = host
self.port = port
self.username = username
self.password = password
self.use_tls = use_tls
self.from_email = from_email
self.from_name = from_name
self.base_url = base_url.rstrip('/')
# Use custom templates or defaults
self.verification_template = verification_template or DEFAULT_VERIFICATION_TEMPLATE
self.password_reset_template = password_reset_template or DEFAULT_PASSWORD_RESET_TEMPLATE
def _send_email(self, to_email: str, subject: str, html_content: str) -> None:
"""
Send an email via SMTP.
Internal method that handles the actual SMTP connection and sending.
Args:
to_email: Recipient email address.
subject: Email subject line.
html_content: HTML content of the email.
Raises:
smtplib.SMTPException: If email sending fails.
"""
# Create message
message = MIMEMultipart("alternative")
message["Subject"] = subject
message["From"] = f"{self.from_name} <{self.from_email}>" if self.from_name else self.from_email
message["To"] = to_email
# Attach HTML content
html_part = MIMEText(html_content, "html")
message.attach(html_part)
# Send email
if self.use_tls:
with smtplib.SMTP(self.host, self.port) as server:
server.starttls()
server.login(self.username, self.password)
server.send_message(message)
else:
with smtplib.SMTP_SSL(self.host, self.port) as server:
server.login(self.username, self.password)
server.send_message(message)
def send_verification_email(self, email: str, token: str) -> None:
"""
Send an email verification link to the user.
Constructs a verification link using the base_url and token,
then sends an email using the configured verification template.
Args:
email: The recipient's email address.
token: The verification token (JWT).
Raises:
smtplib.SMTPException: If email sending fails.
Example:
>>> email_service.send_verification_email(
... "user@example.com",
... "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
... )
"""
verification_link = f"{self.base_url}/auth/verify-email?token={token}"
html_content = self.verification_template.format(verification_link=verification_link)
self._send_email(
to_email=email,
subject="Verify Your Email Address",
html_content=html_content
)
def send_password_reset_email(self, email: str, token: str) -> None:
"""
Send a password reset link to the user.
Constructs a password reset link using the base_url and token,
then sends an email using the configured password reset template.
Args:
email: The recipient's email address.
token: The password reset token.
Raises:
smtplib.SMTPException: If email sending fails.
Example:
>>> email_service.send_password_reset_email(
... "user@example.com",
... "a1b2c3d4e5f6..."
... )
"""
reset_link = f"{self.base_url}/reset-password?token={token}"
html_content = self.password_reset_template.format(reset_link=reset_link)
self._send_email(
to_email=email,
subject="Reset Your Password",
html_content=html_content
)

View File

@@ -162,7 +162,7 @@ class SQLiteUserRepository(UserRepository):
raise UserAlreadyExistsError(f"User with email {user_data.email} already exists")
user_id = str(uuid4())
now = datetime.utcnow()
now = datetime.now()
user = UserInDB(
id=user_id,
@@ -315,7 +315,7 @@ class SQLiteUserRepository(UserRepository):
# Always update the updated_at timestamp
update_fields.append("updated_at = ?")
update_values.append(datetime.utcnow().isoformat())
update_values.append(datetime.now().isoformat())
# Add user_id for WHERE clause
update_values.append(user_id)
@@ -574,7 +574,7 @@ class SQLiteTokenRepository(TokenRepository):
Returns:
Number of tokens deleted.
"""
now = datetime.utcnow().isoformat()
now = datetime.now().isoformat()
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()