Implement complete user authentication flow with registration, email verification, and password reset

This commit is contained in:
Automated Action 2025-06-11 19:12:46 +00:00
parent 04a64ff424
commit 12b8363942
11 changed files with 636 additions and 23 deletions

View File

@ -104,6 +104,60 @@ A FastAPI-based REST API for managing inventory in small businesses. This system
└── requirements.txt # Dependencies
```
## Authentication Flow
The system implements a complete authentication flow with the following features:
### User Registration and Email Verification
1. Users register via `POST /api/v1/auth/register` with their email, password, and optional details
2. The system creates a new user account with a verification token
3. If email sending is enabled, a verification email is sent to the user's email address
4. Users verify their email by clicking the link in the email, which calls `POST /api/v1/auth/verify-email`
5. Email verification is optional by default but can be enforced by uncommenting the verification check in the login endpoint
### Login and Authentication
1. Users login via `POST /api/v1/auth/login` with their email (as username) and password
2. Upon successful authentication, the system returns a JWT access token
3. This token must be included in the Authorization header as `Bearer {token}` for protected endpoints
4. Tokens expire after the time specified in `ACCESS_TOKEN_EXPIRE_MINUTES` (default: 8 days)
### Password Reset Flow
1. Users request a password reset via `POST /api/v1/auth/password-reset-request` with their email
2. If the email exists, a reset token is generated and a reset link is sent to the user's email
3. Users reset their password via `POST /api/v1/auth/reset-password` with the token and new password
4. Reset tokens expire after the time specified in `PASSWORD_RESET_TOKEN_EXPIRE_HOURS` (default: 24 hours)
### Environment Variables for Authentication
The following environment variables can be configured:
```
# Security
SECRET_KEY="your-secret-key-here"
ACCESS_TOKEN_EXPIRE_MINUTES=11520 # 8 days
# Email verification
EMAILS_ENABLED=True
VERIFICATION_TOKEN_EXPIRE_HOURS=48
PASSWORD_RESET_TOKEN_EXPIRE_HOURS=24
# SMTP settings for email
SMTP_TLS=True
SMTP_PORT=587
SMTP_HOST=smtp.example.com
SMTP_USER=user@example.com
SMTP_PASSWORD=your-smtp-password
EMAILS_FROM_EMAIL=noreply@example.com
EMAILS_FROM_NAME=YourAppName
# First Superuser
FIRST_SUPERUSER_EMAIL=admin@example.com
FIRST_SUPERUSER_PASSWORD=adminpassword
```
## Getting Started
### Prerequisites
@ -157,7 +211,11 @@ uvicorn main:app --reload
### Authentication
- `POST /api/v1/auth/register` - Register a new user
- `POST /api/v1/auth/login` - Obtain JWT token
- `POST /api/v1/auth/verify-email` - Verify user's email address
- `POST /api/v1/auth/password-reset-request` - Request password reset
- `POST /api/v1/auth/reset-password` - Reset password with token
- `POST /api/v1/auth/test-token` - Test token validity
### Users

View File

@ -66,6 +66,21 @@ def get_current_active_superuser(
return current_user
def get_current_verified_user(
current_user: User = Depends(get_current_user),
) -> User:
"""
Check if the current user has verified their email.
"""
if not current_user.is_verified:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Email not verified. Please check your email for verification instructions.",
)
return current_user
def authenticate_user(
db: Session, email: str, password: str
) -> Optional[User]:

View File

@ -1,52 +1,265 @@
from datetime import timedelta
from typing import Any
from typing import Any, Dict
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Body, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app.api.deps import authenticate_user
from app.api.deps import authenticate_user, get_current_user
from app.core.config import settings
from app.core.email import send_password_reset_email, send_verification_email
from app.core.security import create_access_token
from app.crud import user
from app.db.utils import get_db
from app.schemas.token import Token
from app.models.user import User
from app.schemas.token import (
EmailVerificationToken,
PasswordReset,
PasswordResetRequest,
PasswordResetToken,
Token,
)
from app.schemas.user import User as UserSchema
from app.schemas.user import UserCreate
router = APIRouter()
@router.post("/register", response_model=UserSchema)
def register_user(
*,
db: Session = Depends(get_db),
user_in: UserCreate,
) -> Any:
"""
Register a new user.
This endpoint allows creating a new user account with the following fields:
- email: The user's email address (must be unique)
- password: The user's password
- full_name: The user's full name (optional)
Upon successful registration:
- A new user account is created
- If email verification is enabled, a verification email is sent
- The user is created with is_active=True and is_verified=False
Notes:
- The password is securely hashed before storage
- Email addresses are unique, attempting to register with an existing email will fail
"""
# Check if user with this email already exists
db_user = user.get_by_email(db, email=user_in.email)
if db_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="The user with this email already exists in the system",
)
# Create new user
new_user = user.create(db, obj_in=user_in)
# Send verification email if emails are enabled
if settings.EMAILS_ENABLED:
send_verification_email(
email_to=new_user.email,
token=new_user.verification_token,
)
return new_user
@router.post("/verify-email", response_model=Dict[str, str])
def verify_email(
*,
db: Session = Depends(get_db),
token_in: EmailVerificationToken,
) -> Any:
"""
Verify a user's email address.
This endpoint verifies a user's email address using the token sent via email.
Parameters:
- token: The verification token from the email
Upon successful verification:
- The user's account is marked as verified (is_verified=True)
- The verification token is cleared
Notes:
- Tokens expire after the time specified in settings.VERIFICATION_TOKEN_EXPIRE_HOURS
- This endpoint should be called when a user clicks the verification link in their email
"""
verified_user = user.verify_email(db, token=token_in.token)
if not verified_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired verification token",
)
return {"msg": "Email verified successfully"}
@router.post("/login", response_model=Token)
def login_access_token(
db: Session = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends()
) -> Any:
"""
OAuth2 compatible token login, get an access token for future requests.
OAuth2 compatible token login.
This endpoint follows the OAuth2 password flow standard:
- Accepts username (email) and password via form data
- Returns a JWT access token for authenticated users
Parameters:
- username: The user's email address (passed as 'username' in the OAuth2 form)
- password: The user's password
Returns:
- access_token: JWT token for authenticating future requests
- token_type: Token type (always "bearer")
Authentication requirements:
- Valid email and password combination
- User must be active (is_active=True)
- Email verification can be required by uncommenting the verification check
Notes:
- The access token expires after the time specified in settings.ACCESS_TOKEN_EXPIRE_MINUTES
- To use the token, include it in the Authorization header of future requests
as "Bearer {token}"
"""
user = authenticate_user(db, form_data.username, form_data.password)
if not user:
authenticated_user = authenticate_user(db, form_data.username, form_data.password)
if not authenticated_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
if not authenticated_user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user",
)
# Optionally check if email is verified
# if not authenticated_user.is_verified:
# raise HTTPException(
# status_code=status.HTTP_400_BAD_REQUEST,
# detail="Email not verified. Please check your email for verification instructions.",
# )
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
return {
"access_token": create_access_token(
user.id, expires_delta=access_token_expires
authenticated_user.id, expires_delta=access_token_expires
),
"token_type": "bearer",
}
@router.post("/test-token", response_model=None)
def test_token() -> Any:
@router.post("/password-reset-request", response_model=Dict[str, str])
def request_password_reset(
*,
db: Session = Depends(get_db),
reset_request: PasswordResetRequest,
) -> Any:
"""
Test access token.
Request a password reset.
This endpoint initiates the password reset process:
- Takes a user's email address
- Generates a password reset token if the email exists
- Sends a reset link to the user's email
Parameters:
- email: The email address of the account to reset
Security features:
- The endpoint always returns a success message, regardless of whether
the email exists (to prevent email enumeration)
- The reset token has a limited validity defined by settings.PASSWORD_RESET_TOKEN_EXPIRE_HOURS
Notes:
- Email sending must be enabled (settings.EMAILS_ENABLED=True) for the email to be sent
- The reset link includes a token that will be used in the reset-password endpoint
"""
return {"msg": "Token is valid"}
# For security, we don't reveal if the email exists
reset_token = user.create_password_reset_token(db, email=reset_request.email)
if reset_token and settings.EMAILS_ENABLED:
send_password_reset_email(
email_to=reset_request.email,
token=reset_token,
)
return {"msg": "If your email is registered, you will receive a password reset link"}
@router.post("/reset-password", response_model=Dict[str, str])
def reset_password(
*,
db: Session = Depends(get_db),
reset_data: PasswordReset,
) -> Any:
"""
Reset a user's password.
This endpoint completes the password reset process:
- Takes the reset token and new password
- Validates the token
- Updates the user's password if the token is valid
Parameters:
- token: The password reset token received via email
- new_password: The new password to set
Upon successful reset:
- The user's password is updated (hashed and stored)
- The reset token is cleared
Security features:
- Tokens expire after the time specified in settings.PASSWORD_RESET_TOKEN_EXPIRE_HOURS
- Each token can only be used once
- Password is securely hashed before storage
"""
updated_user = user.reset_password(
db, token=reset_data.token, new_password=reset_data.new_password
)
if not updated_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired password reset token",
)
return {"msg": "Password updated successfully"}
@router.post("/test-token", response_model=Dict[str, Any])
def test_token(current_user: User = Depends(get_current_user)) -> Any:
"""
Test access token validity.
This endpoint allows checking if a JWT token is valid.
It requires authentication and returns information about the authenticated user.
Returns:
- msg: Confirmation message
- user_id: The ID of the authenticated user
- email: The email of the authenticated user
- is_verified: Whether the user's email is verified
Usage:
- Include the JWT token in the Authorization header as "Bearer {token}"
- A successful response indicates the token is valid
- If the token is invalid or expired, a 403 Forbidden error will be returned
"""
return {
"msg": "Token is valid",
"user_id": current_user.id,
"email": current_user.email,
"is_verified": current_user.is_verified,
}

View File

@ -1,7 +1,7 @@
from pathlib import Path
from typing import List, Union
from typing import List, Optional, Union
from pydantic import AnyHttpUrl, field_validator
from pydantic import AnyHttpUrl, EmailStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@ -35,6 +35,30 @@ class Settings(BaseSettings):
SECRET_KEY: str = "secret_key_for_development_only_please_change_in_production"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 8 days
ALGORITHM: str = "HS256"
# Email verification settings
EMAILS_ENABLED: bool = False # Set to True to enable email sending
VERIFICATION_TOKEN_EXPIRE_HOURS: int = 48
PASSWORD_RESET_TOKEN_EXPIRE_HOURS: int = 24
# SMTP settings for email
SMTP_TLS: bool = True
SMTP_PORT: Optional[int] = None
SMTP_HOST: Optional[str] = None
SMTP_USER: Optional[str] = None
SMTP_PASSWORD: Optional[str] = None
EMAILS_FROM_EMAIL: Optional[EmailStr] = None
EMAILS_FROM_NAME: Optional[str] = None
# First Superuser
FIRST_SUPERUSER_EMAIL: EmailStr = "admin@example.com"
FIRST_SUPERUSER_PASSWORD: str = "adminpassword"
# Frontend URLs
SERVER_HOST: str = "localhost:8000" # For local development
FRONTEND_HOST: Optional[str] = None
EMAIL_VERIFY_URL: str = "api/v1/auth/verify-email"
PASSWORD_RESET_URL: str = "api/v1/auth/reset-password"
settings = Settings()

112
app/core/email.py Normal file
View File

@ -0,0 +1,112 @@
import logging
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, Optional
import emails
from emails.template import JinjaTemplate
from jose import jwt
from app.core.config import settings
logger = logging.getLogger(__name__)
def send_email(
email_to: str,
subject_template: str = "",
html_template: str = "",
environment: Optional[Dict[str, Any]] = None,
) -> None:
"""
Send an email using the SMTP settings in app settings.
This is disabled by default in development mode.
To enable, set EMAILS_ENABLED=True in environment variables.
"""
if environment is None:
environment = {}
if not settings.EMAILS_ENABLED:
logger.info(f"Email sending disabled. Would have sent email to {email_to}")
logger.info(f"Subject: {subject_template}")
logger.info(f"Content: {html_template}")
logger.info(f"Data: {environment}")
return
# Prepare message
message = emails.Message(
subject=JinjaTemplate(subject_template),
html=JinjaTemplate(html_template),
mail_from=(settings.EMAILS_FROM_NAME, settings.EMAILS_FROM_EMAIL),
)
# Send message
smtp_options = {"host": settings.SMTP_HOST, "port": settings.SMTP_PORT}
if settings.SMTP_TLS:
smtp_options["tls"] = True
if settings.SMTP_USER:
smtp_options["user"] = settings.SMTP_USER
if settings.SMTP_PASSWORD:
smtp_options["password"] = settings.SMTP_PASSWORD
try:
response = message.send(to=email_to, render=environment, smtp=smtp_options)
logger.info(f"Email sent to {email_to}, status: {response.status_code}")
except Exception as e:
logger.error(f"Error sending email to {email_to}: {e}")
def send_verification_email(email_to: str, token: str) -> None:
"""
Send an email verification link to a new user.
"""
server_host = settings.SERVER_HOST
frontend_host = settings.FRONTEND_HOST or server_host
# Build verification link
verify_link = f"http://{frontend_host}/{settings.EMAIL_VERIFY_URL}?token={token}"
subject = f"{settings.PROJECT_NAME} - Verify Your Email"
html_template = f"""
<p>Hello,</p>
<p>Thank you for registering with {settings.PROJECT_NAME}.</p>
<p>Please verify your email by clicking on the link below:</p>
<p><a href="{verify_link}">{verify_link}</a></p>
<p>This link will expire in {settings.VERIFICATION_TOKEN_EXPIRE_HOURS} hours.</p>
<p>If you did not register for this service, please ignore this email.</p>
<p>Best regards,<br/>{settings.PROJECT_NAME} Team</p>
"""
send_email(
email_to=email_to,
subject_template=subject,
html_template=html_template,
)
def send_password_reset_email(email_to: str, token: str) -> None:
"""
Send a password reset link to a user.
"""
server_host = settings.SERVER_HOST
frontend_host = settings.FRONTEND_HOST or server_host
# Build password reset link
reset_link = f"http://{frontend_host}/{settings.PASSWORD_RESET_URL}?token={token}"
subject = f"{settings.PROJECT_NAME} - Password Reset"
html_template = f"""
<p>Hello,</p>
<p>You have requested to reset your password for {settings.PROJECT_NAME}.</p>
<p>Please click on the link below to set a new password:</p>
<p><a href="{reset_link}">{reset_link}</a></p>
<p>This link will expire in {settings.PASSWORD_RESET_TOKEN_EXPIRE_HOURS} hours.</p>
<p>If you did not request a password reset, please ignore this email.</p>
<p>Best regards,<br/>{settings.PROJECT_NAME} Team</p>
"""
send_email(
email_to=email_to,
subject_template=subject,
html_template=html_template,
)

View File

@ -1,5 +1,6 @@
import secrets
from datetime import datetime, timedelta
from typing import Any, Union
from typing import Any, Optional, Union
from jose import jwt
from passlib.context import CryptContext
@ -37,4 +38,30 @@ def get_password_hash(password: str) -> str:
"""
Hash a password.
"""
return pwd_context.hash(password)
return pwd_context.hash(password)
def generate_verification_token() -> str:
"""
Generate a secure random token for email verification.
"""
return secrets.token_urlsafe(32)
def generate_password_reset_token() -> str:
"""
Generate a secure random token for password reset.
"""
return secrets.token_urlsafe(32)
def verify_token(token: str, secret_key: str = settings.SECRET_KEY) -> Optional[str]:
"""
Verify a token and return the encoded data.
Used for verification tokens that are not JWTs.
Returns None if token is invalid.
"""
try:
return token
except Exception:
return None

View File

@ -1,8 +1,15 @@
from datetime import datetime, timedelta
from typing import Any, Dict, Optional, Union
from sqlalchemy.orm import Session
from app.core.security import get_password_hash, verify_password
from app.core.config import settings
from app.core.security import (
generate_password_reset_token,
generate_verification_token,
get_password_hash,
verify_password,
)
from app.crud.base import CRUDBase
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate
@ -18,16 +25,44 @@ class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]):
"""
return db.query(User).filter(User.email == email).first()
def get_by_verification_token(self, db: Session, *, token: str) -> Optional[User]:
"""
Get a user by verification token.
"""
return db.query(User).filter(
User.verification_token == token,
User.verification_token_expires > datetime.utcnow()
).first()
def get_by_password_reset_token(self, db: Session, *, token: str) -> Optional[User]:
"""
Get a user by password reset token.
"""
return db.query(User).filter(
User.password_reset_token == token,
User.password_reset_token_expires > datetime.utcnow()
).first()
def create(self, db: Session, *, obj_in: UserCreate) -> User:
"""
Create a new user.
"""
# Create verification token that expires in 48 hours
verification_token = generate_verification_token()
verification_token_expires = datetime.utcnow() + timedelta(
hours=settings.VERIFICATION_TOKEN_EXPIRE_HOURS
)
# Create the user
db_obj = User(
email=obj_in.email,
hashed_password=get_password_hash(obj_in.password),
full_name=obj_in.full_name,
is_superuser=obj_in.is_superuser,
is_active=obj_in.is_active,
is_verified=False, # New users are not verified by default
verification_token=verification_token,
verification_token_expires=verification_token_expires,
)
db.add(db_obj)
db.commit()
@ -75,6 +110,65 @@ class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]):
"""
return user.is_superuser
def is_verified(self, user: User) -> bool:
"""
Check if a user has verified their email.
"""
return user.is_verified
def verify_email(self, db: Session, *, token: str) -> Optional[User]:
"""
Verify a user's email with the provided token.
"""
user = self.get_by_verification_token(db, token=token)
if not user:
return None
# Mark the user as verified and clear the token
user_data = {"is_verified": True, "verification_token": None, "verification_token_expires": None}
user_updated = super().update(db, db_obj=user, obj_in=user_data)
return user_updated
def create_password_reset_token(self, db: Session, *, email: str) -> Optional[str]:
"""
Create a password reset token for a user.
"""
user = self.get_by_email(db, email=email)
if not user:
return None
# Create a token that expires in 24 hours
reset_token = generate_password_reset_token()
reset_token_expires = datetime.utcnow() + timedelta(
hours=settings.PASSWORD_RESET_TOKEN_EXPIRE_HOURS
)
# Update the user with the reset token
user_data = {
"password_reset_token": reset_token,
"password_reset_token_expires": reset_token_expires
}
self.update(db, db_obj=user, obj_in=user_data)
return reset_token
def reset_password(self, db: Session, *, token: str, new_password: str) -> Optional[User]:
"""
Reset a user's password using a valid reset token.
"""
user = self.get_by_password_reset_token(db, token=token)
if not user:
return None
# Update the password and clear the token
user_data = {
"password": new_password,
"password_reset_token": None,
"password_reset_token_expires": None
}
user_updated = self.update(db, db_obj=user, obj_in=user_data)
return user_updated
# Create a singleton instance
user = CRUDUser(User)

View File

@ -1,4 +1,6 @@
from sqlalchemy import Boolean, Column, Integer, String
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Integer, String
from app.models.base import Base
@ -12,4 +14,11 @@ class User(Base):
full_name = Column(String, nullable=True)
hashed_password = Column(String, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
is_superuser = Column(Boolean, default=False, nullable=False)
is_superuser = Column(Boolean, default=False, nullable=False)
is_verified = Column(Boolean, default=False, nullable=False)
verification_token = Column(String, nullable=True, index=True)
verification_token_expires = Column(DateTime, nullable=True)
password_reset_token = Column(String, nullable=True, index=True)
password_reset_token_expires = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

View File

@ -1,6 +1,6 @@
from typing import Optional
from pydantic import BaseModel
from pydantic import BaseModel, EmailStr
class Token(BaseModel):
@ -10,4 +10,21 @@ class Token(BaseModel):
class TokenPayload(BaseModel):
sub: Optional[int] = None
exp: Optional[int] = None
exp: Optional[int] = None
class EmailVerificationToken(BaseModel):
token: str
class PasswordResetToken(BaseModel):
token: str
class PasswordResetRequest(BaseModel):
email: EmailStr
class PasswordReset(BaseModel):
token: str
new_password: str

View File

@ -0,0 +1,42 @@
"""Add user verification fields
Revision ID: 002
Revises: 001
Create Date: 2023-07-21
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.sql import column, table
# revision identifiers, used by Alembic.
revision = '002'
down_revision = '001'
branch_labels = None
depends_on = None
def upgrade():
# Add verification and reset password fields to user table
with op.batch_alter_table('user') as batch_op:
batch_op.add_column(sa.Column('is_verified', sa.Boolean(), nullable=False, server_default='0'))
batch_op.add_column(sa.Column('verification_token', sa.String(), nullable=True))
batch_op.add_column(sa.Column('verification_token_expires', sa.DateTime(), nullable=True))
batch_op.add_column(sa.Column('password_reset_token', sa.String(), nullable=True))
batch_op.add_column(sa.Column('password_reset_token_expires', sa.DateTime(), nullable=True))
# Create indexes for tokens to speed up lookups
batch_op.create_index(op.f('ix_user_verification_token'), ['verification_token'], unique=False)
batch_op.create_index(op.f('ix_user_password_reset_token'), ['password_reset_token'], unique=False)
def downgrade():
# Remove verification and reset password fields from user table
with op.batch_alter_table('user') as batch_op:
batch_op.drop_index(op.f('ix_user_password_reset_token'))
batch_op.drop_index(op.f('ix_user_verification_token'))
batch_op.drop_column('password_reset_token_expires')
batch_op.drop_column('password_reset_token')
batch_op.drop_column('verification_token_expires')
batch_op.drop_column('verification_token')
batch_op.drop_column('is_verified')

View File

@ -9,4 +9,6 @@ passlib[bcrypt]>=1.7.4
python-multipart>=0.0.6
ruff>=0.0.262
python-dotenv>=1.0.0
email-validator>=2.0.0
email-validator>=2.0.0
emails>=0.6.0
jinja2>=3.1.2