Skip to content

Commit 12ad252

Browse files
add auth verification to fastapi (#11704)
* add auth verification to fastapi * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix linter bugs * more specific errors handling * better naming for types * add auth endpoints * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * remove extra secret key function def * remove extra comments * better name * add notes of things to delete * add notes of things to delete * cleaner generate_login_code * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * add missing import * remove extra verify_hash def * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * remove auth middleware that's not needed at this moment * better comments * delete ai generated docs * move auth tests to python * tests don't run in Ci now --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent d433776 commit 12ad252

File tree

7 files changed

+530
-4
lines changed

7 files changed

+530
-4
lines changed

openlibrary/accounts/model.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,21 @@ def get_secret_key():
9595
return config.infobase['secret_key']
9696

9797

98+
def generate_login_code_for_user(username: str) -> str:
99+
"""
100+
Args:
101+
username: The username to generate a login code for
102+
103+
Returns:
104+
A string in the format: "/people/{username},{timestamp},{salt}${hash}"
105+
that can be used as a session cookie value
106+
"""
107+
user_key = "/people/" + username
108+
t = datetime.datetime(*time.gmtime()[:6]).isoformat()
109+
text = f"{user_key},{t}"
110+
return text + "," + generate_hash(get_secret_key(), text)
111+
112+
98113
def generate_uuid() -> str:
99114
return str(uuid.uuid4()).replace("-", "")
100115

@@ -251,10 +266,7 @@ def generate_random_password(cls, n: int = 12) -> str:
251266

252267
def generate_login_code(self) -> str:
253268
"""Returns a string that can be set as login cookie to log in as this user."""
254-
user_key = "/people/" + self.username
255-
t = datetime.datetime(*time.gmtime()[:6]).isoformat()
256-
text = f"{user_key},{t}"
257-
return text + "," + generate_hash(get_secret_key(), text)
269+
return generate_login_code_for_user(self.username)
258270

259271
def _save(self) -> None:
260272
"""Saves this account in store."""

openlibrary/asgi_app.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,11 +176,14 @@ async def set_context(request: Request, call_next):
176176
def health() -> dict[str, str]:
177177
return {"status": "ok"}
178178

179+
from openlibrary.fastapi.account import router as account_router # type: ignore
179180
from openlibrary.fastapi.languages import router as languages_router # type: ignore
180181
from openlibrary.fastapi.search import router as search_router # type: ignore
181182

183+
# Include routers
182184
app.include_router(languages_router)
183185
app.include_router(search_router)
186+
app.include_router(account_router)
184187

185188
return app
186189

openlibrary/fastapi/account.py

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
"""
2+
FastAPI account endpoints for authentication.
3+
"""
4+
5+
from __future__ import annotations
6+
7+
from typing import Annotated
8+
from urllib.parse import unquote
9+
10+
from fastapi import APIRouter, Depends, Form, Request, Response, status
11+
from pydantic import BaseModel, Field
12+
13+
from infogami import config
14+
from openlibrary.accounts.model import audit_accounts, generate_login_code_for_user
15+
from openlibrary.core import stats
16+
from openlibrary.fastapi.auth import (
17+
AuthenticatedUser,
18+
get_authenticated_user,
19+
require_authenticated_user,
20+
)
21+
from openlibrary.plugins.upstream.account import get_login_error
22+
23+
router = APIRouter()
24+
25+
26+
class AuthTestResponse(BaseModel):
27+
"""Response model for the auth test endpoint."""
28+
29+
username: str | None = Field(None, description="The username if authenticated")
30+
user_key: str | None = Field(None, description="The full user key if authenticated")
31+
timestamp: str | None = Field(
32+
None, description="The cookie timestamp if authenticated"
33+
)
34+
is_authenticated: bool = Field(..., description="Whether the user is authenticated")
35+
error: str | None = Field(
36+
None, description="Error message if authentication failed"
37+
)
38+
cookie_name: str = Field(..., description="The name of the session cookie")
39+
cookie_value: str | None = Field(
40+
None, description="The raw cookie value (for debugging)"
41+
)
42+
cookie_parsed: dict = Field(..., description="Parsed cookie components")
43+
44+
45+
# TODO: Delete this before merging, it's just for local testing for now.
46+
@router.get("/account/test.json", response_model=AuthTestResponse)
47+
async def check_authentication(
48+
request: Request,
49+
user: Annotated[AuthenticatedUser | None, Depends(get_authenticated_user)],
50+
) -> AuthTestResponse:
51+
"""
52+
Check endpoint to verify authentication is working correctly.
53+
54+
This endpoint reads the session cookie, decodes it, and returns information
55+
about the authenticated user. It's useful for testing the authentication
56+
middleware without requiring a full login flow.
57+
58+
Returns:
59+
AuthTestResponse: Information about the authentication status
60+
61+
Example:
62+
# With valid session cookie
63+
curl http://localhost:18080/account/test.json \\
64+
-b "session=/people/openlibrary%2C2026-01-18T17%3A25%3A46%2C7897f%24841a3bd2f8e9a5ca46f505fa557d57bd"
65+
66+
# Without cookie
67+
curl http://localhost:18080/account/test.json
68+
"""
69+
70+
cookie_name = config.get("login_cookie_name", "session")
71+
cookie_value = request.cookies.get(cookie_name)
72+
73+
# Parse the cookie for debugging
74+
cookie_parsed = {}
75+
if cookie_value:
76+
decoded = unquote(cookie_value)
77+
parts = decoded.split(",")
78+
cookie_parsed = {
79+
"raw_decoded": decoded,
80+
"parts": parts,
81+
"num_parts": len(parts),
82+
}
83+
if len(parts) == 3:
84+
cookie_parsed["user_key"] = parts[0]
85+
cookie_parsed["timestamp"] = parts[1]
86+
cookie_parsed["hash"] = (
87+
parts[2][:20] + "..." if len(parts[2]) > 20 else parts[2]
88+
)
89+
90+
return AuthTestResponse(
91+
username=user.username if user else None,
92+
user_key=user.user_key if user else None,
93+
timestamp=user.timestamp if user else None,
94+
is_authenticated=user is not None,
95+
cookie_name=cookie_name,
96+
cookie_value=(
97+
cookie_value[:50] + "..."
98+
if cookie_value and len(cookie_value) > 50
99+
else cookie_value
100+
),
101+
cookie_parsed=cookie_parsed,
102+
)
103+
104+
105+
# TODO: Delete this before merging, it's just for local testing for now.
106+
@router.get("/account/protected.json")
107+
async def protected_endpoint(
108+
user: Annotated[AuthenticatedUser, Depends(require_authenticated_user)],
109+
) -> dict:
110+
"""
111+
Example of a protected endpoint that requires authentication.
112+
113+
This endpoint will return 401 Unauthorized if the user is not authenticated.
114+
115+
Returns:
116+
dict: Success message with user information
117+
118+
Raises:
119+
HTTPException: 401 if not authenticated
120+
"""
121+
return {
122+
"message": f"Hello {user.username}!",
123+
"user_key": user.user_key,
124+
"timestamp": user.timestamp,
125+
}
126+
127+
128+
# TODO: Delete this before merging, it's just for local testing for now.
129+
@router.get("/account/optional.json")
130+
async def optional_auth_endpoint(
131+
user: Annotated[AuthenticatedUser | None, Depends(get_authenticated_user)],
132+
) -> dict:
133+
"""
134+
Example of an endpoint with optional authentication.
135+
136+
This endpoint works for both authenticated and unauthenticated users,
137+
returning different information based on auth status.
138+
139+
Returns:
140+
dict: Response with user info or anonymous message
141+
"""
142+
if user:
143+
return {
144+
"message": f"Welcome back, {user.username}!",
145+
"user_key": user.user_key,
146+
"timestamp": user.timestamp,
147+
"is_authenticated": True,
148+
}
149+
else:
150+
return {
151+
"message": "Hello, anonymous user!",
152+
"is_authenticated": False,
153+
}
154+
155+
156+
class LoginForm(BaseModel):
157+
"""Login form data - matches web.py forms.Login"""
158+
159+
username: str
160+
password: str
161+
remember: bool = False
162+
redirect: str = "/"
163+
action: str = ""
164+
165+
166+
@router.post("/account/login")
167+
async def login(
168+
request: Request,
169+
form_data: Annotated[LoginForm, Form()],
170+
) -> Response:
171+
"""
172+
Login endpoint - works identically to web.py version.
173+
174+
This endpoint:
175+
1. Validates email/password against Internet Archive
176+
2. Creates/links OpenLibrary account if needed
177+
3. Sets session cookie and other cookies
178+
4. Redirects to target page
179+
180+
This reuses all existing authentication logic from the legacy system.
181+
"""
182+
183+
# Call the EXACT same audit function that web.py uses
184+
audit = audit_accounts(
185+
email=form_data.username,
186+
password=form_data.password,
187+
require_link=True,
188+
s3_access_key=None,
189+
s3_secret_key=None,
190+
test=False,
191+
)
192+
193+
# Check for authentication errors
194+
if error := audit.get('error'):
195+
from fastapi import HTTPException
196+
197+
raise HTTPException(
198+
status_code=status.HTTP_400_BAD_REQUEST,
199+
detail=get_login_error(error),
200+
)
201+
202+
# Extract user info from audit result
203+
ol_username = audit.get('ol_username')
204+
if not ol_username:
205+
raise HTTPException(
206+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
207+
detail="Login succeeded but no username found",
208+
)
209+
210+
# Determine cookie expiration
211+
expires = 3600 * 24 * 365 if form_data.remember else ""
212+
213+
# Generate auth token (same way web.py does it via Account.generate_login_code())
214+
login_code = generate_login_code_for_user(ol_username)
215+
216+
# Create response with redirect
217+
response = Response(
218+
status_code=status.HTTP_303_SEE_OTHER,
219+
headers={"Location": form_data.redirect},
220+
)
221+
222+
# Set session cookie (same as web.py)
223+
response.set_cookie(
224+
config.login_cookie_name,
225+
login_code,
226+
max_age=expires,
227+
httponly=True,
228+
secure=False,
229+
)
230+
231+
# Set print disability flag if user has special access
232+
response.set_cookie(
233+
"pd",
234+
str(int(audit.get('special_access', 0))) if audit.get('special_access') else "",
235+
max_age=expires,
236+
)
237+
238+
# Increment stats (same as web.py)
239+
stats.increment('ol.account.xauth.login')
240+
241+
return response
242+
243+
244+
@router.post("/account/logout")
245+
async def logout(request: Request) -> Response:
246+
"""
247+
Logout endpoint - clears authentication cookies.
248+
249+
This mirrors the web.py logout functionality.
250+
"""
251+
252+
response = Response(
253+
status_code=status.HTTP_303_SEE_OTHER,
254+
headers={"Location": "/"},
255+
)
256+
257+
# Clear all auth cookies (same as web.py does)
258+
response.delete_cookie(config.login_cookie_name)
259+
response.delete_cookie("pd")
260+
response.delete_cookie("sfw")
261+
262+
return response

0 commit comments

Comments
 (0)