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 bbb56df..d0f751f 100644 --- a/backend/todo/crud/todos.py +++ b/backend/todo/crud/todos.py @@ -7,6 +7,7 @@ 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.dependencies.common import SortOrder from todo.dependencies.todos import SortableTodoItemField @@ -65,6 +66,19 @@ def read_todos_for_user( return [todoschema.TodoItem.from_orm(db_todo) for db_todo in db_todos] +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 1be672f..d023817 100644 --- a/backend/todo/crud/users.py +++ b/backend/todo/crud/users.py @@ -9,17 +9,8 @@ from todo.schemas import users as userschema 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: @@ -29,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) @@ -58,6 +50,19 @@ 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, @@ -90,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/users.py b/backend/todo/dependencies/users.py index c3cca43..dc14147 100644 --- a/backend/todo/dependencies/users.py +++ b/backend/todo/dependencies/users.py @@ -17,6 +17,7 @@ class SortableUserField(Enum): updated = 'updated' first_name = 'first_name' last_name = 'last_name' + is_admin = 'is_admin' @property def field(self) -> Column: 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/users.py b/backend/todo/models/users.py index 0688ea0..91e2bc7 100644 --- a/backend/todo/models/users.py +++ b/backend/todo/models/users.py @@ -1,6 +1,6 @@ """This module defines the SQL data model for users.""" -from sqlalchemy import Column, Integer, String, DateTime +from sqlalchemy import Column, Integer, String, DateTime, Boolean from sqlalchemy.sql.functions import now from sqlalchemy.orm import relationship @@ -19,5 +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") 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 f25e64a..e7f7249 100644 --- a/backend/todo/routes/todos.py +++ b/backend/todo/routes/todos.py @@ -1,4 +1,4 @@ -"""This module contains endpoints for operations related to users.""" +"""This module contains endpoints for operations related to todo-items.""" from typing import Annotated @@ -7,11 +7,12 @@ 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.dependencies.todos import TodoItemSortablePaginationParams - +import todo.auth.auth as auth router = APIRouter( prefix="/todos", @@ -23,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: @@ -33,19 +44,32 @@ 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.") + @router.get("/user/{user_id}", response_model=list[todoschema.TodoItem]) def read_todos( user_id: int, commons: Annotated[TodoItemSortablePaginationParams, Depends(TodoItemSortablePaginationParams)], - db: Session = Depends(get_db) + db: Session = Depends(get_db), + current_user: userschema.User = Depends(auth_handler.get_current_user), ): + if not (current_user.is_admin or current_user.id == user_id): + raise HTTPException(403, "You are not authorized to view this content.") + try: return todocrud.read_todos_for_user( db=db, @@ -60,7 +84,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: @@ -68,16 +99,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 8421d8f..0a07c90 100644 --- a/backend/todo/routes/users.py +++ b/backend/todo/routes/users.py @@ -11,6 +11,7 @@ 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 router = APIRouter( @@ -23,58 +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( - commons: Annotated[UserSortablePaginationParams, Depends(UserSortablePaginationParams)], - db: Session = Depends(get_db) - ): - try: - return usercrud.read_users( - db=db, - skip=commons.skip, - limit=commons.limit, - sortby=commons.sortby, - sortorder=commons.sortorder - ) - except InvalidFilterParameterException as e: - raise HTTPException(400, fmt(str(e))) - - -@router.get("/total/", response_model=dict[str, int]) -def read_users_count(db: Session = Depends(get_db)): - return {"total": usercrud.read_users_count(db=db)} + 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 index 22bff70..348ffd2 100644 --- a/backend/todo/schemas/common.py +++ b/backend/todo/schemas/common.py @@ -1,4 +1,7 @@ -"""This module contains common functions for schema validation.""" +"""This module contains common schemas and common functions for schema validation.""" + +from pydantic import BaseModel, validator + def is_valid_id(id: int) -> bool: """Checks whether the specified id is a valid ID. @@ -10,3 +13,15 @@ def is_valid_id(id: int) -> bool: raise TypeError(f"Expected an integer but got: {type(id)}") 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 30f858f..d34fc2b 100644 --- a/backend/todo/schemas/todos.py +++ b/backend/todo/schemas/todos.py @@ -49,7 +49,7 @@ class TodoItemUpdate(AbstractTodoItemValidator): class TodoItem(AbstractTodoItem): - """Schema for user info displaying.""" + """Schema for todo-item displaying.""" id: int done: bool diff --git a/backend/todo/schemas/users.py b/backend/todo/schemas/users.py index 09115c8..7b3cd85 100644 --- a/backend/todo/schemas/users.py +++ b/backend/todo/schemas/users.py @@ -57,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): @@ -85,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 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..c4d8750 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,8 @@ "name": "fastapi-svelte-template", "version": "0.0.1", "dependencies": { - "dotenv": "^16.0.3" + "dotenv": "^16.0.3", + "jwt-decode": "^3.1.2" }, "devDependencies": { "@sveltejs/adapter-node": "^1.2.4", @@ -2716,6 +2717,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", @@ -6145,6 +6151,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", diff --git a/frontend/package.json b/frontend/package.json index 2ddbf3b..be9b7ea 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ }, "type": "module", "dependencies": { - "dotenv": "^16.0.3" + "dotenv": "^16.0.3", + "jwt-decode": "^3.1.2" } } 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..a470dd9 100644 --- a/frontend/src/lib/api/endpoints.ts +++ b/frontend/src/lib/api/endpoints.ts @@ -1,17 +1,85 @@ import { error } from '@sveltejs/kit'; -import type { User } from './types'; +import type { ItemCount, User } from './types'; +import { getResponseBodyOrError } from '$lib/api/utils'; +import { getTokenFromLocalstorage } from '$lib/auth/session'; -const globalPrefix = '/api'; - -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(jwt: string = getTokenOrError()): Promise { + // TODO add pagination and sorting query params + // TODO refactor Table.svelte + const endpoint = '/api/admin/users/'; + const response = await fetch(endpoint, { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Authorization': `Bearer ${jwt}`, + 'Content-Type': 'application/json', + } + }); + + const responseJson = await getResponseBodyOrError(response); + return responseJson as User[]; +} + +/** + * Retrieves the total user count from the backend API. + * + * @param {string} jwt - The JWT appended as a bearer token to authorize the request. + * @throws{error} - If the request fails or is not permitted. + */ +export async function readUserCount(jwt: string = getTokenOrError()): Promise { + 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; +} diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 87a804d..ba16a48 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -1,7 +1,13 @@ 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 ItemCount = { + total: number } 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/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index c18923b..3edeeda 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -22,14 +22,45 @@ + 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/users/[user]/+page.ts b/frontend/src/routes/users/[user]/+page.ts index 9b0f3d6..93861a6 100644 --- a/frontend/src/routes/users/[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/users/all/+page.svelte b/frontend/src/routes/users/all/+page.svelte index f192861..27d64df 100644 --- a/frontend/src/routes/users/all/+page.svelte +++ b/frontend/src/routes/users/all/+page.svelte @@ -30,6 +30,11 @@ heading: "Updated", type: 'date', }, + { + field: "is_admin", + heading: "Admin", + type: 'boolean', + }, ], itemCountEndpoint: "/api/users/total/", itemsEndpoint: "/api/users/", 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