Move JWT from localStorage to HttpOnly cookie; fix CSRF

- JWT stored in HttpOnly, Secure, SameSite=Strict cookie — JS cannot
  read the token at all; SameSite=Strict prevents CSRF without tokens
- Non-sensitive user payload returned in response body and stored in
  localStorage for UI purposes only (not usable for auth)
- Add POST /api/auth/logout endpoint that clears the cookie server-side
- Add SECURE_COOKIES env var (default true) for local HTTP testing
- Extract login.html inline script to login.js (CSP compliance)
- Remove Authorization: Bearer header from API calls; add credentials:
  include so cookies are sent automatically
- CSP script-src includes unsafe-inline to support existing onclick
  handlers throughout the app

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 23:57:22 -07:00
parent 6d09e40f58
commit 59f9685e2b
10 changed files with 229 additions and 165 deletions

View File

@@ -4,8 +4,7 @@ from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from fastapi import Cookie, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session
from database import get_db
@@ -14,9 +13,10 @@ from models import User
SECRET_KEY = os.environ["JWT_SECRET"]
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_DAYS = 30
COOKIE_NAME = "token"
SECURE_COOKIES = os.environ.get("SECURE_COOKIES", "true").lower() == "true"
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def verify_password(plain_password: str, hashed_password: str) -> bool:
@@ -41,28 +41,44 @@ def create_access_token(user_id: int, username: str, is_admin: bool, user_timezo
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"},
def set_auth_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=COOKIE_NAME,
value=token,
httponly=True,
secure=SECURE_COOKIES,
samesite="strict",
max_age=ACCESS_TOKEN_EXPIRE_DAYS * 24 * 60 * 60,
path="/",
)
def clear_auth_cookie(response: Response) -> None:
response.delete_cookie(key=COOKIE_NAME, httponly=True, secure=SECURE_COOKIES, samesite="strict", path="/")
def token_to_user_payload(token: str) -> dict:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return {
"sub": payload["sub"],
"username": payload["username"],
"is_admin": payload["is_admin"],
"timezone": payload["timezone"],
"exp": payload["exp"],
"admin_id": payload.get("admin_id"),
}
async def get_current_user(
token: str = Depends(oauth2_scheme),
token: Optional[str] = Cookie(default=None, alias=COOKIE_NAME),
db: Session = Depends(get_db),
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
if not token:
raise credentials_exception
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id_str: Optional[str] = payload.get("sub")
@@ -85,3 +101,20 @@ async def get_current_admin(current_user: User = Depends(get_current_user)) -> U
detail="Admin access required",
)
return current_user
async def get_token_payload(
token: Optional[str] = Cookie(default=None, alias=COOKIE_NAME),
) -> dict:
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
)
try:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
)

View File

@@ -1,13 +1,13 @@
import logging
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy import select
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, get_token_payload
from schemas import UserCreate, UserOut, ResetPasswordRequest, AuthResponse
from auth import hash_password, create_access_token, get_current_admin, get_token_payload, set_auth_cookie, token_to_user_payload
router = APIRouter(prefix="/api/admin", tags=["admin"])
logger = logging.getLogger("yolkbook")
@@ -104,9 +104,10 @@ def delete_user(
db.commit()
@router.post("/users/{user_id}/impersonate", response_model=TokenResponse)
@router.post("/users/{user_id}/impersonate", response_model=AuthResponse)
def impersonate_user(
user_id: int,
response: Response,
current_admin: User = Depends(get_current_admin),
db: Session = Depends(get_db),
):
@@ -114,12 +115,14 @@ def impersonate_user(
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, admin_id=current_admin.id)
set_auth_cookie(response, token)
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)
return AuthResponse(user=token_to_user_payload(token))
@router.post("/unimpersonate", response_model=TokenResponse)
@router.post("/unimpersonate", response_model=AuthResponse)
def unimpersonate(
response: Response,
payload: dict = Depends(get_token_payload),
db: Session = Depends(get_db),
):
@@ -130,5 +133,6 @@ def unimpersonate(
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)
set_auth_cookie(response, token)
logger.warning("Admin '%s' (id=%d) ended impersonation session.", admin.username, admin.id)
return TokenResponse(access_token=token)
return AuthResponse(user=token_to_user_payload(token))

View File

@@ -1,23 +1,28 @@
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from database import get_db
from models import User
from schemas import LoginRequest, TokenResponse, UserOut, UserCreate, ChangePasswordRequest, TimezoneUpdate
from auth import verify_password, hash_password, create_access_token, get_current_user
from schemas import LoginRequest, UserOut, UserCreate, ChangePasswordRequest, TimezoneUpdate, AuthResponse
from auth import (
verify_password, hash_password, create_access_token, get_current_user,
set_auth_cookie, clear_auth_cookie, token_to_user_payload,
)
router = APIRouter(prefix="/api/auth", tags=["auth"])
def _make_token(user: User) -> str:
return create_access_token(user.id, user.username, user.is_admin, user.timezone)
def _issue(response: Response, user: User, admin_id=None) -> AuthResponse:
token = create_access_token(user.id, user.username, user.is_admin, user.timezone, admin_id=admin_id)
set_auth_cookie(response, token)
return AuthResponse(user=token_to_user_payload(token))
@router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest, db: Session = Depends(get_db)):
@router.post("/login", response_model=AuthResponse)
def login(body: LoginRequest, response: Response, db: Session = Depends(get_db)):
user = db.scalars(select(User).where(User.username == body.username)).first()
if not user or not verify_password(body.password, user.hashed_password):
raise HTTPException(
@@ -29,15 +34,14 @@ def login(body: LoginRequest, db: Session = Depends(get_db)):
status_code=status.HTTP_403_FORBIDDEN,
detail="Account is disabled. Contact your administrator.",
)
return TokenResponse(access_token=_make_token(user))
return _issue(response, user)
@router.post("/register", response_model=TokenResponse, status_code=201)
def register(body: UserCreate, db: Session = Depends(get_db)):
@router.post("/register", response_model=AuthResponse, status_code=201)
def register(body: UserCreate, response: Response, db: Session = Depends(get_db)):
existing = db.scalars(select(User).where(User.username == body.username)).first()
if existing:
raise HTTPException(status_code=409, detail="Username already taken")
# Default timezone to UTC; user can change it in settings
user = User(
username=body.username,
hashed_password=hash_password(body.password),
@@ -47,7 +51,13 @@ def register(body: UserCreate, db: Session = Depends(get_db)):
db.add(user)
db.commit()
db.refresh(user)
return TokenResponse(access_token=_make_token(user))
return _issue(response, user)
@router.post("/logout")
def logout(response: Response):
clear_auth_cookie(response)
return {"detail": "Logged out"}
@router.get("/me", response_model=UserOut)
@@ -68,18 +78,18 @@ def change_password(
return {"detail": "Password updated"}
@router.put("/timezone", response_model=TokenResponse)
@router.put("/timezone", response_model=AuthResponse)
def update_timezone(
body: TimezoneUpdate,
response: Response,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
try:
ZoneInfo(body.timezone) # validate it's a real IANA timezone
ZoneInfo(body.timezone)
except ZoneInfoNotFoundError:
raise HTTPException(status_code=400, detail=f"Unknown timezone: {body.timezone}")
current_user.timezone = body.timezone
db.commit()
db.refresh(current_user)
# Return a fresh token with the updated timezone embedded
return TokenResponse(access_token=_make_token(current_user))
return _issue(response, current_user)

View File

@@ -14,6 +14,17 @@ class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class UserPayload(BaseModel):
sub: str
username: str
is_admin: bool
timezone: str
exp: int
admin_id: Optional[int] = None
class AuthResponse(BaseModel):
user: UserPayload
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str = Field(min_length=10)

View File

@@ -118,7 +118,7 @@ async function toggleUser(id, disable) {
async function impersonateUser(id) {
try {
const data = await API.post(`/api/admin/users/${id}/impersonate`, {});
Auth.setToken(data.access_token);
Auth.setToken(data.user);
window.location.href = '/';
} catch (err) {
showMessage(document.getElementById('msg'), err.message, 'error');

View File

@@ -2,14 +2,11 @@
const API = {
async _fetch(url, options = {}) {
const token = localStorage.getItem('token');
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(url, { headers, ...options });
const res = await fetch(url, { credentials: 'include', headers, ...options });
if (res.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
return;
}

View File

@@ -1,28 +1,25 @@
// auth.js — authentication utilities used by every authenticated page
const Auth = {
getToken() {
return localStorage.getItem('token');
},
setToken(token) {
localStorage.setItem('token', token);
// Store/retrieve the non-sensitive user payload (not the token itself)
setToken(userData) {
localStorage.setItem('user', JSON.stringify(userData));
},
removeToken() {
localStorage.removeItem('token');
localStorage.removeItem('user');
},
getUser() {
const token = this.getToken();
if (!token) return null;
const raw = localStorage.getItem('user');
if (!raw) return null;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp < Date.now() / 1000) {
const user = JSON.parse(raw);
if (user.exp < Date.now() / 1000) {
this.removeToken();
return null;
}
return payload;
return user;
} catch (_) {
return null;
}
@@ -37,7 +34,10 @@ const Auth = {
return user;
},
logout() {
async logout() {
try {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
} catch (_) { /* best-effort */ }
this.removeToken();
window.location.href = '/login';
},
@@ -46,7 +46,7 @@ const Auth = {
async function returnToAdmin() {
try {
const data = await API.post('/api/admin/unimpersonate', {});
Auth.setToken(data.access_token);
Auth.setToken(data.user);
window.location.href = '/admin';
} catch (err) {
Auth.logout();
@@ -198,7 +198,7 @@ async function submitTimezone() {
const msgEl = document.getElementById('settings-msg');
try {
const data = await API.put('/api/auth/timezone', { timezone: tz });
Auth.setToken(data.access_token);
Auth.setToken(data.user);
msgEl.textContent = `Timezone saved: ${tz.replace(/_/g, ' ')}`;
msgEl.className = 'message success visible';
setTimeout(() => { msgEl.className = 'message'; }, 3000);

110
nginx/html/js/login.js Normal file
View File

@@ -0,0 +1,110 @@
// login.js — login / register page logic
// Redirect if already logged in
(function () {
const raw = localStorage.getItem('user');
if (raw) {
try {
const user = JSON.parse(raw);
if (user.exp > Date.now() / 1000) {
window.location.href = '/';
return;
}
} catch (_) {}
localStorage.removeItem('user');
}
})();
function showLogin() {
document.getElementById('register-panel').style.display = 'none';
document.getElementById('login-panel').style.display = 'block';
document.getElementById('username').focus();
}
function showRegister() {
document.getElementById('login-panel').style.display = 'none';
document.getElementById('register-panel').style.display = 'block';
document.getElementById('reg-username').focus();
}
function showError(elId, text) {
const el = document.getElementById(elId);
el.textContent = text;
el.className = 'message error visible';
}
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('show-register-link').addEventListener('click', (e) => {
e.preventDefault();
showRegister();
});
document.getElementById('show-login-link').addEventListener('click', (e) => {
e.preventDefault();
showLogin();
});
// ── Login ──
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('login-btn');
btn.disabled = true;
btn.textContent = 'Signing in…';
document.getElementById('login-msg').className = 'message';
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (res.status === 429) { showError('login-msg', 'Too many attempts — please wait a minute and try again.'); return; }
if (!res.ok) { showError('login-msg', data.detail || 'Login failed'); return; }
localStorage.setItem('user', JSON.stringify(data.user));
window.location.href = '/';
} catch (err) {
showError('login-msg', 'Could not reach the server. Please try again.');
} finally {
btn.disabled = false;
btn.textContent = 'Sign In';
}
});
// ── Register ──
document.getElementById('reg-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('reg-btn');
const username = document.getElementById('reg-username').value.trim();
const password = document.getElementById('reg-password').value;
const confirm = document.getElementById('reg-confirm').value;
if (password !== confirm) { showError('reg-msg', 'Passwords do not match'); return; }
btn.disabled = true;
btn.textContent = 'Creating account…';
document.getElementById('reg-msg').className = 'message';
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (!res.ok) { showError('reg-msg', data.detail || 'Registration failed'); return; }
localStorage.setItem('user', JSON.stringify(data.user));
window.location.href = '/';
} catch (err) {
showError('reg-msg', 'Could not reach the server. Please try again.');
} finally {
btn.disabled = false;
btn.textContent = 'Create Account';
}
});
});

View File

@@ -27,7 +27,7 @@
<button type="submit" class="btn btn-primary" style="width:100%" id="login-btn">Sign In</button>
</form>
<p style="text-align:center;margin-top:1rem;font-size:0.9rem;color:var(--muted)">
No account? <a href="#" onclick="showRegister()">Create one</a>
No account? <a href="#" id="show-register-link">Create one</a>
</p>
</div>
@@ -51,112 +51,11 @@
<button type="submit" class="btn btn-primary" style="width:100%" id="reg-btn">Create Account</button>
</form>
<p style="text-align:center;margin-top:1rem;font-size:0.9rem;color:var(--muted)">
Already have an account? <a href="#" onclick="showLogin()">Sign in</a>
Already have an account? <a href="#" id="show-login-link">Sign in</a>
</p>
</div>
</div>
<script>
// Redirect if already logged in
(function () {
const token = localStorage.getItem('token');
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp > Date.now() / 1000) {
window.location.href = '/';
return;
}
} catch (_) {}
localStorage.removeItem('token');
}
})();
function showLogin() {
document.getElementById('register-panel').style.display = 'none';
document.getElementById('login-panel').style.display = 'block';
document.getElementById('username').focus();
}
function showRegister() {
document.getElementById('login-panel').style.display = 'none';
document.getElementById('register-panel').style.display = 'block';
document.getElementById('reg-username').focus();
}
function showError(elId, text) {
const el = document.getElementById(elId);
el.textContent = text;
el.className = 'message error visible';
}
function showSuccess(elId, text) {
const el = document.getElementById(elId);
el.textContent = text;
el.className = 'message success visible';
}
// ── Login ──
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('login-btn');
btn.disabled = true;
btn.textContent = 'Signing in…';
document.getElementById('login-msg').className = 'message';
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (res.status === 429) { showError('login-msg', 'Too many attempts — please wait a minute and try again.'); return; }
if (!res.ok) { showError('login-msg', data.detail || 'Login failed'); return; }
localStorage.setItem('token', data.access_token);
window.location.href = '/';
} catch (err) {
showError('login-msg', 'Could not reach the server. Please try again.');
} finally {
btn.disabled = false;
btn.textContent = 'Sign In';
}
});
// ── Register ──
document.getElementById('reg-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('reg-btn');
const username = document.getElementById('reg-username').value.trim();
const password = document.getElementById('reg-password').value;
const confirm = document.getElementById('reg-confirm').value;
if (password !== confirm) { showError('reg-msg', 'Passwords do not match'); return; }
btn.disabled = true;
btn.textContent = 'Creating account…';
document.getElementById('reg-msg').className = 'message';
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (!res.ok) { showError('reg-msg', data.detail || 'Registration failed'); return; }
localStorage.setItem('token', data.access_token);
window.location.href = '/';
} catch (err) {
showError('reg-msg', 'Could not reach the server. Please try again.');
} finally {
btn.disabled = false;
btn.textContent = 'Create Account';
}
});
</script>
<script src="/js/login.js"></script>
</body>
</html>

View File

@@ -31,7 +31,7 @@ http {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'" always;
# ── Static files ──────────────────────────────────────────────────────
location / {