diff --git a/backend/README.md b/backend/README.md index 3581ec8..ea87647 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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. diff --git a/backend/requirements.txt b/backend/requirements.txt index 090495d..2d2c289 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/todo/auth/auth.py b/backend/todo/auth/auth.py new file mode 100644 index 0000000..db3ba57 --- /dev/null +++ b/backend/todo/auth/auth.py @@ -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.") diff --git a/backend/todo/config.py b/backend/todo/config.py index aa3abcf..5eccf82 100644 --- a/backend/todo/config.py +++ b/backend/todo/config.py @@ -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: diff --git a/backend/todo/crud/todos.py b/backend/todo/crud/todos.py index 03f1bdf..d0f751f 100644 --- a/backend/todo/crud/todos.py +++ b/backend/todo/crud/todos.py @@ -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.""" diff --git a/backend/todo/crud/users.py b/backend/todo/crud/users.py index 0b450d2..d023817 100644 --- a/backend/todo/crud/users.py +++ b/backend/todo/crud/users.py @@ -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) diff --git a/backend/todo/database/migrations/versions/335e07a98bc8_create_user_tables.py b/backend/todo/database/migrations/versions/335e07a98bc8_create_user_tables.py index b4fbc35..420824f 100644 --- a/backend/todo/database/migrations/versions/335e07a98bc8_create_user_tables.py +++ b/backend/todo/database/migrations/versions/335e07a98bc8_create_user_tables.py @@ -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), ) diff --git a/backend/todo/dependencies/common.py b/backend/todo/dependencies/common.py new file mode 100644 index 0000000..21719d9 --- /dev/null +++ b/backend/todo/dependencies/common.py @@ -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 diff --git a/backend/todo/dependencies/todos.py b/backend/todo/dependencies/todos.py new file mode 100644 index 0000000..5d41c32 --- /dev/null +++ b/backend/todo/dependencies/todos.py @@ -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 diff --git a/backend/todo/dependencies/users.py b/backend/todo/dependencies/users.py new file mode 100644 index 0000000..dc14147 --- /dev/null +++ b/backend/todo/dependencies/users.py @@ -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 diff --git a/backend/todo/main.py b/backend/todo/main.py index df9b284..a607321 100644 --- a/backend/todo/main.py +++ b/backend/todo/main.py @@ -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/") diff --git a/backend/todo/models/common.py b/backend/todo/models/common.py deleted file mode 100644 index ba3a9ab..0000000 --- a/backend/todo/models/common.py +++ /dev/null @@ -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.") diff --git a/backend/todo/models/todos.py b/backend/todo/models/todos.py index 388ad8e..5c046da 100644 --- a/backend/todo/models/todos.py +++ b/backend/todo/models/todos.py @@ -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) diff --git a/backend/todo/models/users.py b/backend/todo/models/users.py index eda1682..91e2bc7 100644 --- a/backend/todo/models/users.py +++ b/backend/todo/models/users.py @@ -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) diff --git a/backend/todo/routes/admin.py b/backend/todo/routes/admin.py new file mode 100644 index 0000000..78140ab --- /dev/null +++ b/backend/todo/routes/admin.py @@ -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)) diff --git a/backend/todo/routes/auth.py b/backend/todo/routes/auth.py new file mode 100644 index 0000000..c640793 --- /dev/null +++ b/backend/todo/routes/auth.py @@ -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)) diff --git a/backend/todo/routes/todos.py b/backend/todo/routes/todos.py index 79e872d..a17eed1 100644 --- a/backend/todo/routes/todos.py +++ b/backend/todo/routes/todos.py @@ -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))) diff --git a/backend/todo/routes/users.py b/backend/todo/routes/users.py index f93a303..0a07c90 100644 --- a/backend/todo/routes/users.py +++ b/backend/todo/routes/users.py @@ -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: - return usercrud.create_user(db=db, user=user) + 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))) diff --git a/backend/todo/schemas/auth.py b/backend/todo/schemas/auth.py new file mode 100644 index 0000000..c673f5e --- /dev/null +++ b/backend/todo/schemas/auth.py @@ -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 diff --git a/backend/todo/schemas/common.py b/backend/todo/schemas/common.py new file mode 100644 index 0000000..348ffd2 --- /dev/null +++ b/backend/todo/schemas/common.py @@ -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 diff --git a/backend/todo/schemas/todos.py b/backend/todo/schemas/todos.py index 550ff24..d34fc2b 100644 --- a/backend/todo/schemas/todos.py +++ b/backend/todo/schemas/todos.py @@ -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 diff --git a/backend/todo/schemas/users.py b/backend/todo/schemas/users.py index 310246e..7b3cd85 100644 --- a/backend/todo/schemas/users.py +++ b/backend/todo/schemas/users.py @@ -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 diff --git a/backend/todo/utils/fakery.py b/backend/todo/utils/fakery.py index 32db390..3f6e387 100644 --- a/backend/todo/utils/fakery.py +++ b/backend/todo/utils/fakery.py @@ -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) diff --git a/development.docker-compose.yml b/development.docker-compose.yml index f4fd1b3..77c20e7 100644 --- a/development.docker-compose.yml +++ b/development.docker-compose.yml @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 943271b..b2da448 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 2ddbf3b..57479f7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } } diff --git a/frontend/src/app.css b/frontend/src/app.css index 9f1f5dd..4ca388e 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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; + } } diff --git a/frontend/src/lib/api/endpoints.ts b/frontend/src/lib/api/endpoints.ts index 0d95b7e..462460a 100644 --- a/frontend/src/lib/api/endpoints.ts +++ b/frontend/src/lib/api/endpoints.ts @@ -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 { - 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 { + 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 { + + 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 { + 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 { + + 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 { + 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 { + 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; +} diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 87a804d..7ee7cc6 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -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, } diff --git a/frontend/src/lib/api/utils.ts b/frontend/src/lib/api/utils.ts new file mode 100644 index 0000000..384506d --- /dev/null +++ b/frontend/src/lib/api/utils.ts @@ -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 { + 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; +} diff --git a/frontend/src/lib/auth/session.ts b/frontend/src/lib/auth/session.ts new file mode 100644 index 0000000..04423c9 --- /dev/null +++ b/frontend/src/lib/auth/session.ts @@ -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 { + 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 { + 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(); +} diff --git a/frontend/src/lib/data-table/Table.svelte b/frontend/src/lib/components/data-table/Table.svelte similarity index 70% rename from frontend/src/lib/data-table/Table.svelte rename to frontend/src/lib/components/data-table/Table.svelte index 9adcf21..5bec6ce 100644 --- a/frontend/src/lib/data-table/Table.svelte +++ b/frontend/src/lib/components/data-table/Table.svelte @@ -1,33 +1,35 @@ @@ -19,6 +21,10 @@ {/if} {:else} - {data} + {#if isLink} + {data} + {:else} + {data} + {/if} {/if} diff --git a/frontend/src/lib/data-table/Th.svelte b/frontend/src/lib/components/data-table/Th.svelte similarity index 100% rename from frontend/src/lib/data-table/Th.svelte rename to frontend/src/lib/components/data-table/Th.svelte diff --git a/frontend/src/lib/components/data-table/types.ts b/frontend/src/lib/components/data-table/types.ts new file mode 100644 index 0000000..b741f5c --- /dev/null +++ b/frontend/src/lib/components/data-table/types.ts @@ -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[], +} diff --git a/frontend/src/lib/components/data-table/utils.ts b/frontend/src/lib/components/data-table/utils.ts new file mode 100644 index 0000000..a5270fb --- /dev/null +++ b/frontend/src/lib/components/data-table/utils.ts @@ -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; + }); +} diff --git a/frontend/src/lib/components/navbar/Navbar.svelte b/frontend/src/lib/components/navbar/Navbar.svelte new file mode 100644 index 0000000..e428363 --- /dev/null +++ b/frontend/src/lib/components/navbar/Navbar.svelte @@ -0,0 +1,33 @@ + + + diff --git a/frontend/src/lib/utils/markdown.ts b/frontend/src/lib/utils/markdown.ts new file mode 100644 index 0000000..3bd238d --- /dev/null +++ b/frontend/src/lib/utils/markdown.ts @@ -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)); +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index c18923b..0c4d902 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -22,14 +22,22 @@ + diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte new file mode 100644 index 0000000..50bb4f6 --- /dev/null +++ b/frontend/src/routes/login/+page.svelte @@ -0,0 +1,61 @@ + + +
+

Log In

+
+
+ Please enter your login credentials +
+ + +
+
+ + +
+ + {#if formError} +

{errorMessage}

+ {/if} +
+
+
diff --git a/frontend/src/routes/todos/[todo]/+page.svelte b/frontend/src/routes/todos/[todo]/+page.svelte new file mode 100644 index 0000000..9b59df5 --- /dev/null +++ b/frontend/src/routes/todos/[todo]/+page.svelte @@ -0,0 +1,11 @@ + + +

{data.todo.title}

+
+ {@html markdownToHtml(data.todo.description)} +
diff --git a/frontend/src/routes/todos/[todo]/+page.ts b/frontend/src/routes/todos/[todo]/+page.ts new file mode 100644 index 0000000..58c0709 --- /dev/null +++ b/frontend/src/routes/todos/[todo]/+page.ts @@ -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 +} diff --git a/frontend/src/routes/user/all/+page.svelte b/frontend/src/routes/user/all/+page.svelte deleted file mode 100644 index f192861..0000000 --- a/frontend/src/routes/user/all/+page.svelte +++ /dev/null @@ -1,39 +0,0 @@ - - - diff --git a/frontend/src/routes/user/[user]/+page.svelte b/frontend/src/routes/users/[user]/+page.svelte similarity index 67% rename from frontend/src/routes/user/[user]/+page.svelte rename to frontend/src/routes/users/[user]/+page.svelte index 1c8cc9c..86d2ff0 100644 --- a/frontend/src/routes/user/[user]/+page.svelte +++ b/frontend/src/routes/users/[user]/+page.svelte @@ -5,3 +5,4 @@

Profile page for {data.user.email}

+{data.user.first_name}'s Todos diff --git a/frontend/src/routes/user/[user]/+page.ts b/frontend/src/routes/users/[user]/+page.ts similarity index 50% rename from frontend/src/routes/user/[user]/+page.ts rename to frontend/src/routes/users/[user]/+page.ts index 9b0f3d6..93861a6 100644 --- a/frontend/src/routes/user/[user]/+page.ts +++ b/frontend/src/routes/users/[user]/+page.ts @@ -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 }; diff --git a/frontend/src/routes/user/[user]/todos/+page.svelte b/frontend/src/routes/users/[user]/todos/+page.svelte similarity index 59% rename from frontend/src/routes/user/[user]/todos/+page.svelte rename to frontend/src/routes/users/[user]/todos/+page.svelte index 872534a..d4d871c 100644 --- a/frontend/src/routes/user/[user]/todos/+page.svelte +++ b/frontend/src/routes/users/[user]/todos/+page.svelte @@ -1,11 +1,12 @@ diff --git a/frontend/src/routes/user/[user]/todos/+page.ts b/frontend/src/routes/users/[user]/todos/+page.ts similarity index 100% rename from frontend/src/routes/user/[user]/todos/+page.ts rename to frontend/src/routes/users/[user]/todos/+page.ts diff --git a/frontend/src/routes/users/all/+page.svelte b/frontend/src/routes/users/all/+page.svelte new file mode 100644 index 0000000..57d5925 --- /dev/null +++ b/frontend/src/routes/users/all/+page.svelte @@ -0,0 +1,56 @@ + + +
diff --git a/production.docker-compose.yml b/production.docker-compose.yml index f3d6422..cd3607c 100644 --- a/production.docker-compose.yml +++ b/production.docker-compose.yml @@ -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