Merge pull request 'add authentication system and missing frontend routes' (#1) from auth into main

Reviewed-on: https://git.skyforest.net/jlobbes/fastapi-svelte-template/pulls/1
This commit is contained in:
Julian Lobbes 2023-06-01 01:22:38 +02:00
commit b34149e085
49 changed files with 1413 additions and 294 deletions

View file

@ -2,7 +2,9 @@
Built using fastapi.
## Database Migrations
## Deployment
### Database Migrations
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.
## 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
anyio==3.6.2
bcrypt==4.0.1
click==8.1.3
Faker==18.9.0
fastapi==0.95.1
@ -8,8 +9,10 @@ h11==0.14.0
idna==3.4
Mako==1.2.4
MarkupSafe==2.1.2
passlib==1.7.4
psycopg2-binary==2.9.6
pydantic==1.10.7
PyJWT==2.7.0
python-dateutil==2.8.2
six==1.16.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_dbname = os.getenv("POSTGRES_DB", "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"))
# 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
def get_settings() -> Settings:

View file

@ -7,8 +7,10 @@ from sqlalchemy.orm import Session
from todo.models import todos as todomodel
from todo.models import users as usermodel
from todo.schemas import todos as todoschema
from todo.schemas.users import User
from todo.utils.exceptions import NotFoundException, InvalidFilterParameterException
from todo.models.common import SortOrder
from todo.dependencies.common import SortOrder
from todo.dependencies.todos import SortableTodoItemField
def create_todo(db: Session, todo: todoschema.TodoItemCreate, user_id: int) -> todoschema.TodoItem:
@ -44,7 +46,7 @@ def read_todos_for_user(
db: Session,
user_id: int,
skip: int = 0, limit: int = 100,
sortby: todomodel.SortableTodoItemField = todomodel.SortableTodoItemField('updated'),
sortby: SortableTodoItemField = SortableTodoItemField('updated'),
sortorder: SortOrder = SortOrder['desc'],
) -> list[todoschema.TodoItem]:
"""Returns a range of todo-items of the user with the specified user_id from the database."""
@ -64,6 +66,19 @@ def read_todos_for_user(
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:
"""Returns the total number of todo-items of the user with the specified user_id."""

View file

@ -6,19 +6,11 @@ from sqlalchemy.orm import Session
from todo.models import users as usermodel
from todo.schemas import users as userschema
from todo.models.common import SortOrder
from todo.dependencies.common import SortOrder
from todo.dependencies.users import SortableUserField
from todo.utils.exceptions import NotFoundException, InvalidFilterParameterException
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
from todo.schemas.auth import UserWithPassword
import todo.auth.auth as auth
def create_user(db: Session, user: userschema.UserCreate) -> userschema.User:
@ -28,7 +20,8 @@ def create_user(db: Session, user: userschema.UserCreate) -> userschema.User:
email=user.email,
first_name=user.first_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)
@ -57,10 +50,23 @@ def read_user_by_email(db: Session, email: str) -> userschema.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(
db: Session,
skip: int = 0, limit: int = 100,
sortby: usermodel.SortableUserField = usermodel.SortableUserField('id'),
sortby: SortableUserField = SortableUserField('id'),
sortorder: SortOrder = SortOrder['asc'],
) -> list[userschema.User]:
"""Returns an range of users from the database."""
@ -89,12 +95,12 @@ def update_user(db: Session, user: userschema.UserUpdate, id: int) -> userschema
if not db_user:
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)
if value is not None:
setattr(db_user, key, value)
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.refresh(db_user)

View file

@ -6,7 +6,7 @@ Create Date: 2023-05-12 19:59:22.188464
"""
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
@ -27,6 +27,7 @@ def upgrade() -> None:
Column('updated', DateTime(timezone=True), nullable=False, server_default=now(), onupdate=now()),
Column('first_name', String, nullable=False),
Column('last_name', String, nullable=False, index=True),
Column('is_admin', Boolean, nullable=False, default=False, index=True),
)

View file

@ -0,0 +1,43 @@
"""This module provides FastAPI dependencies for commonly used query parameters."""
from enum import Enum
from typing import Callable
import sqlalchemy
from todo.database.engine import Base
class SortOrder(Enum):
"""Possible sort orders for database queries."""
asc = 'asc'
desc = 'desc'
@property
def call(self) -> Callable:
"""Returns the sqlalchemy sort function depending on the instance value."""
if self.value == 'asc':
return sqlalchemy.asc
elif self.value == 'desc':
return sqlalchemy.desc
else:
raise RuntimeError("Logic error.")
class PaginationParams():
"""Represents query parameters used for pagination, when querying for a list of items.
Attributes
----------
skip : int
The number of items to be skipped from the start of the start of the list.
limit : int
Limits the total number of returned list items to the specified number.
"""
def __init__(self, skip: int = 0, limit: int = 100):
self.skip = skip
self.limit = limit

View file

@ -0,0 +1,35 @@
"""Query parameters used to sort todo-items."""
from enum import Enum
from sqlalchemy import Column
from todo.models.todos import TodoItem
from todo.dependencies.common import PaginationParams, SortOrder
class SortableTodoItemField(Enum):
"""Defines which fields todo-item lists can be sorted on."""
id = 'id'
title = 'title'
done = 'done'
created = 'created'
updated = 'updated'
finished = 'finished'
@property
def field(self) -> Column:
return getattr(TodoItem, self.value)
class TodoItemSortablePaginationParams(PaginationParams):
def __init__(
self,
skip: int = 0, limit: int = 100,
sortby: SortableTodoItemField = SortableTodoItemField['updated'],
sortorder: SortOrder = SortOrder['desc'],
):
super().__init__(skip=skip, limit=limit)
self.sortby = sortby
self.sortorder = sortorder

View file

@ -0,0 +1,36 @@
"""Query parameters used to sort users."""
from enum import Enum
from sqlalchemy import Column
from todo.models.users import User
from todo.dependencies.common import PaginationParams, SortOrder
class SortableUserField(Enum):
"""Defines which fields user lists can be sorted on."""
id = 'id'
email = 'email'
created = 'created'
updated = 'updated'
first_name = 'first_name'
last_name = 'last_name'
is_admin = 'is_admin'
@property
def field(self) -> Column:
return getattr(User, self.value)
class UserSortablePaginationParams(PaginationParams):
def __init__(
self,
skip: int = 0, limit: int = 100,
sortby: SortableUserField = SortableUserField['id'],
sortorder: SortOrder = SortOrder['asc'],
):
super().__init__(skip=skip, limit=limit)
self.sortby = sortby
self.sortorder = sortorder

View file

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

View file

@ -1,26 +0,0 @@
"""This module contains common utilities for handling models."""
import enum
from typing import Callable
import sqlalchemy
class SortOrder(enum.Enum):
"""Possible sort orders for database queries."""
asc = 'asc'
ASC = 'asc'
desc = 'desc'
DESC = 'desc'
@property
def call(self) -> Callable:
"""Returns the sqlalchemy sort function depending on the instance value."""
if self.value == 'asc':
return sqlalchemy.asc
elif self.value == 'desc':
return sqlalchemy.desc
else:
raise RuntimeError("Logic error.")

View file

@ -1,8 +1,6 @@
"""This module defines the SQL data model for todo items."""
import enum
from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Date, Enum, CheckConstraint, Boolean
from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Boolean
from sqlalchemy.sql.functions import now
from sqlalchemy.orm import relationship
@ -26,18 +24,3 @@ class TodoItem(Base):
user_id = Column('user_id', Integer, ForeignKey('users.id', ondelete="CASCADE"), nullable=False, index=True)
user = relationship(User, back_populates="todo_items", uselist=False)
class SortableTodoItemField(enum.Enum):
"""Defines which fields todo-item lists can be sorted on."""
id = 'id'
title = 'title'
done = 'done'
created = 'created'
updated = 'updated'
finished = 'finished'
@property
def field(self) -> Column:
return getattr(TodoItem, self.value)

View file

@ -1,8 +1,6 @@
"""This module defines the SQL data model for users."""
import enum
from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy import Column, Integer, String, DateTime, Boolean
from sqlalchemy.sql.functions import now
from sqlalchemy.orm import relationship
@ -21,20 +19,6 @@ class User(Base):
updated = Column('updated', DateTime(timezone=True), nullable=False, server_default=now(), onupdate=now())
first_name = Column('first_name', String, nullable=False)
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")
class SortableUserField(enum.Enum):
"""Defines which fields user lists can be sorted on."""
id = 'id'
email = 'email'
created = 'created'
updated = 'updated'
first_name = 'first_name'
last_name = 'last_name'
@property
def field(self) -> Column:
return getattr(User, self.value)

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,19 +1,21 @@
"""This module contains endpoints for operations related to users."""
"""This module contains endpoints for operations related to todo-items."""
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 todos as todoschema
from todo.schemas import users as userschema
from todo.crud import todos as todocrud
from todo.utils.exceptions import NotFoundException, InvalidFilterParameterException
from todo.utils.exceptions import create_exception_dict as fmt
from todo.models.todos import SortableTodoItemField
from todo.models.common import SortOrder
from todo.dependencies.todos import TodoItemSortablePaginationParams
import todo.auth.auth as auth
router = APIRouter(
prefix="/todo",
prefix="/todos",
tags=["todo-items"]
)
@ -22,9 +24,19 @@ tag_metadata = {
"description": "Operations related to todo items."
}
auth_handler = auth.AuthHandler()
@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:
return todocrud.create_todo(db=db, todo=todo, user_id=user_id)
except NotFoundException as e:
@ -32,23 +44,41 @@ def create_todo(todo: todoschema.TodoItemCreate, user_id: int, db: Session = Dep
@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:
return todocrud.read_todo(db=db, todo_id=todo_id)
todo = todocrud.read_todo(db=db, todo_id=todo_id)
except NotFoundException as 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.")
return todo
@router.get("/user/{user_id}", response_model=list[todoschema.TodoItem])
def read_todos(
user_id: int,
skip: int = 0, limit: int = 100,
sortby: SortableTodoItemField = SortableTodoItemField['updated'],
sortorder: SortOrder = SortOrder['desc'],
db: Session = Depends(get_db)
commons: Annotated[TodoItemSortablePaginationParams, Depends(TodoItemSortablePaginationParams)],
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:
return todocrud.read_todos_for_user(db=db, user_id=user_id, skip=skip, limit=limit, sortby=sortby, sortorder=sortorder)
return todocrud.read_todos_for_user(
db=db,
user_id=user_id,
skip=commons.skip, limit=commons.limit,
sortby=commons.sortby, sortorder=commons.sortorder,
)
except InvalidFilterParameterException as e:
raise HTTPException(400, fmt(str(e)))
except NotFoundException as e:
@ -56,7 +86,14 @@ def read_todos(
@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:
return {"total": todocrud.read_todos_count_for_user(db=db, user_id=user_id)}
except NotFoundException as e:
@ -64,16 +101,33 @@ def read_todos_count(user_id: int, db: Session = Depends(get_db)):
@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:
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)
except NotFoundException as e:
raise HTTPException(404, fmt(str(e)))
@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:
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)
except NotFoundException as e:
raise HTTPException(404, fmt(str(e)))

View file

@ -1,5 +1,7 @@
"""This module contains endpoints for operations related to users."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
@ -8,8 +10,8 @@ from todo.schemas import users as userschema
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.models.common import SortOrder
from todo.models.users import SortableUserField
from todo.dependencies.users import UserSortablePaginationParams
import todo.auth.auth as auth
router = APIRouter(
@ -22,54 +24,72 @@ tag_metadata = {
"description": "Operations related to users."
}
auth_handler = auth.AuthHandler()
@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:
# 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)
raise HTTPException(400, "A user with this email address is already registered.")
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)
@router.get("/{id}", response_model=userschema.User)
def read_user(id: int, db: Session = Depends(get_db)):
@router.get("/{user_id}", response_model=userschema.User)
def read_user(
user_id: int,
db: Session = Depends(get_db),
current_user: userschema.User = Depends(auth_handler.get_current_user),
):
try:
return usercrud.read_user(db=db, id=id)
user = usercrud.read_user(db=db, id=user_id)
except NotFoundException as e:
raise HTTPException(404, fmt(str(e)))
@router.get("/", response_model=list[userschema.User])
def read_users(
skip: int = 0, limit: int = 100,
sortby: SortableUserField = SortableUserField['id'],
sortorder: SortOrder = SortOrder['asc'],
db: Session = Depends(get_db)
):
try:
return usercrud.read_users(db=db, skip=skip, limit=limit, sortby=sortby, sortorder=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)}
if current_user.is_admin or current_user.id == user_id:
return user
raise HTTPException(403, "You are not authorized to view this content.")
@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:
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:
raise HTTPException(404, fmt(str(e)))
@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:
return usercrud.delete_user(db=db, id=id)
return usercrud.delete_user(db=db, id=user_id)
except NotFoundException as 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

@ -0,0 +1,27 @@
"""This module contains common schemas and common functions for schema validation."""
from pydantic import BaseModel, validator
def is_valid_id(id: int) -> bool:
"""Checks whether the specified id is a valid ID.
Performs a shallow check on whether the ID is a valid primary key.
"""
if not isinstance(id, int):
raise TypeError(f"Expected an integer but got: {type(id)}")
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

@ -6,6 +6,8 @@ from typing import Optional
from pydantic import BaseModel, validator
from todo.schemas.common import is_valid_id
class AbstractTodoItemValidator(BaseModel, ABC):
"""Base class for todo-item validators shared with child classes."""
@ -47,7 +49,7 @@ class TodoItemUpdate(AbstractTodoItemValidator):
class TodoItem(AbstractTodoItem):
"""Schema for user info displaying."""
"""Schema for todo-item displaying."""
id: int
done: bool
@ -55,5 +57,11 @@ class TodoItem(AbstractTodoItem):
updated: datetime
finished: datetime | None
@validator('id')
def assert_id_is_valid(cls, id):
if not is_valid_id(id):
raise ValueError("ID is invalid.")
return id
class Config:
orm_mode = True

View file

@ -7,6 +7,8 @@ from re import compile
from pydantic import BaseModel, validator
from todo.schemas.common import is_valid_id
def is_valid_email_format(input_str: str) -> bool:
"""Checks whether the input string is a valid email address format.
@ -55,6 +57,14 @@ class AbstractUser(AbstractUserValidator, ABC):
email: str
first_name: str
last_name: str
is_admin: bool
class UserLogin(AbstractUserValidator, ABC):
"""Schema used during user login"""
email: str
password: str
class UserCreate(AbstractUser):
@ -83,6 +93,7 @@ class UserUpdate(AbstractUserValidator):
email: Optional[str]
first_name: Optional[str]
last_name: Optional[str]
is_admin: Optional[bool]
password: Optional[str] = None
password_confirmation: Optional[str] = None
@ -106,5 +117,11 @@ class User(AbstractUser):
created: datetime
updated: datetime
@validator('id')
def assert_id_is_valid(cls, id):
if not is_valid_id(id):
raise ValueError("ID is invalid.")
return id
class Config:
orm_mode = True

View file

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

View file

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

View file

@ -8,11 +8,16 @@
"name": "fastapi-svelte-template",
"version": "0.0.1",
"dependencies": {
"dotenv": "^16.0.3"
"dompurify": "^3.0.3",
"dotenv": "^16.0.3",
"jwt-decode": "^3.1.2",
"marked": "^5.0.4"
},
"devDependencies": {
"@sveltejs/adapter-node": "^1.2.4",
"@sveltejs/kit": "^1.5.0",
"@types/dompurify": "^3.0.2",
"@types/marked": "^5.0.0",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.23",
"sass": "^1.62.1",
@ -2027,12 +2032,27 @@
"integrity": "sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==",
"dev": true
},
"node_modules/@types/dompurify": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.2.tgz",
"integrity": "sha512-YBL4ziFebbbfQfH5mlC+QTJsvh0oJUrWbmxKMyEdL7emlHJqGR2Qb34TEFKj+VCayBvjKy3xczMFNhugThUsfQ==",
"dev": true,
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz",
"integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==",
"dev": true
},
"node_modules/@types/marked": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.0.tgz",
"integrity": "sha512-YcZe50jhltsCq7rc9MNZC/4QB/OnA2Pd6hrOSTOFajtabN+38slqgDDCeE/0F83SjkKBQcsZUj7VLWR0H5cKRA==",
"dev": true
},
"node_modules/@types/pug": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.6.tgz",
@ -2045,6 +2065,12 @@
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
"dev": true
},
"node_modules/@types/trusted-types": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz",
"integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==",
"dev": true
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@ -2370,6 +2396,11 @@
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"dev": true
},
"node_modules/dompurify": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.0.3.tgz",
"integrity": "sha512-axQ9zieHLnAnHh0sfAamKYiqXMJAVwu+LM/alQ7WDagoWessyWvMSFyW65CqF3owufNu8HBcE4cM2Vflu7YWcQ=="
},
"node_modules/dotenv": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz",
@ -2716,6 +2747,11 @@
"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": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@ -2761,6 +2797,17 @@
"node": ">=12"
}
},
"node_modules/marked": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/marked/-/marked-5.0.4.tgz",
"integrity": "sha512-r0W8/DK56fAkV0qfUCO9cEt/VlFWUzoJOqEigvijmsVkTuPOHckh7ZutNJepRO1AxHhK96/9txonHg4bWd/aLA==",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -5648,12 +5695,27 @@
"integrity": "sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==",
"dev": true
},
"@types/dompurify": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.2.tgz",
"integrity": "sha512-YBL4ziFebbbfQfH5mlC+QTJsvh0oJUrWbmxKMyEdL7emlHJqGR2Qb34TEFKj+VCayBvjKy3xczMFNhugThUsfQ==",
"dev": true,
"requires": {
"@types/trusted-types": "*"
}
},
"@types/estree": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz",
"integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==",
"dev": true
},
"@types/marked": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.0.tgz",
"integrity": "sha512-YcZe50jhltsCq7rc9MNZC/4QB/OnA2Pd6hrOSTOFajtabN+38slqgDDCeE/0F83SjkKBQcsZUj7VLWR0H5cKRA==",
"dev": true
},
"@types/pug": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.6.tgz",
@ -5666,6 +5728,12 @@
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
"dev": true
},
"@types/trusted-types": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz",
"integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==",
"dev": true
},
"any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@ -5874,6 +5942,11 @@
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"dev": true
},
"dompurify": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.0.3.tgz",
"integrity": "sha512-axQ9zieHLnAnHh0sfAamKYiqXMJAVwu+LM/alQ7WDagoWessyWvMSFyW65CqF3owufNu8HBcE4cM2Vflu7YWcQ=="
},
"dotenv": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz",
@ -6145,6 +6218,11 @@
"integrity": "sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg==",
"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": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@ -6181,6 +6259,11 @@
"@jridgewell/sourcemap-codec": "^1.4.13"
}
},
"marked": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/marked/-/marked-5.0.4.tgz",
"integrity": "sha512-r0W8/DK56fAkV0qfUCO9cEt/VlFWUzoJOqEigvijmsVkTuPOHckh7ZutNJepRO1AxHhK96/9txonHg4bWd/aLA=="
},
"merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",

View file

@ -12,6 +12,8 @@
"devDependencies": {
"@sveltejs/adapter-node": "^1.2.4",
"@sveltejs/kit": "^1.5.0",
"@types/dompurify": "^3.0.2",
"@types/marked": "^5.0.0",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.23",
"sass": "^1.62.1",
@ -26,6 +28,9 @@
},
"type": "module",
"dependencies": {
"dotenv": "^16.0.3"
"dompurify": "^3.0.3",
"dotenv": "^16.0.3",
"jwt-decode": "^3.1.2",
"marked": "^5.0.4"
}
}

View file

@ -30,6 +30,14 @@
h6 {
@apply text-lg;
}
a {
@apply underline text-secondary-500;
}
a:visited {
@apply text-secondary-700;
}
}
@layer components {
@ -60,7 +68,6 @@
table {
@apply border-collapse;
}
th:first-of-type {
@apply rounded-tl-md;
}
@ -73,7 +80,6 @@
tr:last-of-type td:last-of-type {
@apply rounded-br-md;
}
tr {
@apply hover:bg-secondary-300/50;
@apply border-y border-primary/50;
@ -82,7 +88,6 @@
tr:first-of-type, tr:last-of-type {
@apply border-y-0;
}
th {
@apply bg-primary font-semibold text-secondary-100;
}
@ -94,4 +99,24 @@
@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,182 @@
import { error } from '@sveltejs/kit';
import type { User } from './types';
import type { ItemCount, TodoItem, User } from './types';
import { getResponseBodyOrError } from '$lib/api/utils';
import { getTokenFromLocalstorage } from '$lib/auth/session';
const globalPrefix = '/api';
const usersPrefix = `${globalPrefix}/users`;
export async function readUser(fetch: CallableFunction, userId: number): Promise<User> {
const endpoint = `${usersPrefix}/${userId}`;
const response = await fetch(endpoint);
if (!response.ok) {
throw error(response.status, response.statusText);
/**
* Retrieves the currently logged in user's JWT from localstorage,
* or throws an error if it is not present.
*
* @throws {error} - If the current user is not logged in or if their JWT is not saved in localstorage.
*/
function getTokenOrError(): string {
let token = getTokenFromLocalstorage();
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;
}
/**
* 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(
skip: number,
limit: number,
sortby: string,
sortorder: string,
jwt: string = getTokenOrError(),
): Promise<User[]> {
const urlParameters = new URLSearchParams({
skip: `${skip}`,
limit: `${limit}`,
sortby: `${sortby}`,
sortorder: `${sortorder}`,
});
const endpoint = `/api/admin/users/?${urlParameters}`;
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;
}
/**
* Retrieves the list of all todo-items for the user with the specified ID 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 readTodos(
userId: number,
skip: number,
limit: number,
sortby: string,
sortorder: string,
jwt: string = getTokenOrError(),
): Promise<TodoItem[]> {
const urlParameters = new URLSearchParams({
skip: `${skip}`,
limit: `${limit}`,
sortby: `${sortby}`,
sortorder: `${sortorder}`,
});
const endpoint = `/api/todos/user/${userId}?${urlParameters}`;
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 TodoItem[];
}
/**
* Retrieves the total todo-item count for the user with the specified ID 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 readTodoCount(
userId: number,
jwt: string = getTokenOrError()
): Promise<number> {
const endpoint = `/api/todos/user/${userId}/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;
}
/**
* Retrieves the todo-item with the specified ID 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 readTodo(
todoId: number,
jwt: string = getTokenOrError()
): Promise<TodoItem> {
const endpoint = `/api/todos/${todoId}`;
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 TodoItem;
}

View file

@ -1,7 +1,23 @@
export type ItemCount = {
total: number
}
export type User = {
id: number,
email: string,
first_name: string,
last_ame: string,
last_name: string,
created: Date,
updated: Date
updated: Date,
is_admin: boolean,
}
export type TodoItem = {
id: number,
title: string,
description: string,
done: boolean,
created: Date,
updated: Date,
finished: Date,
}

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

@ -1,33 +1,35 @@
<script lang='ts'>
import { onMount } from 'svelte';
import { Icon, ChevronRight, ChevronLeft, ChevronDoubleRight, ChevronDoubleLeft } from 'svelte-hero-icons';
import Th from '$lib/data-table/Th.svelte';
import Td from '$lib/data-table/Td.svelte';
import Th from '$lib/components/data-table/Th.svelte';
import Td from '$lib/components/data-table/Td.svelte';
import type { Column, Endpoint } from '$lib/components/data-table/types';
import { interpolateString as i } from '$lib/components/data-table/utils';
/**
* The caption for the data table.
*/
export let caption: string | null = null;
/**
* The columns for the data table.
*/
export let columns: Column[];
export let itemCountEndpoint: string;
export let itemsEndpoint: string;
type Column = {
field: string,
heading: string,
type?: string,
sortable?: boolean,
};
type TotalItemCountApiResponse = {
total: number
};
// API error message bodies are expected to be in one of the following formats:
interface ApiErrorMessage {
detail: string
};
interface ApiErrorList {
detail: { msg: string; loc?: string[]; type?: string }[]
};
/**
* The function which fetches rows for the data table.
* Must support pagination and sorting.
*/
export let getItemsEndpoint: Endpoint = {
callable: () => [],
args: [],
}
/**
* The function which fetches the total number of items. Used for pagination.
*/
export let getItemCountEndpoint: Endpoint = {
callable: () => 0,
args: [],
}
type SortEvent = {
detail: {
@ -117,66 +119,19 @@
await updateTable();
}
async function getTotalItemCount(): Promise<number> {
const response = await fetch(itemCountEndpoint);
const json = await response.json();
if (!response.ok) {
// Try to parse error body as a known format
if ('detail' in json) {
if (typeof json.detail === 'string') {
const errorBody = json as ApiErrorMessage;
errorMessages.push(errorBody.detail);
} else {
const errorBody = json as ApiErrorList;
for (let detail of errorBody.detail) {
if (typeof detail.msg === 'string') errorMessages.push(detail.msg);
}
}
}
throw new Error(`API request failed with status ${response.status}: ${response.statusText}`);
}
const apiResponse = json as TotalItemCountApiResponse;
return apiResponse.total;
}
async function getItems<T>(offset: number = currentItemsOffset, count: number = currentItemsPerPage): Promise<T[]> {
const urlParameters = new URLSearchParams({
skip: `${offset}`,
limit: `${count}`,
sortby: `${currentSortField}`,
sortorder: `${currentSortOrder}`,
});
const response = await fetch(`${itemsEndpoint}?${urlParameters}`);
const json = await response.json();
if (!response.ok) {
// Try to parse error body as a known format
if ('detail' in json) {
if (typeof json.detail === 'string') {
const errorBody = json as ApiErrorMessage;
errorMessages.push(errorBody.detail);
} else {
const errorBody = json as ApiErrorList;
for (let detail of errorBody.detail) {
if (typeof detail.msg === 'string') errorMessages.push(detail.msg);
}
}
}
throw new Error(`API request failed with status ${response.status}: ${response.statusText}`);
}
const apiResponse = json as T[];
return apiResponse;
}
async function updateTable() {
try {
currentState = 'loading';
totalItemCount = await getTotalItemCount();
currentItems = await getItems();
totalItemCount = await getItemCountEndpoint.callable(
...getItemCountEndpoint.args,
);
currentItems = await getItemsEndpoint.callable(
...getItemsEndpoint.args,
currentItemsOffset,
currentItemsPerPage,
currentSortField,
currentSortOrder
);
currentState = 'finished';
} catch (error: any) {
if (!errorMessages.length) {
@ -229,6 +184,8 @@
<Td
data={item[column.field]}
type={column.type ?? 'string'}
isLink={column.isLink ?? false}
linkTarget={i(column.linkTarget, item) ?? ''}
/>
{/each}
</tr>

View file

@ -3,6 +3,8 @@
export let data: any;
export let type: 'string' | 'date' | 'boolean' = 'string';
export let isLink: boolean = false;
export let linkTarget: string = "";
</script>
<td>
@ -18,7 +20,11 @@
{:else}
<Icon src={XMark} mini class="w-6 h-6" />
{/if}
{:else}
{#if isLink}
<a href={linkTarget}>{data}</a>
{:else}
{data}
{/if}
{/if}
</td>

View file

@ -0,0 +1,13 @@
export type Column = {
field: string,
heading: string,
type?: string,
sortable?: boolean,
isLink?: boolean,
linkTarget?: string,
}
export type Endpoint = {
callable: Function,
args: any[],
}

View file

@ -0,0 +1,21 @@
/**
* Replaces placeholders in a template string with values from a replacement items object.
*
* The placeholders are in the format %key%, whereby %key% is replaced with the value of the 'key'
* attribute in the replacementItems object.
*
* @param {string} templateString - The template string containing placeholders to be replaced.
* @param {object} replacementItems - The object containing key-value pairs for replacement.
* @returns {string} The modified string with all placeholders replaced.
*/
export function interpolateString(templateString: string, replacementItems: { [key: string]: any }): string {
if (typeof templateString !== 'string') {
return '';
}
return templateString.replace(/%(\w+)%/g, (match, key) => {
if (replacementItems.hasOwnProperty(key)) {
return replacementItems[key].toString();
}
return match;
});
}

View file

@ -0,0 +1,33 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { logout, storedUser } from '$lib/auth/session';
import type { StoredUser } from '$lib/auth/session';
let user: StoredUser | null = null;
storedUser.subscribe((value) => {
user = value;
});
function handleLogout() {
logout();
goto('/');
}
</script>
<nav>
<ul>
<li class="inline">
<a href="/">Home</a>
{#if user}
<a href={`/users/${user.id}/todos`}>Todos</a>
<a href={`/users/${user.id}`}>My Profile</a>
{#if user.isAdmin}
<a href={`/users/all`}>All Users</a>
{/if}
<button on:click={handleLogout}>Log Out</button>
{:else}
<a href="/login">Log In</a>
{/if}
</li>
</ul>
</nav>

View file

@ -0,0 +1,18 @@
import { marked } from 'marked';
import dompurify from 'dompurify';
/**
* Converts the input text, which is assumed to be in markdown format, into HTML and returns it.
* The returned HTML is sanitized to prevent XSS.
*
* @param {string} text - The input text in markdown format to convert.
* @returns {string} The HTML representation of the input text.
*/
export function markdownToHtml(text: string): string {
// Remove zerowidth chars from beginning of input string
// This is probably not necessary...
text = text.replace(/^[\u200B\u200C\u200D\u200E\u200F\uFEFF]/, "");
// Return markdown parsed as HTML and subsequently sanitized
return dompurify.sanitize(marked.parse(text));
}

View file

@ -22,14 +22,22 @@
</svelte:head>
<script lang="ts">
import { onMount } from 'svelte';
import "../app.css";
import { page } from '$app/stores';
import { loadUserIntoStore } from '$lib/auth/session';
import Navbar from '$lib/components/navbar/Navbar.svelte';
export let title = import.meta.env.PUBLIC_TITLE;
export let description = import.meta.env.PUBLIC_DESCRIPTION;
export let author = import.meta.env.PUBLIC_AUTHOR;
const ogImageUrl = new URL('/images/common/og-image.webp', import.meta.url).href
onMount(async () => {
loadUserIntoStore();
});
</script>
<Navbar />
<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

@ -0,0 +1,11 @@
<script lang="ts">
import type { TodoDetailPage } from './+page';
import { markdownToHtml } from '$lib/utils/markdown';
export let data: TodoDetailPage;
</script>
<h1>{data.todo.title}</h1>
<div>
{@html markdownToHtml(data.todo.description)}
</div>

View file

@ -0,0 +1,18 @@
import type { PageLoad } from './$types';
import type { TodoItem } from '$lib/api/types';
import { readTodo } from '$lib/api/endpoints';
export const ssr = false;
export const load = (async ({ params }) => {
// check if user exists
const todoId = params.todo;
const todo = await readTodo(todoId);
return {
todo: todo
};
}) satisfies PageLoad;
export interface TodoDetailPage {
todo: TodoItem
}

View file

@ -1,39 +0,0 @@
<script lang="ts">
import Table from '$lib/data-table/Table.svelte'
const table = {
caption: "List of users",
columns: [
{
field: "id",
heading: "ID",
},
{
field: "email",
heading: "Email",
},
{
field: "first_name",
heading: "First Name",
},
{
field: "last_name",
heading: "Last Name",
},
{
field: "created",
heading: "Created",
type: 'date',
},
{
field: "updated",
heading: "Updated",
type: 'date',
},
],
itemCountEndpoint: "/api/users/total/",
itemsEndpoint: "/api/users/",
};
</script>
<Table {...table} />

View file

@ -5,3 +5,4 @@
</script>
<h1>Profile page for {data.user.email}</h1>
<a href={`/users/${data.user.id}/todos`}>{data.user.first_name}'s Todos</a>

View file

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

View file

@ -1,11 +1,12 @@
<script lang="ts">
import Table from '$lib/data-table/Table.svelte'
import type { Column } from '$lib/data-table/Table.svelte';
import Table from '$lib/components/data-table/Table.svelte'
import type { Column } from '$lib/components/data-table/types';
import type { UserTodosPage } from "./+page";
import { readTodos, readTodoCount } from '$lib/api/endpoints';
export let data: UserTodosPage;
const cols: Column[] = [
const columns: Column[] = [
{
field: "id",
heading: "ID",
@ -13,11 +14,8 @@
{
field: "title",
heading: "Title",
},
{
field: "description",
heading: "Description",
sortable: false,
isLink: true,
linkTarget: '/todos/%id%',
},
{
field: "done",
@ -43,9 +41,19 @@
const table = {
caption: `List of TODOs for user ${data.user.first_name} ${data.user.last_name}`,
columns: cols,
itemsEndpoint: `/api/todo/user/${data.user.id}/`,
itemCountEndpoint: `/api/todo/user/${data.user.id}/total/`,
columns: columns,
getItemsEndpoint: {
callable: readTodos,
args: [
data.user.id
]
},
getItemCountEndpoint: {
callable: readTodoCount,
args: [
data.user.id
]
}
};
</script>

View file

@ -0,0 +1,56 @@
<script lang="ts">
import Table from '$lib/components/data-table/Table.svelte';
import type { Column } from '$lib/components/data-table/types';
import { readUsers, readUserCount } from '$lib/api/endpoints';
const columns: Column[] = [
{
field: "id",
heading: "ID",
},
{
field: "email",
heading: "Email",
isLink: true,
linkTarget: '/users/%id%',
},
{
field: "first_name",
heading: "First Name",
},
{
field: "last_name",
heading: "Last Name",
},
{
field: "created",
heading: "Created",
type: 'date',
},
{
field: "updated",
heading: "Updated",
type: 'date',
},
{
field: "is_admin",
heading: "Admin",
type: 'boolean',
},
]
const table = {
caption: "List of users",
columns: columns,
getItemsEndpoint: {
callable: readUsers,
args: []
},
getItemCountEndpoint: {
callable: readUserCount,
args: []
}
};
</script>
<Table {...table} />

View file

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