Skip to content

Commit 80ee1b9

Browse files
committed
Changed folder structure to better match FastAPI Best Practices
1 parent 366eb58 commit 80ee1b9

46 files changed

Lines changed: 443 additions & 382 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/app/alembic/env.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
# target_metadata = None
2121

2222
from app.models import SQLModel # noqa
23-
from app.core.config import settings # noqa
23+
from app.config import settings # noqa
2424

2525
target_metadata = SQLModel.metadata
2626

backend/app/api/main.py

Lines changed: 0 additions & 14 deletions
This file was deleted.

backend/app/auth/constants.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
ALGORITHM = "HS256"
2+
3+
# Dummy hash to use for timing attack prevention when user is not found
4+
# This is an Argon2 hash of a random password, used to ensure constant-time comparison
5+
DUMMY_HASH = "$argon2id$v=19$m=65536,t=3,p=4$MjQyZWE1MzBjYjJlZTI0Yw$YTU4NGM5ZTZmYjE2NzZlZjY0ZWY3ZGRkY2U2OWFjNjk"
Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
from pydantic import ValidationError
99
from sqlmodel import Session
1010

11-
from app.core import security
12-
from app.core.config import settings
13-
from app.core.db import engine
14-
from app.models import TokenPayload, User
11+
from app.auth.constants import ALGORITHM
12+
from app.auth.schemas import TokenPayload
13+
from app.config import settings
14+
from app.database import engine
15+
from app.users.models import User
1516

1617
reusable_oauth2 = OAuth2PasswordBearer(
1718
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
@@ -29,9 +30,7 @@ def get_db() -> Generator[Session, None, None]:
2930

3031
def get_current_user(session: SessionDep, token: TokenDep) -> User:
3132
try:
32-
payload = jwt.decode(
33-
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
34-
)
33+
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
3534
token_data = TokenPayload(**payload)
3635
except (InvalidTokenError, ValidationError):
3736
raise HTTPException(
Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,15 @@
55
from fastapi.responses import HTMLResponse
66
from fastapi.security import OAuth2PasswordRequestForm
77

8-
from app import crud
9-
from app.api.deps import CurrentUser, SessionDep, get_current_active_superuser
10-
from app.core import security
11-
from app.core.config import settings
12-
from app.models import Message, NewPassword, Token, UserPublic, UserUpdate
13-
from app.utils import (
14-
generate_password_reset_token,
15-
generate_reset_password_email,
16-
send_email,
17-
verify_password_reset_token,
18-
)
8+
from app.auth import service as auth_service
9+
from app.auth.dependencies import CurrentUser, SessionDep, get_current_active_superuser
10+
from app.auth.schemas import NewPassword, Token
11+
from app.auth.security import create_access_token
12+
from app.config import settings
13+
from app.models import Message
14+
from app.users import service as user_service
15+
from app.users.schemas import UserPublic, UserUpdate
16+
from app.utils import service as email_service
1917

2018
router = APIRouter(tags=["login"])
2119

@@ -27,7 +25,7 @@ def login_access_token(
2725
"""
2826
OAuth2 compatible token login, get an access token for future requests
2927
"""
30-
user = crud.authenticate(
28+
user = auth_service.authenticate(
3129
session=session, email=form_data.username, password=form_data.password
3230
)
3331
if not user:
@@ -36,9 +34,7 @@ def login_access_token(
3634
raise HTTPException(status_code=400, detail="Inactive user")
3735
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
3836
return Token(
39-
access_token=security.create_access_token(
40-
user.id, expires_delta=access_token_expires
41-
)
37+
access_token=create_access_token(user.id, expires_delta=access_token_expires)
4238
)
4339

4440

@@ -55,16 +51,16 @@ def recover_password(email: str, session: SessionDep) -> Message:
5551
"""
5652
Password Recovery
5753
"""
58-
user = crud.get_user_by_email(session=session, email=email)
54+
user = user_service.get_user_by_email(session=session, email=email)
5955

6056
# Always return the same response to prevent email enumeration attacks
6157
# Only send email if user actually exists
6258
if user:
63-
password_reset_token = generate_password_reset_token(email=email)
64-
email_data = generate_reset_password_email(
59+
password_reset_token = auth_service.generate_password_reset_token(email=email)
60+
email_data = email_service.generate_reset_password_email(
6561
email_to=user.email, email=email, token=password_reset_token
6662
)
67-
send_email(
63+
email_service.send_email(
6864
email_to=user.email,
6965
subject=email_data.subject,
7066
html_content=email_data.html_content,
@@ -79,17 +75,17 @@ def reset_password(session: SessionDep, body: NewPassword) -> Message:
7975
"""
8076
Reset password
8177
"""
82-
email = verify_password_reset_token(token=body.token)
78+
email = auth_service.verify_password_reset_token(token=body.token)
8379
if not email:
8480
raise HTTPException(status_code=400, detail="Invalid token")
85-
user = crud.get_user_by_email(session=session, email=email)
81+
user = user_service.get_user_by_email(session=session, email=email)
8682
if not user:
8783
# Don't reveal that the user doesn't exist - use same error as invalid token
8884
raise HTTPException(status_code=400, detail="Invalid token")
8985
elif not user.is_active:
9086
raise HTTPException(status_code=400, detail="Inactive user")
9187
user_in_update = UserUpdate(password=body.new_password)
92-
crud.update_user(
88+
user_service.update_user(
9389
session=session,
9490
db_user=user,
9591
user_in=user_in_update,
@@ -106,15 +102,15 @@ def recover_password_html_content(email: str, session: SessionDep) -> Any:
106102
"""
107103
HTML Content for Password Recovery
108104
"""
109-
user = crud.get_user_by_email(session=session, email=email)
105+
user = user_service.get_user_by_email(session=session, email=email)
110106

111107
if not user:
112108
raise HTTPException(
113109
status_code=404,
114110
detail="The user with this username does not exist in the system.",
115111
)
116-
password_reset_token = generate_password_reset_token(email=email)
117-
email_data = generate_reset_password_email(
112+
password_reset_token = auth_service.generate_password_reset_token(email=email)
113+
email_data = email_service.generate_reset_password_email(
118114
email_to=user.email, email=email, token=password_reset_token
119115
)
120116

backend/app/auth/schemas.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from sqlmodel import Field, SQLModel
2+
3+
4+
# JSON payload containing access token
5+
class Token(SQLModel):
6+
access_token: str
7+
token_type: str = "bearer"
8+
9+
10+
# Contents of JWT token
11+
class TokenPayload(SQLModel):
12+
sub: str | None = None
13+
14+
15+
class NewPassword(SQLModel):
16+
token: str
17+
new_password: str = Field(min_length=8, max_length=128)
18+
19+
20+
class UpdatePassword(SQLModel):
21+
current_password: str = Field(min_length=8, max_length=128)
22+
new_password: str = Field(min_length=8, max_length=128)
Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
from pwdlib.hashers.argon2 import Argon2Hasher
77
from pwdlib.hashers.bcrypt import BcryptHasher
88

9-
from app.core.config import settings
9+
from app.auth.constants import ALGORITHM
10+
from app.config import settings
1011

1112
password_hash = PasswordHash(
1213
(
@@ -16,9 +17,6 @@
1617
)
1718

1819

19-
ALGORITHM = "HS256"
20-
21-
2220
def create_access_token(subject: str | Any, expires_delta: timedelta) -> str:
2321
expire = datetime.now(timezone.utc) + expires_delta
2422
to_encode = {"exp": expire, "sub": str(subject)}

backend/app/auth/service.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from datetime import datetime, timedelta, timezone
2+
3+
import jwt
4+
from jwt.exceptions import InvalidTokenError
5+
from sqlmodel import Session
6+
7+
from app.auth.constants import ALGORITHM, DUMMY_HASH
8+
from app.auth.security import verify_password
9+
from app.config import settings
10+
from app.users import service as user_service
11+
from app.users.models import User
12+
13+
14+
def authenticate(*, session: Session, email: str, password: str) -> User | None:
15+
db_user = user_service.get_user_by_email(session=session, email=email)
16+
if not db_user:
17+
# Prevent timing attacks by running password verification even when user doesn't exist
18+
# This ensures the response time is similar whether or not the email exists
19+
verify_password(password, DUMMY_HASH)
20+
return None
21+
verified, updated_password_hash = verify_password(password, db_user.hashed_password)
22+
if not verified:
23+
return None
24+
if updated_password_hash:
25+
db_user.hashed_password = updated_password_hash
26+
session.add(db_user)
27+
session.commit()
28+
session.refresh(db_user)
29+
return db_user
30+
31+
32+
def generate_password_reset_token(email: str) -> str:
33+
delta = timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS)
34+
now = datetime.now(timezone.utc)
35+
expires = now + delta
36+
exp = expires.timestamp()
37+
encoded_jwt = jwt.encode(
38+
{"exp": exp, "nbf": now, "sub": email},
39+
settings.SECRET_KEY,
40+
algorithm=ALGORITHM,
41+
)
42+
return encoded_jwt
43+
44+
45+
def verify_password_reset_token(token: str) -> str | None:
46+
try:
47+
decoded_token = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
48+
return str(decoded_token["sub"])
49+
except InvalidTokenError:
50+
return None

backend/app/backend_pre_start.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from sqlmodel import Session, select
55
from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed
66

7-
from app.core.db import engine
7+
from app.database import engine
88

99
logging.basicConfig(level=logging.INFO)
1010
logger = logging.getLogger(__name__)

0 commit comments

Comments
 (0)