Skip to content

Commit 241528a

Browse files
feat: integrate backend
1 parent 9711222 commit 241528a

File tree

13 files changed

+214
-28
lines changed

13 files changed

+214
-28
lines changed

backend/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ dependencies = [
1717
"fastapi-pagination>=0.12.34",
1818
"bcrypt==4.0.1",
1919
"google-genai>=1.5.0",
20+
"starlette (>=0.46.2,<0.47.0)",
2021
]
2122

2223
[tool.uv]
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Add auth0_id field to Users table
2+
3+
Revision ID: 1425c896d3ef
4+
Revises: cb16ae472c1e
5+
Create Date: 2025-05-10 00:12:00.358973
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
import sqlmodel.sql.sqltypes
11+
12+
13+
# revision identifiers, used by Alembic.
14+
revision = '1425c896d3ef'
15+
down_revision = 'cb16ae472c1e'
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
# ### commands auto generated by Alembic - please adjust! ###
22+
op.alter_column('user', 'hashed_password',
23+
existing_type=sa.VARCHAR(),
24+
nullable=True)
25+
op.drop_index('ix_user_auth0_id', table_name='user')
26+
op.create_index(op.f('ix_user_auth0_id'), 'user', ['auth0_id'], unique=False)
27+
# ### end Alembic commands ###
28+
29+
30+
def downgrade():
31+
# ### commands auto generated by Alembic - please adjust! ###
32+
op.drop_index(op.f('ix_user_auth0_id'), table_name='user')
33+
op.create_index('ix_user_auth0_id', 'user', ['auth0_id'], unique=True)
34+
op.alter_column('user', 'hashed_password',
35+
existing_type=sa.VARCHAR(),
36+
nullable=False)
37+
# ### end Alembic commands ###

backend/src/auth/auth0_api.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from fastapi import APIRouter, Request
2+
from fastapi.responses import RedirectResponse
3+
from authlib.integrations.starlette_client import OAuth
4+
from src.core.config import settings
5+
6+
router = APIRouter(tags=["auth"])
7+
8+
oauth = OAuth()
9+
oauth.register(
10+
name="auth0",
11+
client_id=settings.AUTH0_CLIENT_ID,
12+
client_secret=settings.AUTH0_CLIENT_SECRET,
13+
server_metadata_url=f"https://{settings.AUTH0_DOMAIN}/.well-known/openid-configuration",
14+
client_kwargs={"scope": "openid profile email"},
15+
)
16+
17+
18+
@router.get("/login")
19+
async def login(request: Request):
20+
redirect_uri = request.url_for("auth0_callback")
21+
return await oauth.auth0.authorize_redirect(
22+
request,
23+
redirect_uri,
24+
prompt="select_account",
25+
connection="google-oauth2"
26+
)
27+
28+
29+
@router.get("/callback", name="auth0_callback")
30+
async def auth0_callback(request: Request):
31+
token = await oauth.auth0.authorize_access_token(request)
32+
33+
user = token.get("userinfo") or await oauth.auth0.userinfo(token=token)
34+
35+
request.session["user"] = {
36+
"email": user["email"],
37+
"name": user.get("name"),
38+
"picture": user.get("picture"),
39+
"sub": user.get("sub"),
40+
}
41+
42+
return RedirectResponse(url="http://localhost:5173/collections")
43+
44+
45+
@router.get("/logout")
46+
async def logout(request: Request):
47+
request.session.clear()
48+
return RedirectResponse(
49+
url=f"https://{settings.AUTH0_DOMAIN}/v2/logout"
50+
f"?client_id={settings.AUTH0_CLIENT_ID}"
51+
f"&returnTo=http://localhost:5173"
52+
)
53+
54+
55+
@router.get("/me")
56+
async def me(request: Request):
57+
user = request.session.get("user")
58+
return {"authenticated": bool(user), "user": user}

backend/src/auth/services.py

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,18 @@
22
from typing import Annotated, Any
33

44
import jwt
5-
from fastapi import Depends, HTTPException, status
5+
from fastapi import Depends, HTTPException, status, Request
66
from fastapi.security import OAuth2PasswordBearer
77
from jwt.exceptions import InvalidTokenError
88
from passlib.context import CryptContext
99
from pydantic import ValidationError
10-
from sqlmodel import Session
10+
from sqlmodel import Session, select
1111

1212
from src.auth.schemas import TokenPayload
1313
from src.core.config import settings
1414
from src.core.db import get_db
1515
from src.users.models import User
16+
from src.users.schemas import UserPublic
1617

1718
ALGORITHM = "HS256"
1819

@@ -23,21 +24,50 @@
2324
TokenDep = Annotated[str, Depends(reusable_oauth2)]
2425

2526

26-
def get_current_user(session: SessionDep, token: TokenDep) -> User:
27+
def get_user_from_session(request: Request, session: SessionDep) -> User:
28+
session_user = request.session.get("user")
29+
if not session_user:
30+
raise HTTPException(status_code=401, detail="Not authenticated (no session)")
31+
32+
user = session.exec(select(User).where(User.email == session_user["email"])).first()
33+
if not user or not user.is_active:
34+
raise HTTPException(status_code=401, detail="Invalid session user")
35+
return UserPublic.model_validate(user)
36+
37+
38+
def get_user_from_token(
39+
session: SessionDep,
40+
token: Annotated[str, Depends(reusable_oauth2)],
41+
) -> User:
2742
try:
2843
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
2944
token_data = TokenPayload(**payload)
45+
user = session.get(User, token_data.sub)
46+
if not user or not user.is_active:
47+
raise HTTPException(status_code=401, detail="Invalid user")
48+
return user
3049
except (InvalidTokenError, ValidationError):
31-
raise HTTPException(
32-
status_code=status.HTTP_403_FORBIDDEN,
33-
detail="Could not validate credentials",
34-
)
35-
user = session.get(User, token_data.sub)
36-
if not user:
37-
raise HTTPException(status_code=404, detail="User not found")
38-
if not user.is_active:
39-
raise HTTPException(status_code=400, detail="Inactive user")
40-
return user
50+
raise HTTPException(status_code=403, detail="Invalid token")
51+
52+
53+
def get_current_user(
54+
request: Request,
55+
session: SessionDep,
56+
token: Annotated[str | None, Depends(OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/tokens", auto_error=False))] = None,
57+
) -> User:
58+
print("in get current user")
59+
# Prefer session (Auth0 flow)
60+
session_user = request.session.get("user")
61+
if session_user:
62+
print("Session user found:", session_user["email"])
63+
res = get_user_from_session(request, session)
64+
print("User from session:", res)
65+
return res
66+
# Fallback to token (JWT flow)
67+
if token:
68+
return get_user_from_token(session, token)
69+
70+
raise HTTPException(status_code=401, detail="Not authenticated")
4171

4272

4373
CurrentUser = Annotated[User, Depends(get_current_user)]
@@ -53,8 +83,15 @@ def authenticate(*, session: Session, email: str, password: str) -> User | None:
5383
db_user = get_user_by_email(session=session, email=email)
5484
if not db_user:
5585
return None
86+
87+
# Auth0 users may not have a password
88+
if not db_user.hashed_password:
89+
# Return None for users without a password when using password authentication
90+
return None
91+
5692
if not verify_password(password, db_user.hashed_password):
5793
return None
94+
5895
return db_user
5996

6097

@@ -67,3 +104,15 @@ def create_access_token(subject: str | Any, expires_delta: timedelta) -> str:
67104

68105
def get_password_hash(password: str) -> str:
69106
return pwd_context.hash(password)
107+
108+
109+
def get_or_create_user_by_email(session: Session, email: str, defaults: dict = {}) -> User:
110+
user = session.exec(select(User).where(User.email == email)).first()
111+
if user:
112+
return user
113+
user = User(email=email, **defaults)
114+
session.add(user)
115+
session.commit()
116+
session.refresh(user)
117+
return user
118+

backend/src/core/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ class Settings(BaseSettings):
4646
POSTGRES_PASSWORD: str
4747
POSTGRES_DB: str = ""
4848

49+
AUTH0_CLIENT_ID: str
50+
AUTH0_CLIENT_SECRET: str
51+
AUTH0_DOMAIN: str
52+
4953
@computed_field # type: ignore[misc]
5054
@property
5155
def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:

backend/src/main.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from fastapi.routing import APIRoute
33
from fastapi_pagination import add_pagination
44
from starlette.middleware.cors import CORSMiddleware
5+
from starlette.middleware.sessions import SessionMiddleware
56

67
from src.core.config import settings
78
from src.routers import api_router
@@ -29,5 +30,15 @@ def custom_generate_unique_id(route: APIRoute) -> str:
2930
allow_headers=["*"],
3031
)
3132

33+
for origin in settings.BACKEND_CORS_ORIGINS:
34+
print(f"Allowed CORS origin: {origin}")
35+
36+
app.add_middleware(
37+
SessionMiddleware,
38+
secret_key=settings.SECRET_KEY,
39+
same_site="lax", # adjust for production
40+
https_only=False
41+
)
42+
3243
app.include_router(api_router, prefix=settings.API_V1_STR)
3344
add_pagination(app)

backend/src/routers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from fastapi import APIRouter
22

33
from src.auth.api import router as auth_router
4+
from src.auth.auth0_api import router as auth0_router
45
from src.flashcards.api import router as flashcards_router
56
from src.stats.api import router as stats_router
67
from src.users.api import router as user_router
@@ -11,3 +12,4 @@
1112
api_router.include_router(user_router, prefix="/users", tags=["users"])
1213
api_router.include_router(flashcards_router, tags=["flashcards"])
1314
api_router.include_router(stats_router, tags=["stats"])
15+
api_router.include_router(auth0_router, prefix="/auth0", tags=["auth0"])

backend/src/users/api.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import Any
22

3-
from fastapi import APIRouter, HTTPException
3+
from fastapi import APIRouter, HTTPException, Request
44

55
from src.auth.services import CurrentUser, SessionDep
66
from src.core.config import settings
@@ -12,10 +12,11 @@
1212

1313

1414
@router.get("/me", response_model=UserPublic)
15-
def read_user_me(current_user: CurrentUser) -> Any:
15+
def read_user_me(request: Request, current_user: CurrentUser) -> Any:
1616
"""
1717
Get current user.
1818
"""
19+
print("session content:", request.session)
1920
return current_user
2021

2122

backend/src/users/models.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import uuid
2-
from typing import TYPE_CHECKING
2+
from typing import TYPE_CHECKING, Optional
33

44
from sqlmodel import Field, Relationship
55

@@ -11,7 +11,8 @@
1111

1212
class User(UserBase, table=True):
1313
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
14-
hashed_password: str
14+
auth0_id: Optional[str] = Field(default=None, index=True)
15+
hashed_password: Optional[str] = Field(default=None)
1516
collections: list["Collection"] = Relationship(
1617
back_populates="user",
1718
cascade_delete=True,

backend/src/users/schemas.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import uuid
2+
from typing import Optional
23

34
from pydantic import EmailStr
45
from sqlmodel import Field, SQLModel
@@ -16,7 +17,8 @@ class UserUpdate(UserBase):
1617

1718

1819
class UserCreate(UserBase):
19-
password: str = Field(min_length=8, max_length=40)
20+
password: Optional[str] = Field(default=None, min_length=8, max_length=40)
21+
auth0_id: Optional[str] = None
2022

2123

2224
class UserRegister(SQLModel):
@@ -26,3 +28,4 @@ class UserRegister(SQLModel):
2628

2729
class UserPublic(UserBase):
2830
id: uuid.UUID
31+
auth0_id: Optional[str] = None

0 commit comments

Comments
 (0)