Skip to content

Commit

Permalink
Implement user accounts (#44)
Browse files Browse the repository at this point in the history
* made user login and new user forms

* Update new user/login form with UX tweaks

* Move account items to user menu in navbar

* Whoop typo

* Fix form popup animation

* Organize form imports

* adding db backend routing for creating users

* Refactor stuff, add migration for users table

* Whoops update get_user_info to use pennkey

* Remove unnecessary package-lock files

* Remove salt from DB (it's stored in hash)

* Add OAuth2 + JWT authentication to backend

* Add frontend main process auth logic

* Get frontend pipeline working

* Update backend to record user pennkey on asset POST

* Add auth token to frontend file download call

---------

Co-authored-by: Debby Lin <[email protected]>
  • Loading branch information
printer83mph and debbylin02 authored Apr 23, 2024
1 parent e3bae0c commit 86ccc92
Show file tree
Hide file tree
Showing 30 changed files with 1,118 additions and 312 deletions.
1 change: 1 addition & 0 deletions backend/.env.development
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ DATABASE_URL="postgresql://postgres:[email protected]:5432/postgres"
AWS_ENDPOINT_URL="http://localhost:4566"
AWS_ACCESS_KEY_ID="test_AWS_ACCESS_KEY_ID"
AWS_SECRET_ACCESS_KEY="test_AWS_SECRET_ACCESS_KEY"
SECRET_KEY="4fc57ea9cd4d54e9e43b534a3b88722ba32cf4f0a2e1b76a2069cde25ff4203f"
1 change: 1 addition & 0 deletions backend/Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ sqlalchemy = "*"
boto3 = "*"
psycopg2 = "*"
sqladmin = "*"
python-jose = {extras = ["cryptography"], version = "*"}

[dev-packages]
boto3-stubs = {extras = ["essential"], version = "*"}
Expand Down
473 changes: 273 additions & 200 deletions backend/Pipfile.lock

Large diffs are not rendered by default.

28 changes: 26 additions & 2 deletions backend/database/models.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from datetime import datetime
from typing import Optional
import enum
from typing import Literal, Optional, get_args
from uuid import UUID, uuid4

from sqlalchemy import ForeignKey, Uuid, func
from sqlalchemy import Enum, ForeignKey, Uuid, func, literal
from sqlalchemy.orm import Mapped, mapped_column, declarative_base, relationship

Base = declarative_base()
Expand Down Expand Up @@ -39,3 +40,26 @@ class Version(Base):
message: Mapped[str]

file_key: Mapped[str]


# school for user model
School = Literal["sas", "seas", "wharton"]


# user class
class User(Base):
__tablename__ = "users"

pennkey: Mapped[str] = mapped_column(primary_key=True)
hashed_password: Mapped[bytes]

first_name: Mapped[str]
last_name: Mapped[str]
school: Mapped[School] = mapped_column(
Enum(
*get_args(School),
name="school",
create_constraint=True,
validate_strings=True
)
)
2 changes: 1 addition & 1 deletion backend/main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from settings import DATABASE_URL, is_dev
from settings import DATABASE_URL, is_dev, SECRET_KEY

from fastapi import FastAPI
from fastapi.concurrency import asynccontextmanager
Expand Down
37 changes: 37 additions & 0 deletions backend/migrations/versions/33e6aa9a48d7_add_user_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Add user table
Revision ID: 33e6aa9a48d7
Revises: 4b80b9859b79
Create Date: 2024-04-23 01:57:35.665974
"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '33e6aa9a48d7'
down_revision: Union[str, None] = '4b80b9859b79'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('pennkey', sa.String(), nullable=False),
sa.Column('hashed_password', sa.LargeBinary(), nullable=False),
sa.Column('first_name', sa.String(), nullable=False),
sa.Column('last_name', sa.String(), nullable=False),
sa.Column('school', sa.Enum('sas', 'seas', 'wharton', name='school', create_constraint=True), nullable=False),
sa.PrimaryKeyConstraint('pennkey')
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('users')
# ### end Alembic commands ###
6 changes: 0 additions & 6 deletions backend/package-lock.json

This file was deleted.

2 changes: 2 additions & 0 deletions backend/routers/api_v1/_router.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from fastapi import APIRouter

from .assets import router as assets_router
from .users import router as users_router

router = APIRouter(
prefix="/api/v1",
)

router.include_router(assets_router)
router.include_router(users_router)
33 changes: 19 additions & 14 deletions backend/routers/api_v1/assets.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
from email.policy import default
from typing import Annotated, Literal, Sequence
from uuid import UUID
from fastapi import (
APIRouter,
BackgroundTasks,
Expand All @@ -10,12 +8,11 @@
HTTPException,
UploadFile,
)
from fastapi.background import P
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from datetime import datetime

from util.crud import (
from database.models import User
from util.crud.assets import (
AssetInfo,
create_asset,
create_version,
Expand All @@ -29,6 +26,7 @@
from database.connection import get_db
from schemas.models import Asset, AssetCreate, Version, VersionCreate
from util.files import save_upload_file_temp
from util.auth import get_current_user, oauth2_scheme

router = APIRouter(
prefix="/assets",
Expand All @@ -45,11 +43,11 @@
Allows searching by arbitrary strings, sorting by date or name, adding keyword filters, and adding offset for pagination.""",
)
def get_assets(
db: Annotated[Session, Depends(get_db)],
search: str | None = None,
keywords: str | None = None,
sort: Literal["date_asc", "name_asc", "date_dsc", "name_dsc"] = "date_dsc",
offset: int = 0,
db: Session = Depends(get_db),
) -> Sequence[Asset]:
# TODO: add fuzzy search somehow
return read_assets(
Expand All @@ -62,8 +60,12 @@ def get_assets(
summary="Create a new asset, not including initial version",
description="Creating a new asset in the database. Does not include initial version -- followed up with POST to `/assets/{uuid}` to upload an initial version.",
)
async def new_asset(asset: AssetCreate, db: Session = Depends(get_db)) -> Asset:
return create_asset(db, asset, "benfranklin")
async def new_asset(
db: Annotated[Session, Depends(get_db)],
user: Annotated[User, Depends(get_current_user)],
asset: AssetCreate,
) -> Asset:
return create_asset(db, asset, user.pennkey)


# TODO: add relatedAssets maybe
Expand All @@ -72,7 +74,7 @@ async def new_asset(asset: AssetCreate, db: Session = Depends(get_db)) -> Asset:
summary="Get info about a specific asset",
description="Based on `uuid`, fetches information on a specific asset.",
)
def get_asset_info(uuid: str, db: Session = Depends(get_db)) -> AssetInfo:
def get_asset_info(uuid: str, db: Annotated[Session, Depends(get_db)]) -> AssetInfo:
result = read_asset_info(db, uuid)
if result is None:
raise HTTPException(status_code=404, detail="Asset not found")
Expand All @@ -81,9 +83,10 @@ def get_asset_info(uuid: str, db: Session = Depends(get_db)) -> AssetInfo:

@router.put("/{uuid}", summary="Update asset metadata")
async def put_asset(
db: Annotated[Session, Depends(get_db)],
token: Annotated[str, Depends(oauth2_scheme)],
uuid: str,
asset: AssetCreate,
db: Session = Depends(get_db),
):
result = update_asset(db, uuid, asset)
if result is None:
Expand All @@ -93,21 +96,22 @@ async def put_asset(

@router.get("/{uuid}/versions", summary="Get a list of versions for a given asset")
def get_asset_versions(
db: Annotated[Session, Depends(get_db)],
uuid: str,
sort: Literal["asc", "desc"] = "desc",
offset: int = 0,
db: Session = Depends(get_db),
) -> Sequence[Version]:
return read_asset_versions(db, uuid, sort, offset)


@router.post("/{uuid}/versions", summary="Upload a new version for a given asset")
def new_asset_version(
db: Annotated[Session, Depends(get_db)],
user: Annotated[User, Depends(get_current_user)],
uuid: str,
file: Annotated[UploadFile, File()],
message: Annotated[str, Form()],
is_major: Annotated[bool, Form()] = False,
db: Session = Depends(get_db),
):
# TODO: reenable this sometime
# if file.content_type != "application/zip":
Expand All @@ -124,7 +128,7 @@ def new_asset_version(
db,
file_path,
uuid,
"benfranklin",
user.pennkey,
VersionCreate(message=message, is_major=is_major),
)

Expand All @@ -139,10 +143,11 @@ def new_asset_version(
},
)
def download_version_file(
db: Annotated[Session, Depends(get_db)],
token: Annotated[str, Depends(oauth2_scheme)],
background_tasks: BackgroundTasks,
uuid: str,
semver: str,
db: Session = Depends(get_db),
) -> FileResponse:
(file_path, cleanup) = read_version_file(db, uuid, semver)
background_tasks.add_task(cleanup)
Expand Down
108 changes: 108 additions & 0 deletions backend/routers/api_v1/users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
from datetime import timedelta
from typing import Annotated, Sequence
from fastapi import (
APIRouter,
Depends,
HTTPException,
)
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session

from util.auth import create_access_token, get_current_user
from util.crud.users import (
authenticate_user,
create_user,
read_user_exists,
read_user,
read_users,
)
from database.connection import get_db
from schemas.models import UserCreate, User, Token


ACCESS_TOKEN_EXPIRE_MINUTES = 30


router = APIRouter(
prefix="/users",
tags=["users"],
responses={404: {"description": "Not found"}},
)


@router.get(
"/",
summary="Get a list of users",
description="Fetches a list of users from the database. Optionally, add a search parameter to filter results.",
)
def get_users(
db: Annotated[Session, Depends(get_db)],
query: str | None = None,
offset: int = 0,
) -> Sequence[User]:
return read_users(db, query=query, offset=offset)


@router.post(
"/",
summary="Create a new user in the database",
)
async def new_user(user: UserCreate, db: Annotated[Session, Depends(get_db)]) -> User:
# make sure user doesn't already exist
if read_user_exists(db, user.pennkey):
raise HTTPException(400, "User already exists")

try:
result = create_user(db, user)
if result is None:
raise HTTPException(status_code=400, detail="User could not be created")
except Exception as e:
print(e)
raise HTTPException(status_code=500, detail="Trolma")

return result


@router.post(
"/token",
summary="Login with PennKey and password",
)
def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
db: Annotated[Session, Depends(get_db)],
) -> Token:
# authenticate user
user = authenticate_user(db, form_data.username, form_data.password)
if user is None:
raise HTTPException(status_code=401, detail="Invalid credentials")

# create JWT access token
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.pennkey}, expires_delta=access_token_expires
)

return Token(access_token=access_token, token_type="bearer")


@router.get(
"/me",
summary="Get info about the current user",
description="Based on the provided token, fetches information on the current user.",
)
def read_users_me(
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
return current_user


@router.get(
"/{pennkey}",
summary="Get info about a specific user",
description="Based on `pennkey`, fetches information on a specific user.",
)
def get_user_info(db: Annotated[Session, Depends(get_db)], pennkey: str) -> User:
result = read_user(db, pennkey)
if result is None:
raise HTTPException(status_code=404, detail="User not found")
return result
28 changes: 27 additions & 1 deletion backend/schemas/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Optional
from typing import Literal, Optional
from uuid import UUID
from pydantic import BaseModel
from datetime import datetime
Expand Down Expand Up @@ -38,3 +38,29 @@ class Version(VersionBase):

class Config:
from_attributes = True


# User class
class UserBase(BaseModel):
pennkey: str
first_name: str
last_name: str
school: Literal["sas", "seas", "wharton"]


class UserCreate(UserBase):
password: str


class User(UserBase):
class Config:
from_attributes = True


class Token(BaseModel):
access_token: str
token_type: str


class TokenData(BaseModel):
pennkey: str | None = None
4 changes: 4 additions & 0 deletions backend/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,7 @@
DATABASE_URL = os.getenv("DATABASE_URL") or "unset"
if DATABASE_URL == "unset":
raise Exception("DATABASE_URL is not set")

SECRET_KEY = os.getenv("SECRET_KEY") or "unset"
if SECRET_KEY == "unset":
raise Exception("SECRET_KEY is not set")
Loading

0 comments on commit 86ccc92

Please sign in to comment.