Implement User Authentication and Authorization Service

This commit includes:
- User registration and authentication API with JWT
- Password reset functionality
- Role-based access control system
- Database models and migrations with SQLAlchemy and Alembic
- API documentation in README

generated with BackendIM... (backend.im)
This commit is contained in:
Automated Action 2025-05-15 19:46:38 +00:00
parent 8cf4ea4447
commit 5b55eedd2b
40 changed files with 1902 additions and 2 deletions

27
.env.example Normal file
View File

@ -0,0 +1,27 @@
# API settings
API_V1_STR=/api/v1
PROJECT_NAME="User Authentication and Authorization Service"
# JWT settings
SECRET_KEY=YourSecretKeyHere
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_MINUTES=10080
# CORS settings
BACKEND_CORS_ORIGINS=["*"]
# Database settings
# This is automatically configured in the app
# Password reset settings
PASSWORD_RESET_TOKEN_EXPIRE_HOURS=24
# Email settings (if you plan to implement email sending)
SMTP_TLS=True
SMTP_PORT=587
SMTP_HOST=smtp.example.com
SMTP_USER=your_username
SMTP_PASSWORD=your_password
EMAILS_FROM_EMAIL=noreply@example.com
EMAILS_FROM_NAME="User Auth Service"

211
README.md
View File

@ -1,3 +1,210 @@
# FastAPI Application # User Authentication and Authorization Service
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. A FastAPI service providing user authentication and authorization features including:
- User registration and management
- JWT authentication
- Password reset functionality
- Role-based access control
## Features
- **User Management:**
- User registration
- User profile management
- Email verification (implementation ready)
- **Authentication:**
- JWT-based authentication with access and refresh tokens
- Secure password hashing with bcrypt
- Password reset functionality
- **Authorization:**
- Role-based access control
- Admin functionality for user management
- Fine-grained permission control
## Installation
1. Clone the repository
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Set up environment variables (or create a `.env` file)
4. Run database migrations:
```bash
alembic upgrade head
```
5. Start the server:
```bash
uvicorn main:app --reload
```
## API Documentation
### Authentication Endpoints
#### Register a new user
```
POST /users
```
Request body:
```json
{
"email": "user@example.com",
"password": "StrongPassword123",
"first_name": "John",
"last_name": "Doe"
}
```
#### Login
```
POST /auth/login
```
Request body:
```json
{
"email": "user@example.com",
"password": "StrongPassword123"
}
```
Response:
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer"
}
```
#### Refresh token
```
POST /auth/refresh-token
```
Request body:
```json
{
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```
#### Request password reset
```
POST /auth/password-reset
```
Request body:
```json
{
"email": "user@example.com"
}
```
#### Reset password
```
POST /auth/password-reset/confirm
```
Request body:
```json
{
"token": "reset_token_here",
"password": "NewStrongPassword123",
"password_confirm": "NewStrongPassword123"
}
```
#### Change password
```
POST /auth/password-change
```
Request body:
```json
{
"current_password": "StrongPassword123",
"new_password": "NewStrongPassword123"
}
```
### User Management Endpoints
#### Get current user
```
GET /users/me
```
#### Update current user
```
PUT /users/me
```
Request body:
```json
{
"first_name": "John",
"last_name": "Smith"
}
```
#### Get all users (admin only)
```
GET /users
```
#### Get user by ID (admin only)
```
GET /users/{user_id}
```
#### Update user (admin only)
```
PUT /users/{user_id}
```
#### Delete user (admin only)
```
DELETE /users/{user_id}
```
#### Verify user (admin only)
```
POST /users/{user_id}/verify
```
### Role Management Endpoints
#### Get user roles (admin only)
```
GET /users/{user_id}/roles
```
#### Add role to user (admin only)
```
POST /users/{user_id}/roles/{role_id}
```
#### Remove role from user (admin only)
```
DELETE /users/{user_id}/roles/{role_id}
```
## Security Considerations
- Passwords are hashed using bcrypt
- JWT tokens with appropriate expiration times
- Role-based authorization for sensitive operations
- Input validation using Pydantic models
## Health Check
The service provides a health check endpoint:
```
GET /health
```
## Development
The project follows standard FastAPI practices and uses:
- SQLAlchemy ORM for database operations
- Alembic for database migrations
- Pydantic for data validation
- JWT for authentication tokens

73
alembic.ini Normal file
View File

@ -0,0 +1,73 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# timezone to use when rendering the date
# within the migration file as well as the filename.
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; this defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path
# version_locations = %(here)s/bar %(here)s/bat alembic/versions
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = sqlite:////app/storage/db/db.sqlite
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

81
alembic/env.py Normal file
View File

@ -0,0 +1,81 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from app.core.config import settings
from app.db.base import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
alembic/script.py.mako Normal file
View File

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,101 @@
"""Initial migration
Revision ID: e2eb9a74d893
Revises:
Create Date: 2025-05-15
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'e2eb9a74d893'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create user table
op.create_table(
'user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('hashed_password', sa.String(), nullable=False),
sa.Column('first_name', sa.String(), nullable=True),
sa.Column('last_name', sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), default=True),
sa.Column('is_verified', sa.Boolean(), default=False),
sa.Column('created_at', sa.DateTime(), default=sa.func.current_timestamp()),
sa.Column('updated_at', sa.DateTime(), default=sa.func.current_timestamp(), onupdate=sa.func.current_timestamp()),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True)
op.create_index(op.f('ix_user_id'), 'user', ['id'], unique=False)
# Create role table
op.create_table(
'role',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), default=sa.func.current_timestamp()),
sa.Column('updated_at', sa.DateTime(), default=sa.func.current_timestamp(), onupdate=sa.func.current_timestamp()),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_role_id'), 'role', ['id'], unique=False)
op.create_index(op.f('ix_role_name'), 'role', ['name'], unique=True)
# Create user_role table
op.create_table(
'userrole',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('role_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), default=sa.func.current_timestamp()),
sa.ForeignKeyConstraint(['role_id'], ['role.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
)
# Create password_reset table
op.create_table(
'passwordreset',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('token', sa.String(), nullable=False),
sa.Column('is_used', sa.Integer(), default=0, nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('created_at', sa.DateTime(), default=sa.func.current_timestamp()),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_passwordreset_id'), 'passwordreset', ['id'], unique=False)
op.create_index(op.f('ix_passwordreset_token'), 'passwordreset', ['token'], unique=True)
# Insert default roles
op.bulk_insert(
sa.table(
'role',
sa.Column('name', sa.String()),
sa.Column('description', sa.String())
),
[
{'name': 'admin', 'description': 'Administrator with full access'},
{'name': 'user', 'description': 'Regular user with limited access'},
]
)
def downgrade() -> None:
op.drop_index(op.f('ix_passwordreset_token'), table_name='passwordreset')
op.drop_index(op.f('ix_passwordreset_id'), table_name='passwordreset')
op.drop_table('passwordreset')
op.drop_table('userrole')
op.drop_index(op.f('ix_role_name'), table_name='role')
op.drop_index(op.f('ix_role_id'), table_name='role')
op.drop_table('role')
op.drop_index(op.f('ix_user_id'), table_name='user')
op.drop_index(op.f('ix_user_email'), table_name='user')
op.drop_table('user')

0
app/__init__.py Normal file
View File

0
app/core/__init__.py Normal file
View File

45
app/core/config.py Normal file
View File

@ -0,0 +1,45 @@
import os
from pathlib import Path
from typing import List, Optional
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# API settings
API_V1_STR: str = "/api/v1"
PROJECT_NAME: str = "User Authentication and Authorization Service"
# JWT settings
SECRET_KEY: str = os.getenv("SECRET_KEY", "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7")
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_MINUTES: int = 10080 # 7 days
# CORS settings
BACKEND_CORS_ORIGINS: List[str] = ["*"]
# Database settings
DB_DIR: Path = Path("/app") / "storage" / "db"
SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_DIR}/db.sqlite"
# Password reset settings
PASSWORD_RESET_TOKEN_EXPIRE_HOURS: int = 24
# Email settings (if you plan to implement email sending)
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[str] = None
EMAILS_FROM_NAME: Optional[str] = None
class Config:
env_file = ".env"
case_sensitive = True
# Create the settings object
settings = Settings()
# Ensure the database directory exists
settings.DB_DIR.mkdir(parents=True, exist_ok=True)

0
app/db/__init__.py Normal file
View File

7
app/db/base.py Normal file
View File

@ -0,0 +1,7 @@
# Import all the models, so that Base has them before being
# imported by Alembic
from app.db.base_class import Base # noqa
from app.models.user import User # noqa
from app.models.role import Role # noqa
from app.models.user_role import UserRole # noqa
from app.models.password_reset import PasswordReset # noqa

14
app/db/base_class.py Normal file
View File

@ -0,0 +1,14 @@
from typing import Any
from sqlalchemy.ext.declarative import as_declarative, declared_attr
@as_declarative()
class Base:
id: Any
__name__: str
# Generate __tablename__ automatically based on class name
@declared_attr
def __tablename__(cls) -> str:
return cls.__name__.lower()

21
app/db/session.py Normal file
View File

@ -0,0 +1,21 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
# Create database engine
engine = create_engine(
settings.SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False} # Needed for SQLite
)
# Create session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Dependency to get DB session
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

89
app/dependencies/auth.py Normal file
View File

@ -0,0 +1,89 @@
from datetime import datetime
from typing import Generator, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from pydantic import ValidationError
from sqlalchemy.orm import Session
from app.core.config import settings
from app.db.session import get_db
from app.models.user import User
from app.schemas.token import TokenPayload
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl=f"/auth/login"
)
async def get_current_user(
db: Session = Depends(get_db),
token: str = Depends(oauth2_scheme)
) -> User:
"""
Decode JWT token to get user.
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM]
)
token_data = TokenPayload(**payload)
# Check if token is expired
if token_data.exp < datetime.utcnow().timestamp():
raise credentials_exception
# Check if it's an access token
if hasattr(token_data, "type") and token_data.type != "refresh":
pass # allow access tokens (no type) and non-refresh tokens
else:
raise credentials_exception
except (JWTError, ValidationError):
raise credentials_exception
user = db.query(User).filter(User.id == token_data.sub).first()
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:
"""
Check if the user is active.
"""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user"
)
return current_user
def get_current_active_verified_user(
current_user: User = Depends(get_current_active_user),
) -> User:
"""
Check if the user is verified.
"""
if not current_user.is_verified:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Email not verified"
)
return current_user

37
app/dependencies/role.py Normal file
View File

@ -0,0 +1,37 @@
from typing import List
from fastapi import Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.dependencies.auth import get_current_active_user
from app.models.user import User
def check_user_role(required_roles: List[str]):
"""
Dependency to check if a user has the required role(s).
"""
async def _check_user_role(
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
) -> User:
# If no roles required, return user
if not required_roles:
return current_user
# Get all user roles
user_roles = [role.name for role in current_user.roles]
# Check if user has any of the required roles
for role in required_roles:
if role in user_roles:
return current_user
# If user doesn't have any required role, raise exception
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have the required permissions to access this resource"
)
return _check_user_role

0
app/models/__init__.py Normal file
View File

View File

@ -0,0 +1,23 @@
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from app.db.base_class import Base
class PasswordReset(Base):
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id", ondelete="CASCADE"), nullable=False)
token = Column(String, unique=True, index=True, nullable=False)
is_used = Column(Integer, default=0, nullable=False) # 0 = not used, 1 = used
expires_at = Column(DateTime, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
user = relationship("User", back_populates="password_resets")
def is_expired(self) -> bool:
return datetime.utcnow() > self.expires_at
def __repr__(self) -> str:
return f"<PasswordReset user_id={self.user_id}>"

19
app/models/role.py Normal file
View File

@ -0,0 +1,19 @@
from datetime import datetime
from sqlalchemy import Column, DateTime, Integer, String
from sqlalchemy.orm import relationship
from app.db.base_class import Base
class Role(Base):
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, index=True, nullable=False)
description = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
users = relationship("User", secondary="userrole", back_populates="roles")
def __repr__(self) -> str:
return f"<Role {self.name}>"

31
app/models/user.py Normal file
View File

@ -0,0 +1,31 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Integer, String
from sqlalchemy.orm import relationship
from app.db.base_class import Base
from app.utils.security import get_password_hash, verify_password
class User(Base):
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
first_name = Column(String, nullable=True)
last_name = Column(String, nullable=True)
is_active = Column(Boolean, default=True)
is_verified = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
roles = relationship("Role", secondary="userrole", back_populates="users")
password_resets = relationship("PasswordReset", back_populates="user")
def set_password(self, password: str) -> None:
self.hashed_password = get_password_hash(password)
def verify_password(self, password: str) -> bool:
return verify_password(password, self.hashed_password)
def __repr__(self) -> str:
return f"<User {self.email}>"

14
app/models/user_role.py Normal file
View File

@ -0,0 +1,14 @@
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, Table
from app.db.base_class import Base
# Association table for the many-to-many relationship between users and roles
class UserRole(Base):
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("user.id", ondelete="CASCADE"), nullable=False)
role_id = Column(Integer, ForeignKey("role.id", ondelete="CASCADE"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
def __repr__(self) -> str:
return f"<UserRole user_id={self.user_id} role_id={self.role_id}>"

0
app/routers/__init__.py Normal file
View File

180
app/routers/auth.py Normal file
View File

@ -0,0 +1,180 @@
from fastapi import APIRouter, Body, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from app.core.config import settings
from app.db.session import get_db
from app.dependencies.auth import get_current_active_user
from app.models.user import User
from app.schemas.auth import ChangePassword, Login
from app.schemas.password import PasswordReset, PasswordResetConfirm
from app.schemas.token import Token, TokenPayload, TokenRefresh
from app.services.auth import (
authenticate_user,
create_tokens_for_user,
generate_password_reset_token,
reset_password,
verify_password_reset_token
)
from app.utils.security import verify_password
router = APIRouter()
@router.post("/login", response_model=Token)
def login(
db: Session = Depends(get_db),
form_data: OAuth2PasswordRequestForm = Depends()
):
"""
OAuth2 compatible token login, get an access token for future requests.
"""
user = authenticate_user(db, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"}
)
tokens = create_tokens_for_user(user.id)
return tokens
@router.post("/login/access-token", response_model=Token)
def login_access_token(
login_data: Login,
db: Session = Depends(get_db)
):
"""
Get an access token for future requests.
"""
user = authenticate_user(db, login_data.email, login_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"}
)
tokens = create_tokens_for_user(user.id)
return tokens
@router.post("/refresh-token", response_model=Token)
def refresh_token(
refresh_token_data: TokenRefresh,
db: Session = Depends(get_db)
):
"""
Get a new access token using refresh token.
"""
try:
payload = jwt.decode(
refresh_token_data.refresh_token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM]
)
token_data = TokenPayload(**payload)
# Check if it's a refresh token
if not hasattr(token_data, "type") or token_data.type != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type",
headers={"WWW-Authenticate": "Bearer"}
)
except (JWTError, ValueError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"}
)
user = db.query(User).filter(User.id == token_data.sub).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"}
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user"
)
tokens = create_tokens_for_user(user.id)
return tokens
@router.post("/password-reset", status_code=status.HTTP_200_OK)
def request_password_reset(
password_reset: PasswordReset,
db: Session = Depends(get_db)
):
"""
Request a password reset token.
"""
token = generate_password_reset_token(db, email=password_reset.email)
# In a real application, you would send an email with the token
# For this example, we'll just return a success message
return {
"message": "Password reset link has been sent to your email"
}
@router.post("/password-reset/confirm", status_code=status.HTTP_200_OK)
def confirm_password_reset(
password_reset_confirm: PasswordResetConfirm,
db: Session = Depends(get_db)
):
"""
Reset password using a token.
"""
user = reset_password(
db,
token=password_reset_confirm.token,
new_password=password_reset_confirm.password
)
if not user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired token"
)
return {
"message": "Password has been reset successfully"
}
@router.post("/password-change", status_code=status.HTTP_200_OK)
def change_password(
password_change: ChangePassword,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Change password.
"""
# Verify current password
if not verify_password(password_change.current_password, current_user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Incorrect password"
)
# Update password
current_user.hashed_password = get_password_hash(password_change.new_password)
db.add(current_user)
db.commit()
return {
"message": "Password has been changed successfully"
}

25
app/routers/health.py Normal file
View File

@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from app.db.session import get_db
router = APIRouter()
@router.get("/health", status_code=status.HTTP_200_OK)
def health_check(db: Session = Depends(get_db)):
"""
Health check endpoint.
"""
try:
# Check database connection
db.execute("SELECT 1")
return {
"status": "ok",
"message": "Service is running"
}
except Exception as e:
return {
"status": "error",
"message": f"Service is experiencing issues: {str(e)}"
}

225
app/routers/users.py Normal file
View File

@ -0,0 +1,225 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.dependencies.auth import get_current_active_user
from app.dependencies.role import check_user_role
from app.models.user import User
from app.schemas.role import Role
from app.schemas.user import User as UserSchema
from app.schemas.user import UserCreate, UserUpdate
from app.services.role import assign_role_to_user, remove_role_from_user
from app.services.user import (
create_user,
delete_user,
get_user_by_email,
get_user_by_id,
get_users,
update_user,
verify_user
)
router = APIRouter()
@router.post("", response_model=UserSchema, status_code=status.HTTP_201_CREATED)
def register_user(
user_in: UserCreate,
db: Session = Depends(get_db)
):
"""
Register a new user.
"""
# Check if user already exists
user = get_user_by_email(db, email=user_in.email)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User with this email already exists"
)
# Create user
user = create_user(db, user_in=user_in)
return user
@router.get("", response_model=List[UserSchema])
def read_users(
skip: int = 0,
limit: int = 100,
current_user: User = Depends(check_user_role(["admin"])),
db: Session = Depends(get_db)
):
"""
Retrieve users. Only for admins.
"""
users = get_users(db, skip=skip, limit=limit)
return users
@router.get("/me", response_model=UserSchema)
def read_user_me(
current_user: User = Depends(get_current_active_user)
):
"""
Get current user.
"""
return current_user
@router.put("/me", response_model=UserSchema)
def update_user_me(
user_in: UserUpdate,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Update current user.
"""
user = update_user(db, user_id=current_user.id, user_in=user_in)
return user
@router.get("/{user_id}", response_model=UserSchema)
def read_user(
user_id: int,
current_user: User = Depends(check_user_role(["admin"])),
db: Session = Depends(get_db)
):
"""
Get a specific user by ID. Only for admins.
"""
user = get_user_by_id(db, user_id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return user
@router.put("/{user_id}", response_model=UserSchema)
def update_user_by_id(
user_id: int,
user_in: UserUpdate,
current_user: User = Depends(check_user_role(["admin"])),
db: Session = Depends(get_db)
):
"""
Update a user. Only for admins.
"""
user = get_user_by_id(db, user_id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
user = update_user(db, user_id=user_id, user_in=user_in)
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user_by_id(
user_id: int,
current_user: User = Depends(check_user_role(["admin"])),
db: Session = Depends(get_db)
):
"""
Delete a user. Only for admins.
"""
user = get_user_by_id(db, user_id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
result = delete_user(db, user_id=user_id)
if not result:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete user"
)
return None
@router.post("/{user_id}/verify", response_model=UserSchema)
def verify_user_by_id(
user_id: int,
current_user: User = Depends(check_user_role(["admin"])),
db: Session = Depends(get_db)
):
"""
Verify a user. Only for admins.
"""
user = verify_user(db, user_id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return user
@router.get("/{user_id}/roles", response_model=List[Role])
def read_user_roles(
user_id: int,
current_user: User = Depends(check_user_role(["admin"])),
db: Session = Depends(get_db)
):
"""
Get a user's roles. Only for admins.
"""
user = get_user_by_id(db, user_id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return user.roles
@router.post("/{user_id}/roles/{role_id}", status_code=status.HTTP_200_OK)
def add_role_to_user(
user_id: int,
role_id: int,
current_user: User = Depends(check_user_role(["admin"])),
db: Session = Depends(get_db)
):
"""
Add a role to a user. Only for admins.
"""
user_role = assign_role_to_user(db, user_id=user_id, role_id=role_id)
if not user_role:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to assign role to user"
)
return {"message": "Role assigned to user successfully"}
@router.delete("/{user_id}/roles/{role_id}", status_code=status.HTTP_200_OK)
def remove_role_from_user_endpoint(
user_id: int,
role_id: int,
current_user: User = Depends(check_user_role(["admin"])),
db: Session = Depends(get_db)
):
"""
Remove a role from a user. Only for admins.
"""
result = remove_role_from_user(db, user_id=user_id, role_id=role_id)
if not result:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to remove role from user"
)
return {"message": "Role removed from user successfully"}

0
app/schemas/__init__.py Normal file
View File

11
app/schemas/auth.py Normal file
View File

@ -0,0 +1,11 @@
from pydantic import BaseModel, EmailStr
class Login(BaseModel):
email: EmailStr
password: str
class ChangePassword(BaseModel):
current_password: str
new_password: str

31
app/schemas/password.py Normal file
View File

@ -0,0 +1,31 @@
from pydantic import BaseModel, EmailStr, Field, validator
class PasswordReset(BaseModel):
email: EmailStr
class PasswordResetConfirm(BaseModel):
token: str
password: str = Field(..., min_length=8)
password_confirm: str
@validator("password")
def password_strength(cls, v):
"""Validate password strength."""
if len(v) < 8:
raise ValueError("Password must be at least 8 characters long")
if not any(char.isdigit() for char in v):
raise ValueError("Password must contain at least one digit")
if not any(char.isupper() for char in v):
raise ValueError("Password must contain at least one uppercase letter")
if not any(char.islower() for char in v):
raise ValueError("Password must contain at least one lowercase letter")
return v
@validator("password_confirm")
def passwords_match(cls, v, values, **kwargs):
"""Check that passwords match."""
if "password" in values and v != values["password"]:
raise ValueError("Passwords do not match")
return v

41
app/schemas/role.py Normal file
View File

@ -0,0 +1,41 @@
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel
# Shared properties
class RoleBase(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
# Properties to receive via API on creation
class RoleCreate(RoleBase):
name: str
# Properties to receive via API on update
class RoleUpdate(RoleBase):
pass
# Properties shared by models stored in DB
class RoleInDBBase(RoleBase):
id: int
name: str
created_at: datetime
updated_at: datetime
class Config:
orm_mode = True
# Properties to return via API
class Role(RoleInDBBase):
pass
# Properties stored in DB
class RoleInDB(RoleInDBBase):
pass

19
app/schemas/token.py Normal file
View File

@ -0,0 +1,19 @@
from typing import List, Optional
from pydantic import BaseModel
class Token(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class TokenPayload(BaseModel):
sub: Optional[str] = None
exp: Optional[int] = None
type: Optional[str] = None
class TokenRefresh(BaseModel):
refresh_token: str

76
app/schemas/user.py Normal file
View File

@ -0,0 +1,76 @@
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, EmailStr, Field, validator
# Shared properties
class UserBase(BaseModel):
email: Optional[EmailStr] = None
is_active: Optional[bool] = True
first_name: Optional[str] = None
last_name: Optional[str] = None
# Properties to receive via API on creation
class UserCreate(BaseModel):
email: EmailStr
password: str = Field(..., min_length=8)
first_name: Optional[str] = None
last_name: Optional[str] = None
@validator("password")
def password_strength(cls, v):
"""Validate password strength."""
if len(v) < 8:
raise ValueError("Password must be at least 8 characters long")
if not any(char.isdigit() for char in v):
raise ValueError("Password must contain at least one digit")
if not any(char.isupper() for char in v):
raise ValueError("Password must contain at least one uppercase letter")
if not any(char.islower() for char in v):
raise ValueError("Password must contain at least one lowercase letter")
return v
# Properties to receive via API on update
class UserUpdate(UserBase):
password: Optional[str] = None
@validator("password")
def password_strength(cls, v):
"""Validate password strength if provided."""
if v is None:
return v
if len(v) < 8:
raise ValueError("Password must be at least 8 characters long")
if not any(char.isdigit() for char in v):
raise ValueError("Password must contain at least one digit")
if not any(char.isupper() for char in v):
raise ValueError("Password must contain at least one uppercase letter")
if not any(char.islower() for char in v):
raise ValueError("Password must contain at least one lowercase letter")
return v
# Properties shared by models stored in DB
class UserInDBBase(UserBase):
id: int
email: EmailStr
is_active: bool
is_verified: bool
created_at: datetime
updated_at: datetime
class Config:
orm_mode = True
# Properties to return via API
class User(UserInDBBase):
pass
# Properties stored in DB
class UserInDB(UserInDBBase):
hashed_password: str

0
app/services/__init__.py Normal file
View File

116
app/services/auth.py Normal file
View File

@ -0,0 +1,116 @@
from datetime import datetime, timedelta
import secrets
from typing import Optional
from sqlalchemy.orm import Session
from app.core.config import settings
from app.models.password_reset import PasswordReset
from app.models.user import User
from app.services.user import get_user_by_email, get_user_by_id
from app.utils.security import create_access_token, create_refresh_token, get_password_hash, verify_password
def generate_password_reset_token(db: Session, email: str) -> Optional[str]:
"""Generate a password reset token for a user."""
user = get_user_by_email(db, email=email)
if not user:
return None
# Generate token
token = secrets.token_urlsafe(32)
expires_at = datetime.utcnow() + timedelta(hours=settings.PASSWORD_RESET_TOKEN_EXPIRE_HOURS)
# Create password reset record
password_reset = PasswordReset(
user_id=user.id,
token=token,
expires_at=expires_at,
)
db.add(password_reset)
db.commit()
return token
def verify_password_reset_token(db: Session, token: str) -> Optional[User]:
"""Verify a password reset token."""
password_reset = db.query(PasswordReset).filter(
PasswordReset.token == token,
PasswordReset.is_used == 0
).first()
if not password_reset:
return None
# Check if token is expired
if password_reset.is_expired():
return None
# Get user
user = get_user_by_id(db, user_id=password_reset.user_id)
if not user:
return None
return user
def reset_password(db: Session, token: str, new_password: str) -> Optional[User]:
"""Reset a user's password."""
password_reset = db.query(PasswordReset).filter(
PasswordReset.token == token,
PasswordReset.is_used == 0
).first()
if not password_reset:
return None
# Check if token is expired
if password_reset.is_expired():
return None
# Get user
user = get_user_by_id(db, user_id=password_reset.user_id)
if not user:
return None
# Update password
user.hashed_password = get_password_hash(new_password)
user.updated_at = datetime.utcnow()
# Mark token as used
password_reset.is_used = 1
db.add(user)
db.add(password_reset)
db.commit()
db.refresh(user)
return user
def authenticate_user(db: Session, email: str, password: str) -> Optional[User]:
"""Authenticate a user."""
user = get_user_by_email(db, email=email)
if not user:
return None
if not user.is_active:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def create_tokens_for_user(user_id: int) -> dict:
"""Create JWT tokens for a user."""
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"
}

113
app/services/role.py Normal file
View File

@ -0,0 +1,113 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from app.models.role import Role
from app.models.user import User
from app.models.user_role import UserRole
from app.schemas.role import RoleCreate, RoleUpdate
def get_role_by_id(db: Session, role_id: int) -> Optional[Role]:
"""Get a role by ID."""
return db.query(Role).filter(Role.id == role_id).first()
def get_role_by_name(db: Session, name: str) -> Optional[Role]:
"""Get a role by name."""
return db.query(Role).filter(Role.name == name).first()
def get_roles(db: Session, skip: int = 0, limit: int = 100) -> List[Role]:
"""Get all roles."""
return db.query(Role).offset(skip).limit(limit).all()
def create_role(db: Session, role_in: RoleCreate) -> Role:
"""Create a new role."""
# Check if role already exists
role = get_role_by_name(db, name=role_in.name)
if role:
return None
# Create role object
db_role = Role(
name=role_in.name,
description=role_in.description,
)
db.add(db_role)
db.commit()
db.refresh(db_role)
return db_role
def update_role(db: Session, role_id: int, role_in: RoleUpdate) -> Optional[Role]:
"""Update a role."""
role = get_role_by_id(db, role_id=role_id)
if not role:
return None
# Update role fields
update_data = role_in.dict(exclude_unset=True)
# Update fields
for field, value in update_data.items():
setattr(role, field, value)
db.add(role)
db.commit()
db.refresh(role)
return role
def delete_role(db: Session, role_id: int) -> bool:
"""Delete a role."""
role = get_role_by_id(db, role_id=role_id)
if not role:
return False
db.delete(role)
db.commit()
return True
def assign_role_to_user(db: Session, user_id: int, role_id: int) -> Optional[UserRole]:
"""Assign a role to a user."""
# Check if user and role exist
user = db.query(User).filter(User.id == user_id).first()
role = db.query(Role).filter(Role.id == role_id).first()
if not user or not role:
return None
# Check if user already has the role
user_role = db.query(UserRole).filter(
UserRole.user_id == user_id,
UserRole.role_id == role_id
).first()
if user_role:
return user_role
# Assign role to user
db_user_role = UserRole(user_id=user_id, role_id=role_id)
db.add(db_user_role)
db.commit()
db.refresh(db_user_role)
return db_user_role
def remove_role_from_user(db: Session, user_id: int, role_id: int) -> bool:
"""Remove a role from a user."""
# Check if user has the role
user_role = db.query(UserRole).filter(
UserRole.user_id == user_id,
UserRole.role_id == role_id
).first()
if not user_role:
return False
db.delete(user_role)
db.commit()
return True

121
app/services/user.py Normal file
View File

@ -0,0 +1,121 @@
from datetime import datetime, timedelta
from typing import List, Optional
from sqlalchemy.orm import Session
from app.core.config import settings
from app.models.role import Role
from app.models.user import User
from app.models.user_role import UserRole
from app.schemas.user import UserCreate, UserUpdate
from app.utils.security import get_password_hash, verify_password
def get_user_by_id(db: Session, user_id: int) -> Optional[User]:
"""Get a user by ID."""
return db.query(User).filter(User.id == user_id).first()
def get_user_by_email(db: Session, email: str) -> Optional[User]:
"""Get a user by email."""
return db.query(User).filter(User.email == email).first()
def get_users(db: Session, skip: int = 0, limit: int = 100) -> List[User]:
"""Get all users."""
return db.query(User).offset(skip).limit(limit).all()
def create_user(db: Session, user_in: UserCreate) -> User:
"""Create a new user."""
# Check if user already exists
user = get_user_by_email(db, email=user_in.email)
if user:
return None
# Create user object
db_user = User(
email=user_in.email,
hashed_password=get_password_hash(user_in.password),
first_name=user_in.first_name,
last_name=user_in.last_name,
is_active=True,
is_verified=False, # Default to not verified
)
db.add(db_user)
db.commit()
db.refresh(db_user)
# Add default user role
default_role = db.query(Role).filter(Role.name == "user").first()
if default_role:
db_user_role = UserRole(user_id=db_user.id, role_id=default_role.id)
db.add(db_user_role)
db.commit()
return db_user
def update_user(db: Session, user_id: int, user_in: UserUpdate) -> Optional[User]:
"""Update a user."""
user = get_user_by_id(db, user_id=user_id)
if not user:
return None
# Update user fields
update_data = user_in.dict(exclude_unset=True)
# Handle password update separately
if "password" in update_data:
password = update_data.pop("password")
user.hashed_password = get_password_hash(password)
# Update other fields
for field, value in update_data.items():
setattr(user, field, value)
user.updated_at = datetime.utcnow()
db.add(user)
db.commit()
db.refresh(user)
return user
def delete_user(db: Session, user_id: int) -> bool:
"""Delete a user."""
user = get_user_by_id(db, user_id=user_id)
if not user:
return False
db.delete(user)
db.commit()
return True
def verify_user(db: Session, user_id: int) -> Optional[User]:
"""Mark a user as verified."""
user = get_user_by_id(db, user_id=user_id)
if not user:
return None
user.is_verified = True
user.updated_at = datetime.utcnow()
db.add(user)
db.commit()
db.refresh(user)
return user
def authenticate_user(db: Session, email: str, password: str) -> Optional[User]:
"""Authenticate a user."""
user = get_user_by_email(db, email=email)
if not user:
return None
if not user.is_active:
return None
if not verify_password(password, user.hashed_password):
return None
return user

0
app/utils/__init__.py Normal file
View File

54
app/utils/security.py Normal file
View File

@ -0,0 +1,54 @@
from datetime import datetime, timedelta
from typing import Any, Dict, Optional, Union
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against a hash."""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Generate password hash."""
return pwd_context.hash(password)
# JWT token functions
def create_access_token(subject: Union[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""
Create a JWT access token.
"""
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode = {"exp": expire, "sub": str(subject)}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def create_refresh_token(subject: Union[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""
Create a JWT refresh token.
"""
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
to_encode = {"exp": expire, "sub": str(subject), "type": "refresh"}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def create_password_reset_token(subject: Union[str, Any]) -> str:
"""
Create a password reset token.
"""
expire = datetime.utcnow() + timedelta(hours=settings.PASSWORD_RESET_TOKEN_EXPIRE_HOURS)
to_encode = {"exp": expire, "sub": str(subject), "type": "password_reset"}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt

28
main.py Normal file
View File

@ -0,0 +1,28 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.routers import auth, users, health
app = FastAPI(
title="User Authentication and Authorization Service",
description="API for user registration, login, password reset, and role management with JWT authentication",
version="0.1.0",
)
# CORS Configuration
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Update this with appropriate origins in production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(auth.router, prefix="/auth", tags=["Authentication"])
app.include_router(users.router, prefix="/users", tags=["Users"])
app.include_router(health.router, tags=["Health"])
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

34
pyproject.toml Normal file
View File

@ -0,0 +1,34 @@
[tool.ruff]
# Enable flake8-bugbear (`B`) rules.
select = ["E", "F", "B", "I", "W"]
# Exclude a variety of commonly ignored directories.
exclude = [
".git",
".ruff_cache",
".venv",
".env",
"venv",
"env",
"__pypackages__",
"dist",
"build",
"alembic",
]
# Same as Black.
line-length = 88
# Allow autofix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]
unfixable = []
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
[tool.ruff.mccabe]
# Unlike Flake8, default to a complexity level of 10.
max-complexity = 10
[tool.ruff.isort]
known-third-party = ["fastapi", "pydantic", "sqlalchemy"]

13
requirements.txt Normal file
View File

@ -0,0 +1,13 @@
fastapi>=0.104.0
uvicorn>=0.23.2
sqlalchemy>=2.0.23
pydantic>=2.4.2
pydantic-settings>=2.0.3
python-jose>=3.3.0
passlib>=1.7.4
python-multipart>=0.0.6
email-validator>=2.0.0
alembic>=1.12.1
bcrypt>=4.0.1
python-dotenv>=1.0.0
ruff>=0.1.1