store login toke expiry correctly in cookies
This commit is contained in:
parent
801edc4042
commit
2a03930b7e
3 changed files with 81 additions and 19 deletions
|
|
@ -1,13 +1,16 @@
|
|||
from typing import Optional
|
||||
import logging
|
||||
import secrets
|
||||
import math
|
||||
from datetime import datetime, UTC, timedelta
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
from contextlib import asynccontextmanager
|
||||
from simple_openid_connect.client import OpenidClient
|
||||
from simple_openid_connect.data import TokenSuccessResponse
|
||||
|
||||
from dooris_api.models import UserStatus
|
||||
from dooris_api import deps
|
||||
from dooris_api.models import UserStatus, UserInfo
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -31,46 +34,74 @@ app = FastAPI(
|
|||
|
||||
|
||||
@app.get("/api/user-info/")
|
||||
async def get_user_info() -> UserStatus:
|
||||
return { "bla": "blub" }
|
||||
async def get_user_info(req: Request, current_user: deps.CurrentUser) -> UserStatus:
|
||||
if current_user is None:
|
||||
return UserStatus(is_logged_in=False, user_info=None)
|
||||
else:
|
||||
return UserStatus(
|
||||
is_logged_in=True,
|
||||
user_info=UserInfo(
|
||||
username=current_user.id_token.preferred_username,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app.get("/auth/login", response_class=RedirectResponse)
|
||||
async def login_init(req: Request, resp: Response, next: Optional[str] = "") -> str:
|
||||
@app.get("/auth/login", response_class=RedirectResponse, status_code=302)
|
||||
async def login_init(req: Request, resp: Response, oidc_client: deps.OpenidClient, next: Optional[str] = "") -> str:
|
||||
# save the ?next url for later redirection if the user requested that
|
||||
if next:
|
||||
resp.set_cookie("auth_next", next, max_age=60 * 10, httponly=True, secure=True, samesite="strict")
|
||||
resp.set_cookie("auth_next", next, max_age=60 * 10, httponly=True, secure=True)
|
||||
|
||||
# save the login state into a cookie to prevent CSRF attacks
|
||||
# ref: https://simple-openid-connect.readthedocs.io/en/stable/nonce_and_state.html
|
||||
state = secrets.token_urlsafe(32)
|
||||
resp.set_cookie("auth_state", state, max_age=60 * 10, httponly=True, secure=True, samesite="strict")
|
||||
resp.set_cookie("auth_state", state, max_age=60 * 10, httponly=True, secure=True)
|
||||
resp.set_cookie("auth_start_time", datetime.now(UTC).timestamp(), max_age=60 * 10, httponly=True, secure=True)
|
||||
|
||||
# prevent replay attacks by generating and specifying a nonce
|
||||
# ref: https://simple-openid-connect.readthedocs.io/en/stable/nonce_and_state.html
|
||||
nonce = secrets.token_urlsafe(32)
|
||||
|
||||
# assemble the response
|
||||
oidc_client = req.app.extra["oidc_client"] # type: OpenidClient
|
||||
resp.set_cookie("auth_nonce", nonce, max_age=60*10, httponly=True, secure=True, samesite="strict")
|
||||
resp.set_cookie("auth_nonce", nonce, max_age=60 * 10, httponly=True, secure=True)
|
||||
|
||||
return oidc_client.authorization_code_flow.start_authentication(state=state, nonce=nonce)
|
||||
|
||||
|
||||
|
||||
@app.get("/auth/login-callback")
|
||||
async def login_callback(req: Request, resp: Response):
|
||||
if "auth_state" not in req.cookies or "auth_nonce" not in req.cookies:
|
||||
async def login_callback(req: Request, resp: Response, oidc_client: deps.OpenidClient):
|
||||
# check that the user is currently in an authenticating state
|
||||
# these cookies are set by the login_init() view
|
||||
if "auth_state" not in req.cookies or "auth_nonce" not in req.cookies or "auth_start_time" not in req.cookies:
|
||||
raise ValueError("user is currently not authentication or the authentication expired. try again")
|
||||
|
||||
# ensure cookies are always cleared in the response
|
||||
resp.set_cookie("auth_state", "", max_age=0)
|
||||
resp.set_cookie("auth_start_time", "", max_age=0)
|
||||
|
||||
oidc_client = req.app.extra["oidc_client"] # type: OpenidClient
|
||||
# perform the actual authentication via OIDC token exchange
|
||||
auth_result = oidc_client.authorization_code_flow.handle_authentication_result(current_url=req.url, state=req.cookies["auth_state"])
|
||||
|
||||
# save the authentication result for later reuse
|
||||
if isinstance(auth_result, TokenSuccessResponse):
|
||||
# extract the ID token once now to validate its authenticity
|
||||
_id_token = oidc_client.decode_id_token(auth_result.id_token, nonce=req.cookies["auth_nonce"])
|
||||
|
||||
resp.set_cookie("access_token", auth_result.access_token, httponly=True, secure=True, samesite="strict")
|
||||
resp.set_cookie("id_token", auth_result.id_token, httponly=False, secure=True, samesite="strict")
|
||||
now = datetime.now(UTC)
|
||||
auth_start_time = datetime.fromtimestamp(float(req.cookies["auth_start_time"]), UTC)
|
||||
|
||||
# extract the ID token now to validate its authenticity and properly set the cookie lifetime
|
||||
id_token = oidc_client.decode_id_token(auth_result.id_token, nonce=req.cookies["auth_nonce"])
|
||||
|
||||
# calculate how long each token is valid
|
||||
at_max_age = auth_start_time - now + timedelta(seconds=auth_result.expires_in)
|
||||
rt_max_age = auth_start_time - now + timedelta(seconds=auth_result.refresh_expires_in)
|
||||
id_max_age = datetime.fromtimestamp(id_token.exp, UTC) - now
|
||||
|
||||
# update cookies
|
||||
resp.set_cookie("access_token", auth_result.access_token, max_age=int(at_max_age.total_seconds()), httponly=True, secure=True)
|
||||
resp.set_cookie("refresh_token", auth_result.refresh_token, max_age=int(rt_max_age.total_seconds()), httponly=True, secure=True)
|
||||
resp.set_cookie("id_token", auth_result.id_token, max_age=int(id_max_age.total_seconds()), httponly=True, secure=True)
|
||||
resp.set_cookie("auth_nonce", req.cookies["auth_nonce"], max_age=int(id_max_age.total_seconds()), httponly=True, secure=True)
|
||||
|
||||
# redirect the user to the page they wanted to visit
|
||||
return {"authenticated": True}
|
||||
else:
|
||||
return {"authenticated": False, "error": auth_result}
|
||||
|
|
|
|||
20
api/src/dooris_api/deps.py
Normal file
20
api/src/dooris_api/deps.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from typing import Annotated, Optional
|
||||
from fastapi import Request, Depends
|
||||
from simple_openid_connect.client import OpenidClient
|
||||
|
||||
from dooris_api import models
|
||||
|
||||
|
||||
async def get_oidc_client(req: Request) -> OpenidClient:
|
||||
return req.app.extra["oidc_client"]
|
||||
|
||||
|
||||
OpenidClient = Annotated[OpenidClient, Depends(get_oidc_client)]
|
||||
|
||||
|
||||
async def get_current_user(req: Request, oidc_client: OpenidClient) -> Optional[models.CurrentUser]:
|
||||
return None
|
||||
|
||||
|
||||
CurrentUser = Annotated[Optional[models.CurrentUser], Depends(get_current_user)]
|
||||
|
||||
|
|
@ -1,8 +1,19 @@
|
|||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
from simple_openid_connect.data import IdToken
|
||||
|
||||
|
||||
class CurrentUser(BaseModel):
|
||||
access_token: str
|
||||
access_token_expiry: datetime
|
||||
refresh_token: Optional[str]
|
||||
refresh_token_expiry: Optional[datetime]
|
||||
id_token: IdToken
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
name: str
|
||||
username: str
|
||||
|
||||
|
||||
class UserStatus(BaseModel):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue