From 6d09e40f58caa79350083bb22bb2492e2a0da850 Mon Sep 17 00:00:00 2001 From: derekc Date: Thu, 19 Mar 2026 23:32:08 -0700 Subject: [PATCH] Remove admin token from sessionStorage during impersonation Embed admin_id claim in impersonation JWTs and add a backend /api/admin/unimpersonate endpoint that re-issues the admin token from that claim. The admin token no longer needs to be stored in sessionStorage, eliminating the risk of token theft via XSS. Co-Authored-By: Claude Sonnet 4.6 --- backend/auth.py | 17 ++++++++++++++++- backend/routers/admin.py | 20 ++++++++++++++++++-- nginx/html/js/admin.js | 2 -- nginx/html/js/auth.js | 20 +++++++++++--------- 4 files changed, 45 insertions(+), 14 deletions(-) diff --git a/backend/auth.py b/backend/auth.py index e2bbef6..83d9c91 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -27,7 +27,7 @@ def hash_password(password: str) -> str: return pwd_context.hash(password) -def create_access_token(user_id: int, username: str, is_admin: bool, user_timezone: str = "UTC") -> str: +def create_access_token(user_id: int, username: str, is_admin: bool, user_timezone: str = "UTC", admin_id: Optional[int] = None) -> str: expire = datetime.now(timezone.utc) + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS) payload = { "sub": str(user_id), @@ -36,9 +36,24 @@ def create_access_token(user_id: int, username: str, is_admin: bool, user_timezo "timezone": user_timezone, "exp": expire, } + if admin_id is not None: + payload["admin_id"] = admin_id return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) +async def get_token_payload( + token: str = Depends(oauth2_scheme), +) -> dict: + try: + return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + async def get_current_user( token: str = Depends(oauth2_scheme), db: Session = Depends(get_db), diff --git a/backend/routers/admin.py b/backend/routers/admin.py index 3507d52..72c82e0 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import Session from database import get_db from models import User from schemas import UserCreate, UserOut, ResetPasswordRequest, TokenResponse -from auth import hash_password, create_access_token, get_current_admin +from auth import hash_password, create_access_token, get_current_admin, get_token_payload router = APIRouter(prefix="/api/admin", tags=["admin"]) logger = logging.getLogger("yolkbook") @@ -113,6 +113,22 @@ def impersonate_user( user = db.get(User, user_id) if not user: raise HTTPException(status_code=404, detail="User not found") - token = create_access_token(user.id, user.username, user.is_admin, user.timezone) + token = create_access_token(user.id, user.username, user.is_admin, user.timezone, admin_id=current_admin.id) logger.warning("Admin '%s' (id=%d) is impersonating user '%s' (id=%d).", current_admin.username, current_admin.id, user.username, user.id) return TokenResponse(access_token=token) + + +@router.post("/unimpersonate", response_model=TokenResponse) +def unimpersonate( + payload: dict = Depends(get_token_payload), + db: Session = Depends(get_db), +): + admin_id = payload.get("admin_id") + if not admin_id: + raise HTTPException(status_code=400, detail="Not in an impersonation session") + admin = db.get(User, int(admin_id)) + if not admin or admin.is_disabled or not admin.is_admin: + raise HTTPException(status_code=403, detail="Original admin account is no longer valid") + token = create_access_token(admin.id, admin.username, admin.is_admin, admin.timezone) + logger.warning("Admin '%s' (id=%d) ended impersonation session.", admin.username, admin.id) + return TokenResponse(access_token=token) diff --git a/nginx/html/js/admin.js b/nginx/html/js/admin.js index 8e2bbb5..27b48ec 100644 --- a/nginx/html/js/admin.js +++ b/nginx/html/js/admin.js @@ -118,8 +118,6 @@ async function toggleUser(id, disable) { async function impersonateUser(id) { try { const data = await API.post(`/api/admin/users/${id}/impersonate`, {}); - // Save admin token so user can return - sessionStorage.setItem('admin_token', Auth.getToken()); Auth.setToken(data.access_token); window.location.href = '/'; } catch (err) { diff --git a/nginx/html/js/auth.js b/nginx/html/js/auth.js index 6dc6947..4905936 100644 --- a/nginx/html/js/auth.js +++ b/nginx/html/js/auth.js @@ -39,16 +39,18 @@ const Auth = { logout() { this.removeToken(); - sessionStorage.removeItem('admin_token'); window.location.href = '/login'; }, }; -function returnToAdmin() { - const adminToken = sessionStorage.getItem('admin_token'); - Auth.setToken(adminToken); - sessionStorage.removeItem('admin_token'); - window.location.href = '/admin'; +async function returnToAdmin() { + try { + const data = await API.post('/api/admin/unimpersonate', {}); + Auth.setToken(data.access_token); + window.location.href = '/admin'; + } catch (err) { + Auth.logout(); + } } // ── Timezone helpers ────────────────────────────────────────────────────────── @@ -110,11 +112,11 @@ function initNav() { const nav = document.querySelector('.nav'); if (!nav) return; - const adminToken = sessionStorage.getItem('admin_token'); + const isImpersonating = !!user.admin_id; const navUser = document.createElement('div'); navUser.className = 'nav-user'; - if (adminToken) { + if (isImpersonating) { navUser.innerHTML = ` Viewing as ${user.username} @@ -130,7 +132,7 @@ function initNav() { nav.appendChild(navUser); - if (!adminToken) { + if (!isImpersonating) { const tzOptions = buildTimezoneOptions(user.timezone || 'UTC'); document.body.insertAdjacentHTML('beforeend', `