3 |
4 |
5 |
7 |
8 |
9 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
19 | Top
20 |
21 |
22 |
24 | hx-get demo
25 |
26 |
27 |
28 |
29 |
30 |
31 |
33 | Secret#1
34 |
35 |
36 |
38 | Secret#2
39 |
40 |
41 |
42 |
43 |
44 | {#
#}
45 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | {% include 'auth_refresh_token.j2' %}
57 |
58 |
67 |
--------------------------------------------------------------------------------
/templates/auth_refresh_token.j2:
--------------------------------------------------------------------------------
1 |
2 |
3 |
80 |
--------------------------------------------------------------------------------
/templates/auth_navbar.login.j2:
--------------------------------------------------------------------------------
1 | {% include 'auth_navbar.login.callback.j2' %}
2 |
3 | {# {% include 'auth_navbar.login.html.j2' %} #}
4 | {# {% include 'auth_navbar.login.js.j2' %} #}
5 |
6 |
7 |
8 |
9 | {#
10 |
11 | #}
12 |
13 |
14 |
16 |
17 |
18 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
28 | Top
29 |
30 |
31 |
33 | hx-get demo
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
43 |
45 | Secret#1
46 |
47 |
48 |
49 |
51 |
53 | Secret#2
54 |
55 |
56 |
57 |
58 |
59 | {% include 'auth_navbar.login.html.j2' %}
60 | {# {% include 'auth_navbar.login.js.j2' %} #}
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
76 |
77 | {% include 'auth_refresh_token.j2' %}
78 |
79 |
88 |
--------------------------------------------------------------------------------
/admin/debug.py:
--------------------------------------------------------------------------------
1 | from config import settings
2 | from fastapi import APIRouter, HTTPException, Response, Request, Depends, Cookie, Header, Form
3 | from fastapi.templating import Jinja2Templates
4 | from fastapi.responses import JSONResponse, HTMLResponse
5 | from typing import Annotated
6 | from data.db import UserBase
7 | from admin import auth
8 |
9 | from admin.cachestore import CacheStore, get_cache_store
10 |
11 | router = APIRouter()
12 | templates = Jinja2Templates(directory='templates')
13 |
14 | @router.get("/sessions")
15 | async def list_sessions(cs: CacheStore = Depends(get_cache_store)):
16 | return cs.list_sessions()
17 |
18 | @router.get("/env/")
19 | async def env():
20 | print("settings: ", settings)
21 | return {
22 | "origin_server": settings.origin_server,
23 | "google_oauth2_client_id": settings.google_oauth2_client_id,
24 | }
25 |
26 | @router.get("/me")
27 | async def dump_users_info(request: Request, user: UserBase = Depends(auth.get_current_user), cs: CacheStore = Depends(get_cache_store)):
28 | session_id = request.cookies.get("session_id")
29 | session = cs.get_session(session_id)
30 | try:
31 | return {"user": user, "session": session}
32 | except:
33 | return None
34 |
35 | @router.get("/debug_headers")
36 | async def debug_headers(request: Request):
37 | headers = request.headers
38 | print("Headers: ", headers)
39 | return{"Headers": headers}
40 |
41 | @router.get("/refresh_token")
42 | def refresh_token(response: Response,
43 | session_id: Annotated[str | None, Cookie()] = None,
44 | cs: CacheStore = Depends(get_cache_store)):
45 | # print("session_id: ", session_id)
46 | session = cs.get_session(session_id)
47 | if not session:
48 | raise HTTPException(status_code=403, detail="No session found for the session_id: "+session_id)
49 |
50 | try:
51 | new_session = auth.mutate_session(response, session, cs, False)
52 | return {"ok": True, "new_token": new_session["session_id"], "csrf_token": new_session["csrf_token"]}
53 | except HTTPException as e:
54 | return JSONResponse(status_code=e.status_code, content={"detail": e.detail})
55 |
56 | @router.get("/csrf_js")
57 | async def csrf_js_get(request: Request):
58 | context = {"request": request, "url":"/debug/csrf_js"}
59 | return templates.TemplateResponse("debug_csrf_js.j2", context)
60 |
61 | @router.post("/csrf_js")
62 | async def csrf_js_post(x_csrf_token: Annotated[str | None, Header()] = None,
63 | session_id: Annotated[str | None, Cookie()] = None,
64 | cs: CacheStore = Depends(get_cache_store)):
65 | csrf_token = x_csrf_token
66 | return await csrf_post(session_id, cs, csrf_token)
67 |
68 | @router.get("/csrf_html", response_class=HTMLResponse)
69 | async def csrf_html_get(request: Request,
70 | csrf_token: Annotated[str | None, Cookie()] = None):
71 | context = {"request": request, "csrf_token": csrf_token, "url":"/debug/csrf_html"}
72 | response = templates.TemplateResponse("debug_csrf_html.j2", context)
73 | return response
74 |
75 | @router.post("/csrf_html")
76 | async def csrf_html_post(csrf_token: Annotated[str | None, Form()] = None,
77 | session_id: Annotated[str | None, Cookie()] = None,
78 | cs: CacheStore = Depends(get_cache_store)):
79 | return await csrf_post(session_id, cs, csrf_token)
80 |
81 | async def csrf_post(session_id, cs, csrf_token):
82 | session = cs.get_session(session_id)
83 | if not session:
84 | raise HTTPException(status_code=403, detail="No session found for the session_id: "+session_id)
85 | csrf_token = auth.csrf_verify(csrf_token, session)
86 | return {"ok": True, "csrf_token": csrf_token}
87 |
--------------------------------------------------------------------------------
/Readme.md:
--------------------------------------------------------------------------------
1 | # FastAPI + HTMX with Sign in with Google
2 |
3 | ## What this look like
4 |
5 |
6 |
7 | ## Directory structure
8 |
9 | ```
10 | .
11 | ├── Readme.md
12 | ├── admin
13 | │ ├── auth.py
14 | │ ├── debug.py
15 | │ └── user.py
16 | ├── auth
17 | ├── config.py
18 | ├── customer
19 | ├── data
20 | │ ├── cache.db
21 | │ ├── create_data.sh
22 | │ ├── data.db
23 | │ └── db.py
24 | ├── htmx
25 | │ ├── htmx.py
26 | │ └── htmx_secret.py
27 | ├── images
28 | │ ├── FastAPI-HTMX-Google-OAuth01.gif
29 | │ ├── cat_meme.png
30 | │ ├── dog_meme.png
31 | │ ├── door-check-out-icon.png
32 | │ ├── image.py
33 | │ └── unknown-person-icon.png
34 | ├── main.py
35 | └── templates
36 | ├── auth_navbar.login.callback.j2
37 | ├── auth_navbar.login.html.j2
38 | ├── auth_navbar.login.j2
39 | ├── auth_navbar.login.js.j2
40 | ├── auth_navbar.logout.j2
41 | ├── content.error.j2
42 | ├── content.list.j2
43 | ├── content.list.tbody.j2
44 | ├── content.secret.j2
45 | ├── content.top.j2
46 | ├── head.j2
47 | └── spa.j2
48 | ```
49 |
50 | # Howto run app in this repository.
51 |
52 | ## 1.
Setup OAuth configuration on Google APIs console
53 |
54 | 1. Open https://console.cloud.google.com/apis/credentials.
55 | 1. Go CREATE CREDENTIALS -> Go Create OAuth client ID -> Choose "Web applicatin" as Application type -> create.
56 | 1. Save the client ID somewhere, as it is needed later.
57 | 1. Go one of the OAuth 2.0 Client IDs just created, then add both of the following to the Authorized JavaScript origins box.
58 |
59 | ~~~
60 | http://localhost
61 | http://localhost:8000
62 | ~~~
63 |
64 | For details, see https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid.
65 |
66 | ## 2. FastAPI
67 |
68 | Prepare python venv and install packages
69 | ~~~
70 | python3 -m venv .venv
71 | source .venv/bin/activate
72 | pip install fastapi sqlalchemy uvicorn google-auth requests python-dotenv python-multipart pydantic-settings pydantic[email] jinja2 PyJWT redis
73 | ~~~
74 |
75 | Create database
76 | ~~~
77 | rm data/data.db data/cache.db
78 | python3 data/db.py
79 | ./data/create_data.sh
80 | ~~~
81 |
82 | Edit .env in the directory where main.py exists.
83 | ~~~
84 | ORIGIN_SERVER=http://localhost:3000
85 | GOOGLE_OAUTH2_CLIENT_ID=888888888888-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com
86 | ADMIN_EMAIL=admin@example.com
87 | SESSION_MAX_AGE=300
88 | CACHE_STORE=sql
89 | # CACHE_STORE=redis
90 | REDIS_HOST=localhost
91 | REDIS_PORT=6379
92 | ~~~
93 |
94 | Run server
95 | ~~~
96 | uvicorn main:app --host 0.0.0.0 --reload --log-config log_config.yaml
97 | ~~~
98 |
99 | ## (Optional) Redis for Session Storage
100 |
101 | Run redis
102 |
103 | ```
104 | docker compose -f data/docker-compose.yml up -d
105 | ```
106 |
107 | Edit .env file
108 |
109 | ```
110 | # CACHE_STORE=sql
111 | CACHE_STORE=redis
112 | ```
113 |
114 | re-create a session for admin login
115 | ```
116 | ./data/renew_admin_session.sh
117 | ```
118 |
119 | restart uvicon
120 | ```
121 | uvicorn main:app --host 0.0.0.0 --reload --log-config log_config.yaml
122 | ```
123 |
124 | ## (Optional) Monitor Session storage contents
125 |
126 | ### SQLite
127 |
128 | ```
129 | watch -n 1 'echo "select * from sessions" | sqlite3 data/cache.db'
130 | ```
131 |
132 | ### Redis
133 |
134 | If the OS has redis-cli, use the following;
135 |
136 | ```
137 | watch -n 1 'for k in $(redis-cli keys "*" | xargs) ; do echo -n $k": " ; redis-cli get $k|xargs ; done'
138 | ```
139 |
140 | If the OS does not have redis-cli, first exec into the redis docker container,
141 |
142 | ```
143 | $ docker ps
144 | CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
145 | 29a78e16ec02 redis "docker-entrypoint.s…" 7 days ago Up 7 days 0.0.0.0:6379->6379/tcp, :::6379->6379/tcp data-redis-1
146 |
147 | $ docker exec -it data-redis-1 bash
148 | ```
149 |
150 | then monitor the contents using redis-cli, for example;
151 |
152 | ```
153 | while true ; do sleep 1 ;clear; for k in $(redis-cli keys "*" | xargs) ; do echo -n $k": " ; redis-cli get $k|xargs ;done ; done
154 | ```
155 |
--------------------------------------------------------------------------------
/admin/cachestore.py:
--------------------------------------------------------------------------------
1 | import secrets, json
2 | import redis
3 | from typing import Optional, Dict, List
4 | from datetime import datetime, timezone, timedelta
5 | from abc import ABC, abstractmethod
6 | from fastapi import HTTPException, status
7 | from sqlalchemy.exc import SQLAlchemyError, NoResultFound
8 |
9 | from data.db import Sessions, get_cache
10 | from config import settings
11 |
12 | class CacheStore(ABC):
13 |
14 | @abstractmethod
15 | def get_session(self, session_id: str) -> Optional[Dict]:
16 | pass
17 |
18 | def list_sessions(self) -> List[Dict]:
19 | pass
20 |
21 | @abstractmethod
22 | def create_session(self, user_id: int, email: str) -> Dict:
23 | pass
24 |
25 | @abstractmethod
26 | def delete_session(self, session_id: str) -> None:
27 | pass
28 |
29 | @abstractmethod
30 | def cleanup_sessions(self) -> None:
31 | pass
32 |
33 | class SQLCacheStore(CacheStore):
34 | def __init__(self, db_session):
35 | self.cs = db_session
36 |
37 | def get_session(self, session_id: str) -> Optional[Dict]:
38 | try:
39 | session_data = self.cs.query(Sessions).filter(Sessions.session_id == session_id).one()
40 | except NoResultFound:
41 | return None
42 | except SQLAlchemyError as e:
43 | print(f"An error occurred while retrieving the session: {e}")
44 | raise RuntimeError(f"An error occurred while retrieving the session: {e}")
45 |
46 | if session_data:
47 | session = session_data.__dict__
48 | print("session: ", session)
49 | print("session_id: ", session["session_id"])
50 | return session
51 | else:
52 | return None
53 |
54 | def list_sessions(self) -> List[Dict]:
55 | sessions = self.cs.query(Sessions).offset(0).limit(100).all()
56 | return [session.__dict__ for session in sessions]
57 |
58 | def create_session(self, user_id: int, email: str) -> Dict:
59 | session_id = secrets.token_urlsafe(64)
60 | csrf_token = secrets.token_urlsafe(32)
61 |
62 | session = self.get_session(session_id)
63 | if session:
64 | raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Duplicate session_id")
65 |
66 | expires = int((datetime.now(timezone.utc)
67 | + timedelta(seconds=settings.session_max_age)).timestamp())
68 |
69 | session_entry = Sessions(session_id=session_id, csrf_token=csrf_token,
70 | user_id=user_id, email=email, expires=expires)
71 | self.cs.add(session_entry)
72 | self.cs.commit()
73 | self.cs.refresh(session_entry)
74 | return session_entry.__dict__
75 |
76 | def delete_session(self, session_id: str) -> None:
77 | session = self.cs.query(Sessions).filter(Sessions.session_id == session_id).first()
78 | # session is not dict
79 | if session.email == settings.admin_email:
80 | return
81 | if session:
82 | self.cs.delete(session)
83 | self.cs.commit()
84 |
85 | def cleanup_sessions(self) -> None:
86 | now = int(datetime.now().timestamp())
87 | expired_sessions = self.cs.query(Sessions).filter(Sessions.expires <= now).all()
88 | for session in expired_sessions:
89 | print("Cleaning up expired session: ", session.session_id)
90 | self.cs.delete(session)
91 | self.cs.commit()
92 |
93 | class RedisCacheStore(CacheStore):
94 |
95 | def __init__(self):
96 | self.redis_client = redis.Redis(
97 | host=settings.redis_host,
98 | port=settings.redis_port,
99 | db=0,
100 | decode_responses=True)
101 |
102 | def get_session(self, session_id: str) -> Optional[dict]:
103 | session_data = self.redis_client.get(f"session:{session_id}")
104 | session = json.loads(session_data) if session_data else None
105 | if session:
106 | print("session: ", session)
107 | print("session_id: ", session["session_id"])
108 | return session
109 |
110 | def list_sessions(self) -> List[Dict]:
111 | sessions = []
112 | for key in self.redis_client.scan_iter(match="session:*"):
113 | session_data = self.redis_client.get(key)
114 | session = json.loads(session_data)
115 | sessions.append(session)
116 | return sessions
117 |
118 | def create_session(self, user_id: int, email: str) -> Dict:
119 | session_id = secrets.token_urlsafe(64)
120 | csrf_token = secrets.token_urlsafe(32)
121 | expires = settings.session_max_age
122 | session_data = {
123 | "session_id": session_id,
124 | "csrf_token": csrf_token,
125 | "user_id": user_id,
126 | "email": email,
127 | "expires": int((datetime.now(timezone.utc) + timedelta(seconds=expires)).timestamp())
128 | }
129 | self.redis_client.setex(f"session:{session_id}", expires, json.dumps(session_data))
130 | return session_data
131 |
132 | def delete_session(self, session_id: str) -> None:
133 | session = self.get_session(session_id)
134 | # session is dict
135 | if session["email"] == settings.admin_email:
136 | return
137 | self.redis_client.delete(f"session:{session_id}")
138 |
139 | def cleanup_sessions(self) -> None:
140 | # Redis handles session expiration automatically based on the TTL set during creation.
141 | pass
142 |
143 | def get_cache_store() -> CacheStore:
144 | if settings.cache_store == 'redis':
145 | return RedisCacheStore()
146 | elif settings.cache_store == 'sql':
147 | cs = next(get_cache())
148 | return SQLCacheStore(cs)
149 |
150 |
--------------------------------------------------------------------------------
/admin/auth.py:
--------------------------------------------------------------------------------
1 | import urllib.parse
2 | import hashlib, hmac, base64
3 | from datetime import datetime, timezone, timedelta
4 | from fastapi import Depends, APIRouter, HTTPException, status, Response, Request, BackgroundTasks, Header, Cookie
5 | from fastapi.responses import JSONResponse, HTMLResponse
6 | from sqlalchemy.orm import Session
7 | from data.db import User, UserBase
8 | from data.db import get_db
9 | from admin.user import create as GetOrCreateUser
10 |
11 | from typing import Annotated
12 | from fastapi.security import APIKeyCookie
13 |
14 | from google.oauth2 import id_token
15 | from google.auth.transport import requests
16 | from config import settings
17 |
18 | from admin.cachestore import CacheStore, get_cache_store
19 |
20 | from fastapi.templating import Jinja2Templates
21 |
22 | router = APIRouter()
23 | templates = Jinja2Templates(directory='templates')
24 |
25 | cookie_scheme = APIKeyCookie(name="session_id", description="Admin session_id is created by create_session.sh")
26 |
27 | def hash_email(email: str):
28 | # return hashlib.sha256(email.encode()).hexdigest()
29 | return base64.urlsafe_b64encode(hashlib.sha256(email.encode()).digest()).decode()
30 |
31 | def mutate_session(response: Response, old_session: dict, cs: CacheStore, immediate: bool = False):
32 | if not old_session:
33 | raise HTTPException(status_code=404, detail="Session not found")
34 | if old_session["email"] == settings.admin_email:
35 | return old_session
36 |
37 | age_left = old_session["expires"] - int(datetime.now(timezone.utc).timestamp())
38 | if not immediate and age_left*2 > settings.session_max_age:
39 | print("Session still has much time: ", age_left, " seconds left.")
40 | return old_session
41 |
42 | print("Session expires soon in", age_left, ". Mutating the session.")
43 | session = cs.create_session(old_session["user_id"], old_session["email"])
44 | new_cookie(response, session)
45 | cs.delete_session(old_session["session_id"])
46 | return session
47 |
48 | def new_cookie(response: Response, session: dict):
49 | if not session:
50 | raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="No session provided")
51 |
52 | max_age = settings.session_max_age
53 | samesite = "Strict" # For normal web pages, it is better to use Strict.
54 | # samesite = "Lax" For API server, it is better to use Lax.
55 | # !!! Warining; It is very important to set httponly=True for the session_id cookie!!!
56 | response.set_cookie(key="session_id", value=session["session_id"], httponly=True,
57 | samesite=samesite, secure=True, max_age=max_age, expires=session["expires"])
58 | response.set_cookie(key="csrf_token", value=session["csrf_token"], httponly=False,
59 | samesite=samesite, secure=True, max_age=max_age, expires=session["expires"])
60 | response.set_cookie(key="user_token", value=hash_email(session["email"]), httponly=False,
61 | samesite=samesite, secure=True, max_age=max_age, expires=session["expires"])
62 | return response
63 |
64 | def delete_cookie(response: Response):
65 | max_age = 0
66 | expires = datetime.now(timezone.utc) + timedelta(seconds=max_age)
67 | samesite = "Strict" # For normal web pages, it is better to use Strict.
68 | # samesite = "Lax" For API server, it is better to use Lax.
69 | response.set_cookie(key="session_id", value="", httponly=True,
70 | samesite=samesite, secure=True, max_age=max_age, expires=expires,)
71 | response.set_cookie(key="csrf_token", value="", httponly=False,
72 | samesite=samesite, secure=True, max_age=max_age, expires=expires,)
73 | response.set_cookie(key="user_token", value="", httponly=False,
74 | samesite=samesite, secure=True, max_age=max_age, expires=expires,)
75 | return response
76 |
77 | def csrf_verify(csrf_token: str, session: dict):
78 | print("### Debug: csrf_verify: ", csrf_token)
79 | if hmac.compare_digest(csrf_token, session['csrf_token']):
80 | # if csrf_token == session['csrf_token']:
81 | return csrf_token
82 | else:
83 | raise HTTPException(status_code=403, detail="CSRF token: "+csrf_token+" did not match the record.")
84 |
85 | def user_verify(user_token: str, session: dict):
86 | print("### Debug: user_verify: ", user_token)
87 | if hmac.compare_digest(user_token, hash_email(session['email'])):
88 | # if user_token == hash_email(session["email"]):
89 | return user_token
90 | else:
91 | raise HTTPException(status_code=403, detail="USER token: "+user_token+" did not match the record.")
92 |
93 | def get_user_by_user_id(user_id: int, ds: Session):
94 | user=ds.query(User).filter(User.id==user_id).first().__dict__
95 | return user
96 |
97 | async def get_current_user(session_id: str = Depends(cookie_scheme),
98 | ds: Session = Depends(get_db), cs: CacheStore = Depends(get_cache_store)):
99 | if not session_id:
100 | return None
101 |
102 | session = cs.get_session(session_id)
103 | if not session:
104 | print("get_current_user: No session found for the session_id: ", session_id)
105 | return None
106 | # raise HTTPException(status_code=403, detail="No session found for the session_id: "+session_id)
107 |
108 | user_dict = get_user_by_user_id(session["user_id"], ds)
109 | user=UserBase(**user_dict)
110 | return user
111 |
112 | async def is_authenticated(session_id: str = Depends(cookie_scheme),
113 | ds: Session = Depends(get_db), cs: CacheStore = Depends(get_cache_store)):
114 |
115 | user = await get_current_user(session_id=session_id, cs=cs, ds=ds)
116 |
117 | if not user:
118 | raise HTTPException(
119 | status_code=status.HTTP_401_UNAUTHORIZED,
120 | detail="NotAuthenticated"
121 | )
122 | elif user.disabled:
123 | raise HTTPException(
124 | status_code=status.HTTP_403_FORBIDDEN,
125 | detail="Disabled user"
126 | )
127 | else:
128 | print("Authenticated.")
129 | return JSONResponse({"message": "Authenticated"})
130 |
131 | class RequiresLogin(Exception):
132 | pass
133 |
134 | async def is_authenticated_admin(
135 | session_id: Annotated[str | None, Cookie()] = None,
136 | ds: Session = Depends(get_db),
137 | cs: CacheStore = Depends(get_cache_store)
138 | ):
139 | user = await get_current_user(session_id=session_id, cs=cs, ds=ds)
140 | if not user:
141 | raise RequiresLogin("You must log in as Admin")
142 | if not user.disabled and user.admin:
143 | print("Authenticated as Admin.")
144 | return JSONResponse({"message": "Authenticated Admin"})
145 | raise RequiresLogin("You must log in as Admin")
146 |
147 | async def VerifyToken(jwt: str):
148 | try:
149 | idinfo = id_token.verify_oauth2_token(
150 | jwt,
151 | requests.Request(),
152 | settings.google_oauth2_client_id)
153 | except ValueError:
154 | print("Error: Failed to validate JWT token with GOOGLE_OAUTH2_CLIENT_ID=" + settings.google_oauth2_client_id +".")
155 | return None
156 |
157 | print("idinfo: ", idinfo)
158 | return idinfo
159 |
160 | @router.post("/login")
161 | async def login(request: Request, ds: Session = Depends(get_db), cs: CacheStore = Depends(get_cache_store)):
162 |
163 | body = await request.body()
164 | jwt = dict(urllib.parse.parse_qsl(body.decode('utf-8'))).get('credential')
165 |
166 | idinfo = await VerifyToken(jwt)
167 | if not idinfo:
168 | print("Error: Failed to validate JWT token")
169 | return Response("Error: Failed to validate JWT token")
170 |
171 | expected_nonce = request.session.get('expected_nonce')
172 | if not expected_nonce or idinfo['nonce'] != expected_nonce:
173 | raise HTTPException(status_code=400, detail="Invalid nonce")
174 |
175 | request.session['expected_nonce'] = None
176 |
177 | user = await GetOrCreateUser(idinfo, ds)
178 | if not user:
179 | print("Error: Failed to GetOrCreateUser")
180 | return Response("Error: Failed to GetOrCreateUser for the JWT")
181 |
182 | response = JSONResponse({"Authenticated_as": user.name})
183 | session = cs.create_session(user.id, user.email)
184 | new_cookie(response, session)
185 |
186 | response.headers["HX-Trigger"] = "ReloadNavbar"
187 | return response
188 |
189 | @router.get("/logout")
190 | async def logout(response: Response,
191 | session_id: Annotated[str | None, Cookie()] = None,
192 | hx_request: Annotated[str | None, Header()] = None,
193 | cs: CacheStore = Depends(get_cache_store)):
194 |
195 | if not hx_request:
196 | raise HTTPException(
197 | status_code=status.HTTP_400_BAD_REQUEST,
198 | detail="Only HX request is allowed to this end point."
199 | )
200 |
201 | response = JSONResponse({"message": "user logged out"})
202 | response.headers["HX-Trigger"] = "ReloadNavbar, LogoutSecretContent"
203 | cs.delete_session(session_id)
204 | delete_cookie(response)
205 | return response
206 |
207 | @router.get("/auth_navbar", response_class=HTMLResponse)
208 | async def auth_navbar(request: Request,
209 | session_id: Annotated[str|None, Cookie()] = None,
210 | hx_request: Annotated[str|None, Header()] = None,
211 | ds: Session = Depends(get_db), cs: CacheStore = Depends(get_cache_store)
212 | ):
213 |
214 | if not hx_request:
215 | raise HTTPException(
216 | status_code=status.HTTP_400_BAD_REQUEST,
217 | detail="Only HX request is allowed to this end point."
218 | )
219 |
220 | user = await get_current_user(session_id=session_id, cs=cs, ds=ds)
221 |
222 | # For authenticated users, return the menu.logout component.
223 | if user:
224 | logout_url = "/auth/logout"
225 | icon_url = "/img/logout.png"
226 | refresh_token_url = "/auth/refresh_token"
227 | mutate_user_url = "/auth/mutate_user"
228 |
229 | context = {"request": request, "logout_url":logout_url,
230 | "icon_url": icon_url, "refresh_token_url": refresh_token_url, "mutate_user_url": mutate_user_url,
231 | "name": user.name, "picture": user.picture, "userToken": hash_email(user.email)}
232 | return templates.TemplateResponse("auth_navbar.logout.j2", context)
233 |
234 | print("User not logged-in.")
235 | cs.cleanup_sessions()
236 |
237 | # For unauthenticated users, return the menu.login component.
238 | client_id = settings.google_oauth2_client_id
239 | login_url = "/auth/login"
240 | icon_url = "/img/icon.png"
241 | refresh_token_url = "/auth/refresh_token"
242 | mutate_user_url = "/auth/mutate_user"
243 | nonce = base64.urlsafe_b64encode(hashlib.sha256(str(datetime.now()).encode()).digest()).decode()
244 |
245 | request.session['expected_nonce'] = nonce
246 |
247 | context = {"request": request, "client_id": client_id, "login_url": login_url,
248 | "icon_url": icon_url, "refresh_token_url": refresh_token_url, "mutate_user_url": mutate_user_url,
249 | "userToken": "anonymous", "nonce": nonce}
250 | response = templates.TemplateResponse("auth_navbar.login.j2", context)
251 | return response
252 |
253 | @router.get("/check")
254 | async def check(response: Response,
255 | session_id: Annotated[str|None, Cookie()] = None,
256 | hx_request: Annotated[str|None, Header()] = None,
257 | ds: Session = Depends(get_db), cs: CacheStore = Depends(get_cache_store)):
258 |
259 | if not hx_request:
260 | raise HTTPException(
261 | status_code=status.HTTP_400_BAD_REQUEST,
262 | detail="Only HX request is allowed to this end point."
263 | )
264 |
265 | user = await get_current_user(session_id=session_id, cs=cs, ds=ds)
266 |
267 | if not user:
268 | response = JSONResponse({"message": "user logged out"})
269 | response.headers["HX-Trigger"] = "ReloadNavbar, LogoutSecretContent"
270 | return response
271 |
272 | return Response(status_code=status.HTTP_204_NO_CONTENT)
273 |
274 | @router.get("/refresh_token")
275 | async def refresh_token(response: Response,
276 | hx_request: Annotated[str | None, Header()] = None,
277 | session_id: Annotated[str | None, Cookie()] = None,
278 | x_csrf_token: Annotated[str | None, Header()] = None,
279 | x_user_token: Annotated[str | None, Header()] = None,
280 | cs: CacheStore = Depends(get_cache_store)):
281 |
282 | if not hx_request:
283 | raise HTTPException(
284 | status_code=status.HTTP_400_BAD_REQUEST,
285 | detail="Only HX request is allowed to this end point.")
286 |
287 | if not session_id:
288 | raise HTTPException(status_code=403, detail="No session_id in the request")
289 |
290 | session = cs.get_session(session_id)
291 | if not session:
292 | print("refresh_token: No session found for the session_id: ", session_id)
293 | raise HTTPException(status_code=403, detail="No session found for the session_id: "+session_id)
294 |
295 | try:
296 | csrf_verify(x_csrf_token, session)
297 | user_verify(x_user_token, session)
298 | new_session = mutate_session(response, session, cs, False)
299 | if new_session != session:
300 | print("Session mutated, new_session: ", new_session)
301 | response.headers["HX-Trigger"] = "ReloadNavbar"
302 | return {"ok": True, "new_session_id": new_session["session_id"]}
303 | except HTTPException as e:
304 | response = JSONResponse(status_code=e.status_code, content={"detail": e.detail})
305 | response.headers["HX-Trigger"] = "ReloadNavbar, LogoutSecretContent"
306 | return response
307 |
308 | @router.get("/mutate_user")
309 | async def mutate_user(
310 | hx_request: Annotated[str | None, Header()] = None,
311 | x_user_token: Annotated[str | None, Header()] = None,
312 | ):
313 |
314 | if not hx_request:
315 | raise HTTPException(
316 | status_code=status.HTTP_400_BAD_REQUEST,
317 | detail="Only HX request is allowed to this end point.")
318 |
319 | response = JSONResponse({"User mutated, new user": x_user_token})
320 | response.headers["HX-Trigger"] = "ReloadNavbar, LogoutSecretContent"
321 | return response
322 |
323 | @router.get("/logout_content")
324 | async def logout_content(request: Request,
325 | hx_request: Annotated[str|None, Header()] = None):
326 | if not hx_request:
327 | raise HTTPException(
328 | status_code=status.HTTP_400_BAD_REQUEST,
329 | detail="Only HX request is allowed to this end point."
330 | )
331 |
332 | context = {"request": request, "message": "User logged out"}
333 | return templates.TemplateResponse("content.error.j2", context)
334 |
335 | @router.get("/cleanup_sessions")
336 | async def cleanup_sessions(
337 | background_tasks: BackgroundTasks,
338 | session_id: Annotated[str|None, Cookie()] = None,
339 | cs: CacheStore = Depends(get_cache_store)):
340 | if not session_id:
341 | return {"message": "Session CleanUp not triggered. Please login first."}
342 |
343 | background_tasks.add_task(cs.cleanup_sessions)
344 | return {"message": "Session CleanUp triggered."}
345 |
--------------------------------------------------------------------------------