Skip to content

Commit f98e835

Browse files
committed
WIP: use HTTP-only cookie for authentication instead of sending the token in plain text
1 parent d1df85e commit f98e835

File tree

8 files changed

+105
-33
lines changed

8 files changed

+105
-33
lines changed

backend/app/api/deps.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from collections.abc import Generator
2-
from typing import Annotated
2+
from typing import Annotated, Optional
33

44
import jwt
5-
from fastapi import Depends, HTTPException, status
6-
from fastapi.security import OAuth2PasswordBearer
5+
from fastapi import Depends, HTTPException, status, Cookie
6+
from fastapi.security import OAuth2PasswordBearer, APIKeyCookie
77
from jwt.exceptions import InvalidTokenError
88
from pydantic import ValidationError
99
from sqlmodel import Session
@@ -16,7 +16,7 @@
1616
reusable_oauth2 = OAuth2PasswordBearer(
1717
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
1818
)
19-
19+
cookie_scheme = APIKeyCookie(name="http_only_auth_cookie")
2020

2121
def get_db() -> Generator[Session, None, None]:
2222
with Session(engine) as session:
@@ -27,27 +27,42 @@ def get_db() -> Generator[Session, None, None]:
2727
TokenDep = Annotated[str, Depends(reusable_oauth2)]
2828

2929

30-
def get_current_user(session: SessionDep, token: TokenDep) -> User:
30+
def get_current_user(
31+
session: SessionDep,
32+
http_only_auth_cookie: str = Depends(cookie_scheme),
33+
) -> User:
34+
print("start get_current_user...")
35+
if not http_only_auth_cookie:
36+
raise HTTPException(
37+
status_code=status.HTTP_401_UNAUTHORIZED,
38+
detail="Not authenticated",
39+
)
40+
3141
try:
3242
payload = jwt.decode(
33-
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
43+
http_only_auth_cookie,
44+
settings.SECRET_KEY,
45+
algorithms=[security.ALGORITHM]
3446
)
3547
token_data = TokenPayload(**payload)
48+
print(f"get_current_user token data: {token_data}")
3649
except (InvalidTokenError, ValidationError):
3750
raise HTTPException(
3851
status_code=status.HTTP_403_FORBIDDEN,
3952
detail="Could not validate credentials",
4053
)
54+
4155
user = session.get(User, token_data.sub)
4256
if not user:
4357
raise HTTPException(status_code=404, detail="User not found")
4458
if not user.is_active:
4559
raise HTTPException(status_code=400, detail="Inactive user")
60+
4661
return user
4762

4863

4964
CurrentUser = Annotated[User, Depends(get_current_user)]
50-
65+
print(f"CurrentUser {CurrentUser}")
5166

5267
def get_current_active_superuser(current_user: CurrentUser) -> User:
5368
if not current_user.is_superuser:

backend/app/api/routes/login.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@
22
from typing import Annotated, Any
33

44
from fastapi import APIRouter, Depends, HTTPException
5-
from fastapi.responses import HTMLResponse
5+
from fastapi.responses import HTMLResponse, JSONResponse
66
from fastapi.security import OAuth2PasswordRequestForm
7-
87
from app import crud
98
from app.api.deps import CurrentUser, SessionDep, get_current_active_superuser
109
from app.core import security
@@ -24,7 +23,7 @@
2423
@router.post("/login/access-token")
2524
def login_access_token(
2625
session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()]
27-
) -> Token:
26+
) -> JSONResponse:
2827
"""
2928
OAuth2 compatible token login, get an access token for future requests
3029
"""
@@ -36,11 +35,12 @@ def login_access_token(
3635
elif not user.is_active:
3736
raise HTTPException(status_code=400, detail="Inactive user")
3837
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
39-
return Token(
40-
access_token=security.create_access_token(
41-
user.id, expires_delta=access_token_expires
42-
)
43-
)
38+
return security.set_response_cookie(user.id, access_token_expires)
39+
# return Token(
40+
# access_token=security.create_access_token(
41+
# user.id, expires_delta=access_token_expires
42+
# ))
43+
4444

4545

4646
@router.post("/login/test-token", response_model=UserPublic)

backend/app/api/routes/users.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ def read_user_me(current_user: CurrentUser) -> Any:
122122
"""
123123
Get current user.
124124
"""
125+
print("read_user_me!!!!!!!")
125126
return current_user
126127

127128

backend/app/core/security.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
from datetime import datetime, timedelta, timezone
22
from typing import Any
3-
43
import jwt
54
from passlib.context import CryptContext
6-
5+
from fastapi.responses import JSONResponse
6+
from fastapi import Response
77
from app.core.config import settings
88

99
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
1010

11-
1211
ALGORITHM = "HS256"
1312

1413

@@ -19,6 +18,23 @@ def create_access_token(subject: str | Any, expires_delta: timedelta) -> str:
1918
return encoded_jwt
2019

2120

21+
def set_response_cookie(subject: str | Any, expires_delta: timedelta) -> Response:
22+
access_token = create_access_token(subject, expires_delta)
23+
response = JSONResponse(
24+
content={"message": "Login successful"}
25+
)
26+
response.set_cookie(
27+
key="http_only_auth_cookie",
28+
value=access_token,
29+
httponly=True,
30+
max_age=3600,
31+
expires=3600,
32+
samesite="lax",
33+
secure=False,
34+
)
35+
return response
36+
37+
2238
def verify_password(plain_password: str, hashed_password: str) -> bool:
2339
return pwd_context.verify(plain_password, hashed_password)
2440

frontend/src/client/core/ApiRequestOptions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,5 @@ export type ApiRequestOptions<T = unknown> = {
1818
readonly responseHeader?: string
1919
readonly responseTransformer?: (data: unknown) => Promise<T>
2020
readonly url: string
21+
readonly withCredentials?: boolean
2122
}

frontend/src/client/core/request.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -132,13 +132,9 @@ export const getHeaders = async <T>(
132132
options: ApiRequestOptions<T>,
133133
): Promise<Record<string, string>> => {
134134
const [token, username, password, additionalHeaders] = await Promise.all([
135-
// @ts-ignore
136135
resolve(options, config.TOKEN),
137-
// @ts-ignore
138136
resolve(options, config.USERNAME),
139-
// @ts-ignore
140137
resolve(options, config.PASSWORD),
141-
// @ts-ignore
142138
resolve(options, config.HEADERS),
143139
])
144140

@@ -178,6 +174,8 @@ export const getHeaders = async <T>(
178174
} else if (options.formData !== undefined) {
179175
if (options.mediaType) {
180176
headers["Content-Type"] = options.mediaType
177+
} else {
178+
headers["Content-Type"] = "application/x-www-form-urlencoded"
181179
}
182180
}
183181

@@ -203,15 +201,41 @@ export const sendRequest = async <T>(
203201
): Promise<AxiosResponse<T>> => {
204202
const controller = new AbortController()
205203

204+
// Properly handle form data for URL-encoded submissions
205+
let data = body;
206+
207+
// If we have formData but it's not a FormData instance,
208+
// and Content-Type is application/x-www-form-urlencoded
209+
if (options.formData && !isFormData(options.formData) &&
210+
headers["Content-Type"] === "application/x-www-form-urlencoded") {
211+
// Use URLSearchParams or axios's built-in handling for url-encoded data
212+
const params = new URLSearchParams();
213+
Object.entries(options.formData).forEach(([key, value]) => {
214+
if (value !== undefined && value !== null) {
215+
params.append(key, String(value));
216+
}
217+
});
218+
data = params;
219+
} else {
220+
data = body ?? formData;
221+
}
222+
206223
let requestConfig: AxiosRequestConfig = {
207-
data: body ?? formData,
224+
data: data,
208225
headers,
209226
method: options.method,
210227
signal: controller.signal,
211228
url,
212-
withCredentials: config.WITH_CREDENTIALS,
229+
withCredentials: options.withCredentials ?? config.WITH_CREDENTIALS,
213230
}
214231

232+
console.log("Request config:", JSON.stringify({
233+
url: requestConfig.url,
234+
method: requestConfig.method,
235+
headers: requestConfig.headers,
236+
withCredentials: requestConfig.withCredentials
237+
}));
238+
215239
onCancel(() => controller.abort())
216240

217241
for (const fn of config.interceptors.request._fns) {
@@ -367,15 +391,14 @@ export const request = <T>(
367391
if (options.responseTransformer && isSuccess(response.status)) {
368392
transformedBody = await options.responseTransformer(responseBody)
369393
}
370-
371394
const result: ApiResult = {
372395
url,
373396
ok: isSuccess(response.status),
374397
status: response.status,
375398
statusText: response.statusText,
376399
body: responseHeader ?? transformedBody,
377400
}
378-
401+
console.log(result)
379402
catchErrorCodes(options, result)
380403

381404
resolve(result.body)

frontend/src/client/sdk.gen.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,11 @@ export class LoginService {
183183
method: "POST",
184184
url: "/api/v1/login/access-token",
185185
formData: data.formData,
186+
headers: {
187+
"Content-Type": "application/x-www-form-urlencoded",
188+
},
186189
mediaType: "application/x-www-form-urlencoded",
190+
withCredentials: true,
187191
errors: {
188192
422: "Validation Error",
189193
},
@@ -200,6 +204,7 @@ export class LoginService {
200204
return __request(OpenAPI, {
201205
method: "POST",
202206
url: "/api/v1/login/test-token",
207+
withCredentials: true,
203208
})
204209
}
205210

@@ -326,12 +331,20 @@ export class UsersService {
326331
* @returns UserPublic Successful Response
327332
* @throws ApiError
328333
*/
329-
public static readUserMe(): CancelablePromise<UsersReadUserMeResponse> {
330-
return __request(OpenAPI, {
331-
method: "GET",
332-
url: "/api/v1/users/me",
333-
})
334-
}
334+
// public static readUserMe(): CancelablePromise<UsersReadUserMeResponse> {
335+
// console.log("readUserMe")
336+
// let r = __request(OpenAPI, {
337+
// method: "GET",
338+
// url: "/api/v1/users/me",
339+
// withCredentials: true,
340+
// })
341+
// console.log(r.promise)
342+
// return __request(OpenAPI, {
343+
// method: "GET",
344+
// url: "/api/v1/users/me",
345+
// withCredentials: true,
346+
// })
347+
// }
335348

336349
/**
337350
* Delete User Me

frontend/src/client/types.gen.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ export type Token = {
4949
token_type?: string
5050
}
5151

52+
export type HTTPOnlyCookie = {
53+
message: string
54+
}
5255
export type UpdatePassword = {
5356
current_password: string
5457
new_password: string
@@ -136,7 +139,7 @@ export type LoginLoginAccessTokenData = {
136139
formData: Body_login_login_access_token
137140
}
138141

139-
export type LoginLoginAccessTokenResponse = Token
142+
export type LoginLoginAccessTokenResponse = HTTPOnlyCookie
140143

141144
export type LoginTestTokenResponse = UserPublic
142145

0 commit comments

Comments
 (0)