diff --git a/backend/auth.py b/backend/auth.py index 83d9c91..fe40000 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -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", + ) diff --git a/backend/routers/admin.py b/backend/routers/admin.py index 72c82e0..d01b693 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -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)) diff --git a/backend/routers/auth_router.py b/backend/routers/auth_router.py index a78c0cf..99c4897 100644 --- a/backend/routers/auth_router.py +++ b/backend/routers/auth_router.py @@ -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) diff --git a/backend/schemas.py b/backend/schemas.py index f318672..07eaeb1 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -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) diff --git a/nginx/html/js/admin.js b/nginx/html/js/admin.js index 27b48ec..b0c5937 100644 --- a/nginx/html/js/admin.js +++ b/nginx/html/js/admin.js @@ -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'); diff --git a/nginx/html/js/api.js b/nginx/html/js/api.js index b753e35..b32c783 100644 --- a/nginx/html/js/api.js +++ b/nginx/html/js/api.js @@ -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; } diff --git a/nginx/html/js/auth.js b/nginx/html/js/auth.js index 4905936..36123fe 100644 --- a/nginx/html/js/auth.js +++ b/nginx/html/js/auth.js @@ -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); diff --git a/nginx/html/js/login.js b/nginx/html/js/login.js new file mode 100644 index 0000000..e0da2fa --- /dev/null +++ b/nginx/html/js/login.js @@ -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'; + } + }); +}); diff --git a/nginx/html/login.html b/nginx/html/login.html index 1a14423..e12101f 100644 --- a/nginx/html/login.html +++ b/nginx/html/login.html @@ -27,7 +27,7 @@
- No account? Create one + No account? Create one
@@ -51,112 +51,11 @@- Already have an account? Sign in + Already have an account? Sign in
- +