feat: add authentication system

This commit is contained in:
Julian Lobbes 2023-05-31 16:37:56 +01:00
parent 3c4a7bacdf
commit 0fbcd7116b
32 changed files with 861 additions and 94 deletions

View file

@ -2,7 +2,9 @@
Built using fastapi. Built using fastapi.
## Database Migrations ## Deployment
### Database Migrations
You must run database migrations after first deploying the database, by running You must run database migrations after first deploying the database, by running
@ -11,3 +13,15 @@ sudo docker exec -w /app/todo/database -it todo-backend alembic upgrade head
``` ```
once you have launched the `todo-backend`-container. once you have launched the `todo-backend`-container.
## Development
### Populate the database
You can populate the database with fake users and todo-items by running
```bash
sudo docker exec -it todo-backend ash -c 'python -c "import todo.utils.fakery as fk; fk.populate_database()"'
```
once you have applied all database migrations.

View file

@ -1,5 +1,6 @@
alembic==1.10.4 alembic==1.10.4
anyio==3.6.2 anyio==3.6.2
bcrypt==4.0.1
click==8.1.3 click==8.1.3
Faker==18.9.0 Faker==18.9.0
fastapi==0.95.1 fastapi==0.95.1
@ -8,8 +9,10 @@ h11==0.14.0
idna==3.4 idna==3.4
Mako==1.2.4 Mako==1.2.4
MarkupSafe==2.1.2 MarkupSafe==2.1.2
passlib==1.7.4
psycopg2-binary==2.9.6 psycopg2-binary==2.9.6
pydantic==1.10.7 pydantic==1.10.7
PyJWT==2.7.0
python-dateutil==2.8.2 python-dateutil==2.8.2
six==1.16.0 six==1.16.0
sniffio==1.3.0 sniffio==1.3.0

99
backend/todo/auth/auth.py Normal file
View file

@ -0,0 +1,99 @@
from datetime import datetime, timedelta
import jwt
from fastapi import Depends, HTTPException, Security
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from todo.database.engine import get_db
from todo.config import get_settings
from todo.schemas.common import is_valid_id
from todo.schemas.users import User
import todo.crud.users as userscrud
from todo.utils.exceptions import NotFoundException
class AuthHandler():
"""This class handles operations related to authentication and authorization."""
security = HTTPBearer()
crypt_context = CryptContext(schemes=['bcrypt'], deprecated=['auto'])
jwt_secret = get_settings().jwt_secret
def hash_password(self, input_str: str) -> str:
"""Returns a salted hash of the input string.
Used to hash plaintext passwords prior to storage.
"""
if not isinstance(input_str, str):
raise TypeError("Expected a string.")
if not len(input_str):
raise ValueError("Input string cannot be empty.")
return self.crypt_context.hash(input_str)
def verify_password(self, plaintext: str, hash: str) -> bool:
"""Checks whether the hashed plaintext password matches the provided hash.
Used to compare a provided plaintext password to a stored hash during authentication.
"""
for input_str in [plaintext, hash]:
if not isinstance(input_str, str):
raise TypeError("Expected a string.")
if not len(input_str):
raise ValueError("Input string cannot be empty.")
return self.crypt_context.verify(plaintext, hash)
def encode_token(self, user_id: int) -> str:
"""Creates a fresh JWT containing the specified user id."""
if not is_valid_id(user_id):
raise ValueError("Invalid user ID.")
payload = {
'exp': datetime.utcnow() + timedelta(seconds=get_settings().jwt_expiration_time),
'iat': datetime.utcnow(),
'sub': user_id
}
return jwt.encode(
payload,
self.jwt_secret,
algorithm='HS256',
)
def decode_token(self, token: str) -> int:
"""Decodes the input JWT token and returns the user id stored in it.
If the token's signature does not match its contents, or if the token has
expired, an HTTPException is raised.
"""
try:
payload = jwt.decode(token, self.jwt_secret, algorithms=['HS256'])
return payload['sub']
except jwt.ExpiredSignatureError:
raise HTTPException(401, "JWT signature has expired.")
except jwt.InvalidTokenError:
raise HTTPException(401, "JWT token is invalid.")
def get_current_user_id(self, auth: HTTPAuthorizationCredentials) -> int :
return self.decode_token(auth.credentials)
def get_current_user(self, auth: HTTPAuthorizationCredentials = Security(security), db: Session = Depends(get_db)) -> User:
user_id = self.decode_token(auth.credentials)
return userscrud.read_user(db, user_id)
def asset_current_user_is_admin(self, auth: HTTPAuthorizationCredentials = Security(security), db: Session = Depends(get_db)):
user_id = self.decode_token(auth.credentials)
if not userscrud.read_user(db, user_id).is_admin:
raise HTTPException(403, "You are not authorized to perform this action.")

View file

@ -35,8 +35,15 @@ class Settings(BaseSettings):
pg_port = os.getenv("POSTGRES_PORT", "5432") pg_port = os.getenv("POSTGRES_PORT", "5432")
pg_dbname = os.getenv("POSTGRES_DB", "todo") pg_dbname = os.getenv("POSTGRES_DB", "todo")
pg_user = url_encode(os.getenv("POSTGRES_USER", "todo")) pg_user = url_encode(os.getenv("POSTGRES_USER", "todo"))
# XXX Change the database password to a random string of at least 16 characters
pg_password = url_encode(os.getenv("POSTGRES_PASSWORD", "todo")) pg_password = url_encode(os.getenv("POSTGRES_PASSWORD", "todo"))
# XXX Change the JWT secret to a random string of at least 32 characters
# The JWT secret is used to verify JWT signatures
jwt_secret = os.getenv("JWT_SECRET", "todo")
# Duration in seconds for how long newly created JWTs are valid
jwt_expiration_time = int(os.getenv("JWT_EXPIRATION_TIME", "172800"))
@lru_cache @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:

View file

@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
from todo.models import todos as todomodel from todo.models import todos as todomodel
from todo.models import users as usermodel from todo.models import users as usermodel
from todo.schemas import todos as todoschema from todo.schemas import todos as todoschema
from todo.schemas.users import User
from todo.utils.exceptions import NotFoundException, InvalidFilterParameterException from todo.utils.exceptions import NotFoundException, InvalidFilterParameterException
from todo.dependencies.common import SortOrder from todo.dependencies.common import SortOrder
from todo.dependencies.todos import SortableTodoItemField from todo.dependencies.todos import SortableTodoItemField
@ -65,6 +66,19 @@ def read_todos_for_user(
return [todoschema.TodoItem.from_orm(db_todo) for db_todo in db_todos] return [todoschema.TodoItem.from_orm(db_todo) for db_todo in db_todos]
def read_user_for_todo(
db: Session,
todo_id: int,
) -> User:
"""Returns the user who owns the todo-item with the specified ID."""
db_todo = db.query(todomodel.TodoItem).filter(todomodel.TodoItem.id == todo_id).first()
if not db_todo:
raise NotFoundException(f"Todo item with id '{todo_id}' not found.")
return User.from_orm(db_todo.user)
def read_todos_count_for_user(db: Session, user_id: int) -> int: def read_todos_count_for_user(db: Session, user_id: int) -> int:
"""Returns the total number of todo-items of the user with the specified user_id.""" """Returns the total number of todo-items of the user with the specified user_id."""

View file

@ -9,17 +9,8 @@ from todo.schemas import users as userschema
from todo.dependencies.common import SortOrder from todo.dependencies.common import SortOrder
from todo.dependencies.users import SortableUserField from todo.dependencies.users import SortableUserField
from todo.utils.exceptions import NotFoundException, InvalidFilterParameterException from todo.utils.exceptions import NotFoundException, InvalidFilterParameterException
from todo.schemas.auth import UserWithPassword
import todo.auth.auth as auth
def hash_password(password: str) -> str:
"""This is a placeholder for a secure password hashing algorithm.
It will convert a plaintext password into a secure, salted hash, for storage
in the database.
"""
# TODO actually hash the password!
return password
def create_user(db: Session, user: userschema.UserCreate) -> userschema.User: def create_user(db: Session, user: userschema.UserCreate) -> userschema.User:
@ -29,7 +20,8 @@ def create_user(db: Session, user: userschema.UserCreate) -> userschema.User:
email=user.email, email=user.email,
first_name=user.first_name, first_name=user.first_name,
last_name=user.last_name, last_name=user.last_name,
password=hash_password(user.password), password=auth.AuthHandler().hash_password(user.password),
is_admin=user.is_admin,
) )
db.add(db_user) db.add(db_user)
@ -58,6 +50,19 @@ def read_user_by_email(db: Session, email: str) -> userschema.User:
return userschema.User.from_orm(db_user) return userschema.User.from_orm(db_user)
def read_user_by_email_with_password(db: Session, email: str) -> UserWithPassword:
"""Queries the db for a user with the specified email and returns them.
The returned object also contains the user's hashed password.
"""
db_user = db.query(usermodel.User).filter(usermodel.User.email == email).first()
if not db_user:
raise NotFoundException(f"User with email '{email}' not found.")
return UserWithPassword.from_orm(db_user)
def read_users( def read_users(
db: Session, db: Session,
skip: int = 0, limit: int = 100, skip: int = 0, limit: int = 100,
@ -90,12 +95,12 @@ def update_user(db: Session, user: userschema.UserUpdate, id: int) -> userschema
if not db_user: if not db_user:
raise NotFoundException(f"User with id '{id}' not found.") raise NotFoundException(f"User with id '{id}' not found.")
for key in ['email', 'first_name', 'last_name']: for key in ['email', 'first_name', 'last_name', 'is_admin']:
value = getattr(user, key) value = getattr(user, key)
if value is not None: if value is not None:
setattr(db_user, key, value) setattr(db_user, key, value)
if user.password is not None: if user.password is not None:
setattr(db_user, "password", hash_password(user.password)) setattr(db_user, "password", auth.AuthHandler().hash_password(user.password))
db.commit() db.commit()
db.refresh(db_user) db.refresh(db_user)

View file

@ -6,7 +6,7 @@ Create Date: 2023-05-12 19:59:22.188464
""" """
from alembic import op from alembic import op
from sqlalchemy import Column, Integer, String, DateTime from sqlalchemy import Column, Integer, String, DateTime, Boolean
from sqlalchemy.sql.functions import now from sqlalchemy.sql.functions import now
@ -27,6 +27,7 @@ def upgrade() -> None:
Column('updated', DateTime(timezone=True), nullable=False, server_default=now(), onupdate=now()), Column('updated', DateTime(timezone=True), nullable=False, server_default=now(), onupdate=now()),
Column('first_name', String, nullable=False), Column('first_name', String, nullable=False),
Column('last_name', String, nullable=False, index=True), Column('last_name', String, nullable=False, index=True),
Column('is_admin', Boolean, nullable=False, default=False, index=True),
) )

View file

@ -17,6 +17,7 @@ class SortableUserField(Enum):
updated = 'updated' updated = 'updated'
first_name = 'first_name' first_name = 'first_name'
last_name = 'last_name' last_name = 'last_name'
is_admin = 'is_admin'
@property @property
def field(self) -> Column: def field(self) -> Column:

View file

@ -6,10 +6,10 @@ This module defines the API routes provided by the backend.
from fastapi import FastAPI from fastapi import FastAPI
from todo import config from todo import config
from todo.routes.users import router as users_router from todo.routes.users import router as router_users, tag_metadata as tags_users
from todo.routes.users import tag_metadata as users_tags from todo.routes.todos import router as router_todos, tag_metadata as tags_todos
from todo.routes.todos import router as todos_router from todo.routes.auth import router as router_auth, tag_metadata as tags_auth
from todo.routes.todos import tag_metadata as todos_tags from todo.routes.admin import router as router_admin, tag_metadata as tags_admin
s = config.get_settings() s = config.get_settings()
@ -23,13 +23,17 @@ app = FastAPI(
"url": f"{s.contact_url}", "url": f"{s.contact_url}",
}, },
openapi_tags=[ openapi_tags=[
users_tags, tags_users,
todos_tags, tags_todos,
tags_auth,
tags_admin,
], ],
) )
app.include_router(users_router) app.include_router(router_users)
app.include_router(todos_router) app.include_router(router_todos)
app.include_router(router_auth)
app.include_router(router_admin)
@app.get("/hello/") @app.get("/hello/")

View file

@ -1,6 +1,6 @@
"""This module defines the SQL data model for users.""" """This module defines the SQL data model for users."""
from sqlalchemy import Column, Integer, String, DateTime from sqlalchemy import Column, Integer, String, DateTime, Boolean
from sqlalchemy.sql.functions import now from sqlalchemy.sql.functions import now
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@ -19,5 +19,6 @@ class User(Base):
updated = Column('updated', DateTime(timezone=True), nullable=False, server_default=now(), onupdate=now()) updated = Column('updated', DateTime(timezone=True), nullable=False, server_default=now(), onupdate=now())
first_name = Column('first_name', String, nullable=False) first_name = Column('first_name', String, nullable=False)
last_name = Column('last_name', String, nullable=False, index=True) last_name = Column('last_name', String, nullable=False, index=True)
is_admin = Column('is_admin', Boolean, nullable=False, default=False, index=True)
todo_items = relationship("TodoItem", back_populates="user", uselist=True, cascade="all, delete") todo_items = relationship("TodoItem", back_populates="user", uselist=True, cascade="all, delete")

View file

@ -0,0 +1,67 @@
"""This module contains endpoints for admin operations."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from todo.database.engine import get_db
from todo.schemas import users as userschema
from todo.schemas import common as commonschema
from todo.crud import users as usercrud
from todo.utils.exceptions import NotFoundException, InvalidFilterParameterException
from todo.utils.exceptions import create_exception_dict as fmt
from todo.dependencies.users import UserSortablePaginationParams
import todo.auth.auth as auth
auth_handler = auth.AuthHandler()
router = APIRouter(
prefix="/admin",
tags=["admin"],
dependencies=[Depends(auth_handler.asset_current_user_is_admin)],
)
tag_metadata = {
"name": "admin",
"description": "Operations for administrators."
}
@router.post("/users/", response_model=userschema.User)
def create_user(
user: userschema.UserCreate,
db: Session = Depends(get_db),
):
# Check if user already exists
try:
usercrud.read_user_by_email(db, email=user.email)
raise HTTPException(400, "A user with this email address is already registered.")
except NotFoundException:
pass
return usercrud.create_user(db=db, user=user)
@router.get("/users/", response_model=list[userschema.User])
def read_users(
commons: Annotated[UserSortablePaginationParams, Depends(UserSortablePaginationParams)],
db: Session = Depends(get_db),
):
try:
return usercrud.read_users(
db=db,
skip=commons.skip,
limit=commons.limit,
sortby=commons.sortby,
sortorder=commons.sortorder
)
except InvalidFilterParameterException as e:
raise HTTPException(400, fmt(str(e)))
@router.get("/users/total/", response_model=commonschema.ItemCount)
def read_users_count(db: Session = Depends(get_db)):
return commonschema.ItemCount(total=usercrud.read_users_count(db=db))

View file

@ -0,0 +1,41 @@
"""This module contains endpoints for operations related to user authentication."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from todo.database.engine import get_db
from todo.utils.exceptions import NotFoundException, create_exception_dict as fmt
import todo.auth.auth as auth
from todo.crud.users import read_user_by_email_with_password
from todo.schemas.users import UserLogin as UserLoginSchema
from todo.schemas.auth import AuthResponseToken
router = APIRouter(
prefix="/auth",
tags=["authentication"]
)
tag_metadata = {
"name": "authentication",
"description": "Operations related to user authentication."
}
@router.post("/login", response_model=AuthResponseToken)
def login(credentials: UserLoginSchema, db: Session = Depends(get_db)):
"""Returns a JWT for the user whose credentials were provided.
The JWT can be submitted as a Bearer token on subsequent requests to authenticate
the user.
"""
try:
user = read_user_by_email_with_password(db, credentials.email)
except NotFoundException:
raise HTTPException(401, fmt("Invalid email or password."))
if not auth.AuthHandler().verify_password(credentials.password, user.password):
raise HTTPException(401, fmt("Invalid email or password."))
return AuthResponseToken(token=auth.AuthHandler().encode_token(user.id))

View file

@ -1,4 +1,4 @@
"""This module contains endpoints for operations related to users.""" """This module contains endpoints for operations related to todo-items."""
from typing import Annotated from typing import Annotated
@ -7,11 +7,12 @@ from sqlalchemy.orm import Session
from todo.database.engine import get_db from todo.database.engine import get_db
from todo.schemas import todos as todoschema from todo.schemas import todos as todoschema
from todo.schemas import users as userschema
from todo.crud import todos as todocrud from todo.crud import todos as todocrud
from todo.utils.exceptions import NotFoundException, InvalidFilterParameterException from todo.utils.exceptions import NotFoundException, InvalidFilterParameterException
from todo.utils.exceptions import create_exception_dict as fmt from todo.utils.exceptions import create_exception_dict as fmt
from todo.dependencies.todos import TodoItemSortablePaginationParams from todo.dependencies.todos import TodoItemSortablePaginationParams
import todo.auth.auth as auth
router = APIRouter( router = APIRouter(
prefix="/todos", prefix="/todos",
@ -23,9 +24,19 @@ tag_metadata = {
"description": "Operations related to todo items." "description": "Operations related to todo items."
} }
auth_handler = auth.AuthHandler()
@router.post("/user/{user_id}", response_model=todoschema.TodoItem) @router.post("/user/{user_id}", response_model=todoschema.TodoItem)
def create_todo(todo: todoschema.TodoItemCreate, user_id: int, db: Session = Depends(get_db)): def create_todo(
todo: todoschema.TodoItemCreate,
user_id: int,
db: Session = Depends(get_db),
current_user: userschema.User = Depends(auth_handler.get_current_user),
):
if not (current_user.is_admin or current_user.id == user_id):
raise HTTPException(403, "You are not authorized to perform this action.")
try: try:
return todocrud.create_todo(db=db, todo=todo, user_id=user_id) return todocrud.create_todo(db=db, todo=todo, user_id=user_id)
except NotFoundException as e: except NotFoundException as e:
@ -33,19 +44,32 @@ def create_todo(todo: todoschema.TodoItemCreate, user_id: int, db: Session = Dep
@router.get("/{todo_id}", response_model=todoschema.TodoItem) @router.get("/{todo_id}", response_model=todoschema.TodoItem)
def read_todo(todo_id: int, db: Session = Depends(get_db)): def read_todo(
todo_id: int,
db: Session = Depends(get_db),
current_user: userschema.User = Depends(auth_handler.get_current_user),
):
try: try:
return todocrud.read_todo(db=db, todo_id=todo_id) todo = todocrud.read_todo(db=db, todo_id=todo_id)
except NotFoundException as e: except NotFoundException as e:
raise HTTPException(404, fmt(str(e))) raise HTTPException(404, fmt(str(e)))
todo_owner = todocrud.read_user_for_todo(db=db, todo_id=todo_id)
if not (current_user.is_admin or current_user.id == todo_owner.id):
raise HTTPException(403, "You are not authorized to view this content.")
@router.get("/user/{user_id}", response_model=list[todoschema.TodoItem]) @router.get("/user/{user_id}", response_model=list[todoschema.TodoItem])
def read_todos( def read_todos(
user_id: int, user_id: int,
commons: Annotated[TodoItemSortablePaginationParams, Depends(TodoItemSortablePaginationParams)], commons: Annotated[TodoItemSortablePaginationParams, Depends(TodoItemSortablePaginationParams)],
db: Session = Depends(get_db) db: Session = Depends(get_db),
current_user: userschema.User = Depends(auth_handler.get_current_user),
): ):
if not (current_user.is_admin or current_user.id == user_id):
raise HTTPException(403, "You are not authorized to view this content.")
try: try:
return todocrud.read_todos_for_user( return todocrud.read_todos_for_user(
db=db, db=db,
@ -60,7 +84,14 @@ def read_todos(
@router.get("/user/{user_id}/total", response_model=dict[str, int]) @router.get("/user/{user_id}/total", response_model=dict[str, int])
def read_todos_count(user_id: int, db: Session = Depends(get_db)): def read_todos_count(
user_id: int,
db: Session = Depends(get_db),
current_user: userschema.User = Depends(auth_handler.get_current_user),
):
if not (current_user.is_admin or current_user.id == user_id):
raise HTTPException(403, "You are not authorized to view this content.")
try: try:
return {"total": todocrud.read_todos_count_for_user(db=db, user_id=user_id)} return {"total": todocrud.read_todos_count_for_user(db=db, user_id=user_id)}
except NotFoundException as e: except NotFoundException as e:
@ -68,16 +99,33 @@ def read_todos_count(user_id: int, db: Session = Depends(get_db)):
@router.patch("/{todo_id}", response_model=todoschema.TodoItem) @router.patch("/{todo_id}", response_model=todoschema.TodoItem)
def update_todo(todo_id: int, todo: todoschema.TodoItemUpdate, db: Session = Depends(get_db)): def update_todo(
todo_id: int,
todo: todoschema.TodoItemUpdate,
db: Session = Depends(get_db),
current_user: userschema.User = Depends(auth_handler.get_current_user),
):
try: try:
todo_owner = todocrud.read_user_for_todo(db=db, todo_id=todo_id)
if not (current_user.is_admin or current_user.id == todo_owner.id):
raise HTTPException(403, "You are not authorized to perform this action.")
return todocrud.update_todo(db=db, todo=todo, todo_id=todo_id) return todocrud.update_todo(db=db, todo=todo, todo_id=todo_id)
except NotFoundException as e: except NotFoundException as e:
raise HTTPException(404, fmt(str(e))) raise HTTPException(404, fmt(str(e)))
@router.delete("/{todo_id}", response_model=todoschema.TodoItem) @router.delete("/{todo_id}", response_model=todoschema.TodoItem)
def delete_todo(todo_id: int, db: Session = Depends(get_db)): def delete_todo(
todo_id: int,
db: Session = Depends(get_db),
current_user: userschema.User = Depends(auth_handler.get_current_user),
):
try: try:
todo_owner = todocrud.read_user_for_todo(db=db, todo_id=todo_id)
if not (current_user.is_admin or current_user.id == todo_owner.id):
raise HTTPException(403, "You are not authorized to perform this action.")
return todocrud.delete_todo(db=db, todo_id=todo_id) return todocrud.delete_todo(db=db, todo_id=todo_id)
except NotFoundException as e: except NotFoundException as e:
raise HTTPException(404, fmt(str(e))) raise HTTPException(404, fmt(str(e)))

View file

@ -11,6 +11,7 @@ from todo.crud import users as usercrud
from todo.utils.exceptions import NotFoundException, InvalidFilterParameterException from todo.utils.exceptions import NotFoundException, InvalidFilterParameterException
from todo.utils.exceptions import create_exception_dict as fmt from todo.utils.exceptions import create_exception_dict as fmt
from todo.dependencies.users import UserSortablePaginationParams from todo.dependencies.users import UserSortablePaginationParams
import todo.auth.auth as auth
router = APIRouter( router = APIRouter(
@ -23,58 +24,72 @@ tag_metadata = {
"description": "Operations related to users." "description": "Operations related to users."
} }
auth_handler = auth.AuthHandler()
@router.post("/", response_model=userschema.User) @router.post("/", response_model=userschema.User)
def create_user(user: userschema.UserCreate, db: Session = Depends(get_db)): def create_user(
user: userschema.UserCreate,
db: Session = Depends(get_db),
):
# Check if user already exists
try: try:
# An exception is expected here, because we need to check if a user with this email is already registered
usercrud.read_user_by_email(db, email=user.email) usercrud.read_user_by_email(db, email=user.email)
raise HTTPException(400, "A user with this email address is already registered.") raise HTTPException(400, "A user with this email address is already registered.")
except NotFoundException: except NotFoundException:
pass
if user.is_admin:
raise HTTPException(403, "You are not authorized to perform this action.")
return usercrud.create_user(db=db, user=user) return usercrud.create_user(db=db, user=user)
@router.get("/{id}", response_model=userschema.User) @router.get("/{user_id}", response_model=userschema.User)
def read_user(id: int, db: Session = Depends(get_db)): def read_user(
user_id: int,
db: Session = Depends(get_db),
current_user: userschema.User = Depends(auth_handler.get_current_user),
):
try: try:
return usercrud.read_user(db=db, id=id) user = usercrud.read_user(db=db, id=user_id)
except NotFoundException as e: except NotFoundException as e:
raise HTTPException(404, fmt(str(e))) raise HTTPException(404, fmt(str(e)))
if current_user.is_admin or current_user.id == user_id:
@router.get("/", response_model=list[userschema.User]) return user
def read_users( raise HTTPException(403, "You are not authorized to view this content.")
commons: Annotated[UserSortablePaginationParams, Depends(UserSortablePaginationParams)],
db: Session = Depends(get_db)
):
try:
return usercrud.read_users(
db=db,
skip=commons.skip,
limit=commons.limit,
sortby=commons.sortby,
sortorder=commons.sortorder
)
except InvalidFilterParameterException as e:
raise HTTPException(400, fmt(str(e)))
@router.get("/total/", response_model=dict[str, int])
def read_users_count(db: Session = Depends(get_db)):
return {"total": usercrud.read_users_count(db=db)}
@router.patch("/{id}", response_model=userschema.User) @router.patch("/{id}", response_model=userschema.User)
def update_user(id: int, user: userschema.UserUpdate, db: Session = Depends(get_db)): def update_user(
user_id: int,
user: userschema.UserUpdate,
db: Session = Depends(get_db),
current_user: userschema.User = Depends(auth_handler.get_current_user),
):
if not (current_user.is_admin or current_user.id == user_id):
raise HTTPException(403, "You are not authorized to perform this action.")
if user.is_admin and not current_user.is_admin:
raise HTTPException(403, "You are not authorized to perform this action.")
try: try:
return usercrud.update_user(db=db, user=user, id=id) return usercrud.update_user(db=db, user=user, id=user_id)
except NotFoundException as e: except NotFoundException as e:
raise HTTPException(404, fmt(str(e))) raise HTTPException(404, fmt(str(e)))
@router.delete("/{id}", response_model=userschema.User) @router.delete("/{id}", response_model=userschema.User)
def delete_user(id: int, db: Session = Depends(get_db)): def delete_user(
user_id: int,
db: Session = Depends(get_db),
current_user: userschema.User = Depends(auth_handler.get_current_user),
):
if not (current_user.is_admin or current_user.id == user_id):
raise HTTPException(403, "You are not authorized to perform this action.")
try: try:
return usercrud.delete_user(db=db, id=id) return usercrud.delete_user(db=db, id=user_id)
except NotFoundException as e: except NotFoundException as e:
raise HTTPException(404, fmt(str(e))) raise HTTPException(404, fmt(str(e)))

View file

@ -0,0 +1,24 @@
"""This module declareds the pydantic ORM representation for authentication responses."""
from pydantic import BaseModel
from todo.schemas.users import User
class UserWithPassword(User):
"""Schema used during authentication.
As this object includes the user's hashed password, it should NEVER be sent
to the client.
"""
password: str
class Config:
orm_mode = True
class AuthResponseToken(BaseModel):
"""Schema used when responding to a login request."""
token: str

View file

@ -1,4 +1,7 @@
"""This module contains common functions for schema validation.""" """This module contains common schemas and common functions for schema validation."""
from pydantic import BaseModel, validator
def is_valid_id(id: int) -> bool: def is_valid_id(id: int) -> bool:
"""Checks whether the specified id is a valid ID. """Checks whether the specified id is a valid ID.
@ -10,3 +13,15 @@ def is_valid_id(id: int) -> bool:
raise TypeError(f"Expected an integer but got: {type(id)}") raise TypeError(f"Expected an integer but got: {type(id)}")
return id > 0 return id > 0
class ItemCount(BaseModel):
"""A schema used to represent an item count."""
total: int
@validator('total')
def assert_total_is_valid(cls, total):
if total < 0:
raise ValueError("An item count cannot be negative.")
return total

View file

@ -49,7 +49,7 @@ class TodoItemUpdate(AbstractTodoItemValidator):
class TodoItem(AbstractTodoItem): class TodoItem(AbstractTodoItem):
"""Schema for user info displaying.""" """Schema for todo-item displaying."""
id: int id: int
done: bool done: bool

View file

@ -57,6 +57,14 @@ class AbstractUser(AbstractUserValidator, ABC):
email: str email: str
first_name: str first_name: str
last_name: str last_name: str
is_admin: bool
class UserLogin(AbstractUserValidator, ABC):
"""Schema used during user login"""
email: str
password: str
class UserCreate(AbstractUser): class UserCreate(AbstractUser):
@ -85,6 +93,7 @@ class UserUpdate(AbstractUserValidator):
email: Optional[str] email: Optional[str]
first_name: Optional[str] first_name: Optional[str]
last_name: Optional[str] last_name: Optional[str]
is_admin: Optional[bool]
password: Optional[str] = None password: Optional[str] = None
password_confirmation: Optional[str] = None password_confirmation: Optional[str] = None

View file

@ -6,13 +6,16 @@ from faker import Faker
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from todo.schemas.users import UserCreate, User from todo.schemas.users import UserCreate, User
from todo.crud.users import hash_password from todo.auth.auth import AuthHandler
from todo.models.users import User as UserModel from todo.models.users import User as UserModel
from todo.schemas.todos import TodoItemCreate, TodoItem from todo.schemas.todos import TodoItemCreate, TodoItem
from todo.models.todos import TodoItem as TodoItemModel from todo.models.todos import TodoItem as TodoItemModel
from todo.database.engine import get_db from todo.database.engine import get_db
auth_handler = AuthHandler()
def _get_faker() -> Faker: def _get_faker() -> Faker:
"""Creates and returns a Faker object.""" """Creates and returns a Faker object."""
@ -24,10 +27,10 @@ def get_fake_user_details() -> UserCreate:
fk = _get_faker() fk = _get_faker()
password = fk.password(length=randint(6, 16))
email = fk.profile(fields=['mail'])['mail'] email = fk.profile(fields=['mail'])['mail']
first_name = fk.first_name() first_name = fk.first_name()
last_name = fk.last_name() last_name = fk.last_name()
password = email
return UserCreate( return UserCreate(
email=email, email=email,
@ -35,6 +38,7 @@ def get_fake_user_details() -> UserCreate:
last_name=last_name, last_name=last_name,
password=password, password=password,
password_confirmation=password, password_confirmation=password,
is_admin=False,
) )
@ -70,14 +74,16 @@ def create_fake_user(db: Session = next(get_db())) -> User:
fk = _get_faker() fk = _get_faker()
created = fk.date_time_between('-2y', 'now') created = fk.date_time_between('-2y', 'now')
updated = fk.date_time_between(created, 'now') updated = fk.date_time_between(created, 'now')
is_admin = randint(0, 10) == 0
db_user = UserModel( db_user = UserModel(
email=user.email, email=user.email,
first_name=user.first_name, first_name=user.first_name,
last_name=user.last_name, last_name=user.last_name,
password=hash_password(user.password), password=auth_handler.hash_password(user.password),
created=created, created=created,
updated=updated, updated=updated,
is_admin=is_admin,
) )
db.add(db_user) db.add(db_user)

View file

@ -67,6 +67,8 @@ services:
POSTGRES_DB: "todo" POSTGRES_DB: "todo"
POSTGRES_USER: "todo" POSTGRES_USER: "todo"
POSTGRES_PASSWORD: "todo" POSTGRES_PASSWORD: "todo"
JWT_SECRET: "todo"
JWT_EXPIRATION_TIME: "172800"
todo-db: todo-db:
image: postgres:alpine image: postgres:alpine
container_name: todo-db container_name: todo-db

View file

@ -8,7 +8,8 @@
"name": "fastapi-svelte-template", "name": "fastapi-svelte-template",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"dotenv": "^16.0.3" "dotenv": "^16.0.3",
"jwt-decode": "^3.1.2"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^1.2.4", "@sveltejs/adapter-node": "^1.2.4",
@ -2716,6 +2717,11 @@
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
}, },
"node_modules/jwt-decode": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
},
"node_modules/kleur": { "node_modules/kleur": {
"version": "4.1.5", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@ -6145,6 +6151,11 @@
"integrity": "sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg==", "integrity": "sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg==",
"dev": true "dev": true
}, },
"jwt-decode": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
},
"kleur": { "kleur": {
"version": "4.1.5", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",

View file

@ -26,6 +26,7 @@
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"dotenv": "^16.0.3" "dotenv": "^16.0.3",
"jwt-decode": "^3.1.2"
} }
} }

View file

@ -30,6 +30,14 @@
h6 { h6 {
@apply text-lg; @apply text-lg;
} }
a {
@apply underline text-secondary-500;
}
a:visited {
@apply text-secondary-700;
}
} }
@layer components { @layer components {
@ -60,7 +68,6 @@
table { table {
@apply border-collapse; @apply border-collapse;
} }
th:first-of-type { th:first-of-type {
@apply rounded-tl-md; @apply rounded-tl-md;
} }
@ -73,7 +80,6 @@
tr:last-of-type td:last-of-type { tr:last-of-type td:last-of-type {
@apply rounded-br-md; @apply rounded-br-md;
} }
tr { tr {
@apply hover:bg-secondary-300/50; @apply hover:bg-secondary-300/50;
@apply border-y border-primary/50; @apply border-y border-primary/50;
@ -82,7 +88,6 @@
tr:first-of-type, tr:last-of-type { tr:first-of-type, tr:last-of-type {
@apply border-y-0; @apply border-y-0;
} }
th { th {
@apply bg-primary font-semibold text-secondary-100; @apply bg-primary font-semibold text-secondary-100;
} }
@ -94,4 +99,24 @@
@apply p-2 text-start; @apply p-2 text-start;
} }
} }
fieldset {
@apply border border-secondary-300;
@apply p-2;
}
legend {
@apply text-black/50;
}
input {
@apply border border-secondary;
@apply bg-secondary-50;
@apply rounded-md;
@apply p-1;
}
input:focus {
@apply outline-none border-none ring-2 ring-secondary-400;
}
label {
@apply text-sm text-secondary;
}
} }

View file

@ -1,17 +1,85 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { User } from './types'; import type { ItemCount, User } from './types';
import { getResponseBodyOrError } from '$lib/api/utils';
import { getTokenFromLocalstorage } from '$lib/auth/session';
const globalPrefix = '/api'; /**
* Retrieves the currently logged in user's JWT from localstorage,
const usersPrefix = `${globalPrefix}/users`; * or throws an error if it is not present.
export async function readUser(fetch: CallableFunction, userId: number): Promise<User> { *
const endpoint = `${usersPrefix}/${userId}`; * @throws {error} - If the current user is not logged in or if their JWT is not saved in localstorage.
const response = await fetch(endpoint); */
function getTokenOrError(): string {
if (!response.ok) { let token = getTokenFromLocalstorage();
throw error(response.status, response.statusText); if (token === null) {
throw error(401, 'You are not logged in.');
} }
return token;
}
const responseJson = await response.json(); /**
* Retrieves the user with the specified ID from the backend API.
*
* @param {number} userId - The ID of the user whom to retrieve.
* @param {string} jwt - The JWT appended as a bearer token to authorize the request.
* @throws{error} - If the request fails or is not permitted.
*/
export async function readUser(userId: number, jwt: string = getTokenOrError()): Promise<User> {
const endpoint = `/api/users/${userId}`;
const response = await fetch(endpoint, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${jwt}`,
'Content-Type': 'application/json',
}
});
const responseJson = await getResponseBodyOrError(response);
return responseJson as User; return responseJson as User;
} }
/**
* Retrieves the list of all users from the backend API.
*
* @param {string} jwt - The JWT appended as a bearer token to authorize the request.
* @throws{error} - If the request fails or is not permitted.
*/
export async function readUsers(jwt: string = getTokenOrError()): Promise<User[]> {
// TODO add pagination and sorting query params
// TODO refactor Table.svelte
const endpoint = '/api/admin/users/';
const response = await fetch(endpoint, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${jwt}`,
'Content-Type': 'application/json',
}
});
const responseJson = await getResponseBodyOrError(response);
return responseJson as User[];
}
/**
* Retrieves the total user count from the backend API.
*
* @param {string} jwt - The JWT appended as a bearer token to authorize the request.
* @throws{error} - If the request fails or is not permitted.
*/
export async function readUserCount(jwt: string = getTokenOrError()): Promise<number> {
const endpoint = '/api/admin/users/total/';
const response = await fetch(endpoint, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${jwt}`,
'Content-Type': 'application/json',
}
});
const responseJson = await getResponseBodyOrError(response);
const itemCount = responseJson as ItemCount;
return itemCount.total;
}

View file

@ -1,7 +1,13 @@
export type User = { export type User = {
id: number, id: number,
email: string,
first_name: string, first_name: string,
last_ame: string, last_name: string,
created: Date, created: Date,
updated: Date updated: Date,
is_admin: boolean,
}
export type ItemCount = {
total: number
} }

View file

@ -0,0 +1,27 @@
import { error } from '@sveltejs/kit';
/**
* Returns the specified response object's body as an object if the status is ok.
* If not, raises an error after attempting to extract a detailed
* error message from the response body.
* @param {Response} response - The response to inspect
* @throws {Error} With a detailed Error.message if the response's status is not ok.
* @returns {unknown} The object contained by the response object
*/
export async function getResponseBodyOrError(response: Response): Promise<unknown> {
const json = await response.json();
if (!response.ok) {
if ('detail' in json) {
if (typeof json.detail === 'string') {
throw error(response.status, json.detail);
} else if (Array.isArray(json.detail)) {
if ('msg' in json.detail[0] && typeof json.detail[0].msg === 'string') {
throw error(response.status, json.detail[0].msg);
}
}
}
throw error(response.status, `API request failed: ${response.statusText}`);
}
return json;
}

View file

@ -0,0 +1,157 @@
import { getResponseBodyOrError } from '$lib/api/utils';
import { readUser } from '$lib/api/endpoints';
import jwt_decode from 'jwt-decode';
import { writable } from 'svelte/store';
/**
* Svelte store to hold the current StoredUser object.
*/
export const storedUser = writable();
// Name of the key holding the auth JWT in localstorage
const jwtKey = 'jwt';
// Name of the key holding the authenticated user in localstorage
const userKey = 'user';
export type StoredUser = {
id: number,
email: string,
isAdmin: boolean,
sessionExpires: Date
}
/**
* Saves the specified token in localstorage.
*
* @param {string} token - The token to save in localstorage.
*/
function saveTokenToLocalstorage(token: string): void {
localStorage.setItem(jwtKey, token);
}
/**
* Retrieves and returns the token, if present, from localstorage.
*/
export function getTokenFromLocalstorage(): string | null {
return localStorage.getItem(jwtKey);
}
/**
* Removes the saved token from localstorage.
*/
function clearTokenInLocalstorage(): void {
localStorage.removeItem(jwtKey);
}
/**
* Saves the specified StoredUser object in localstorage.
*
* @param {StoredUser} user - The user to write to localstorage.
*/
function saveUserToLocalstorage(user: StoredUser): void {
localStorage.setItem(userKey, JSON.stringify(user));
}
/**
* Retrieves and returns the user, if present, from localstorage.
*/
function getUserFromLocalstorage(): StoredUser | null {
let item: string | null = localStorage.getItem(userKey);
if (typeof item !== 'string') {
return null;
}
return JSON.parse(item) as StoredUser;
}
/**
* Removes the saved user from localstorage.
*/
function clearUserInLocalstorage(): void {
localStorage.removeItem(userKey);
}
/**
* Sends an API request containing the specified login credentials to the backend,
* and returns the JWT retrieved from the response on sucess.
*
* @param {string} email - The email address of the user whose JWT to retrieve.
* @param {string} password - The password used to authenticate the user whose JWT is being retrieved.
* @throws {Error} - If the API request failed or the supplied credentials were invalid.
*/
async function requestJwt(email: string, password: string): Promise<string> {
type Token = {
token: string
}
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email,
password: password,
})
});
const responseBody = await getResponseBodyOrError(response) as Token;
return responseBody.token;
}
/**
* Loads the user currently saved in localstorage into the storedUser
* svelte store.
*/
export function loadUserIntoStore(): void {
storedUser.set(getUserFromLocalstorage());
}
/**
* Clears the storedUser svelte store.
*/
function clearUserFromStore(): void {
storedUser.set(null);
}
/**
* Sends an API request to retrieve the specified user's JWT from the backend,
* and stores the retrieved token in localstorage. Then, retrieves the authenticated
* user's basic account information (StoredUser), saves it in localsotrage and in the
* currentUser svelte store.
*
* @param {string} email - The email address of the user whose token and information to retrieve.
* @param {string} password - The password used to authenticate the user whose token is being retrieved.
* @throws {Error} - If any API request fails or the supplied credentials were invalid.
*/
export async function login(email: string, password: string): Promise<void> {
const token = await requestJwt(email, password);
interface Token {
sub: number,
exp: number
}
const parsedToken = jwt_decode(token) as Token;
const userId = parsedToken.sub;
const user = await readUser(userId, token);
saveTokenToLocalstorage(token);
saveUserToLocalstorage({
id: user.id,
email: user.email,
isAdmin: user.is_admin,
sessionExpires: new Date(parsedToken.exp),
});
loadUserIntoStore();
}
/**
* Purges the currently logged in user's information and token from localstorage
* and the svelte store.
*/
export function logout(): void {
clearUserFromStore();
clearUserInLocalstorage();
clearTokenInLocalstorage();
}

View file

@ -22,14 +22,45 @@
</svelte:head> </svelte:head>
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import "../app.css"; import "../app.css";
import { page } from '$app/stores'; import { page } from '$app/stores';
import { loadUserIntoStore, logout, storedUser } from '$lib/auth/session';
import type { StoredUser } from '$lib/auth/session';
export let title = import.meta.env.PUBLIC_TITLE; export let title = import.meta.env.PUBLIC_TITLE;
export let description = import.meta.env.PUBLIC_DESCRIPTION; export let description = import.meta.env.PUBLIC_DESCRIPTION;
export let author = import.meta.env.PUBLIC_AUTHOR; export let author = import.meta.env.PUBLIC_AUTHOR;
const ogImageUrl = new URL('/images/common/og-image.webp', import.meta.url).href const ogImageUrl = new URL('/images/common/og-image.webp', import.meta.url).href
let user: StoredUser | null = null;
storedUser.subscribe((value) => {
user = value;
});
function handleLogout() {
logout();
goto('/');
}
onMount(async () => {
loadUserIntoStore();
});
</script> </script>
<nav>
<ul>
<li class="inline">
<a href="/">Home</a>
{#if user}
<a href={`/users/${user.id}`}>My Profile</a>
<button on:click={handleLogout}>Log Out</button>
{:else}
<a href="/login">Log In</a>
{/if}
</li>
</ul>
</nav>
<slot /> <slot />

View file

@ -0,0 +1,61 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { login } from '$lib/auth/session';
let email: string = "";
let password: string = "";
let formError = false;
let errorMessage: string | null = null;
function isValidEmail(email: string | null) {
const emailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return typeof email === 'string' ? emailRegex.test(email) : false;
}
function isValidpassword(password: string | null) {
return typeof password === 'string' ? password.length > 0 : false;
}
async function handleFormSubmit(event: SubmitEvent) {
event.preventDefault();
if (!isValidEmail(email)) {
console.log("Invalid email address format.");
return;
}
if (!isValidpassword(password)) {
console.log("Invalid password format.");
return;
}
try {
await login(email, password);
goto('/');
} catch (error: any) {
errorMessage = error.message;
formError = true;
}
}
</script>
<div class="flex flex-col items-center gap-y-4">
<h1>Log In</h1>
<form action="" method="post" on:submit={handleFormSubmit}>
<fieldset class="flex flex-col gap-y-4 max-w-lg">
<legend>Please enter your login credentials</legend>
<div class="flex flex-col">
<input bind:value={email} type="email" name="email" placeholder="user@example.com" required class="block" />
<label for="email" class="block">Email</label>
</div>
<div class="flex flex-col">
<input bind:value={password} type="password" name="password" required class="block" />
<label for="password" class="block">Password</label>
</div>
<button type="submit" name="submit">Log In</button>
{#if formError}
<p class="text-red-500">{errorMessage}</p>
{/if}
</fieldset>
</form>
</div>

View file

@ -1,15 +1,12 @@
import { error } from '@sveltejs/kit';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
import type { User } from '$lib/api/types'; import type { User } from '$lib/api/types';
import { readUser } from '$lib/api/endpoints';
export const ssr = false; export const ssr = false;
export const load = (async ({ fetch, params }) => { export const load = (async ({ params }) => {
// check if user exists // check if user exists
const userId = params.user; const userId = params.user;
const response = await fetch(`/api/users/${userId}`); const user = await readUser(userId);
if (!response.ok) throw error(response.status, response.statusText);
const user = await response.json() as User;
return { return {
user: user user: user
}; };

View file

@ -30,6 +30,11 @@
heading: "Updated", heading: "Updated",
type: 'date', type: 'date',
}, },
{
field: "is_admin",
heading: "Admin",
type: 'boolean',
},
], ],
itemCountEndpoint: "/api/users/total/", itemCountEndpoint: "/api/users/total/",
itemsEndpoint: "/api/users/", itemsEndpoint: "/api/users/",

View file

@ -63,6 +63,8 @@ services:
POSTGRES_DB: "todo" POSTGRES_DB: "todo"
POSTGRES_USER: "todo" POSTGRES_USER: "todo"
POSTGRES_PASSWORD: "todo" POSTGRES_PASSWORD: "todo"
JWT_SECRET: "todo"
JWT_EXPIRATION_TIME: "172800"
todo-db: todo-db:
image: postgres:alpine image: postgres:alpine
container_name: todo-db container_name: todo-db