fix: harden dashboard RBAC and cookie auth flows
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m17s
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m17s
Restrict Docker/Files writes to admins, move terminal websocket auth to cookie-first with temporary query fallback, and migrate refresh-token handling to httpOnly cookies for safer session persistence. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
from datetime import timedelta
|
||||
import asyncio
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, status, Request, Response
|
||||
from pydantic import BaseModel
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
@@ -15,11 +16,13 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
totp_code: str = ""
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
@@ -28,8 +31,46 @@ class Token(BaseModel):
|
||||
has_2fa: bool
|
||||
role: str = "admin"
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
refresh_token: str = ""
|
||||
|
||||
|
||||
def _set_auth_response_tokens(response: Response, access_token: str, refresh_token: str):
|
||||
auth.set_auth_cookies(response, access_token, refresh_token)
|
||||
|
||||
|
||||
def _extract_refresh_token(request: Request, req: Optional[RefreshRequest] = None) -> str:
|
||||
cookie_token = request.cookies.get(auth.REFRESH_COOKIE_NAME, "")
|
||||
if cookie_token:
|
||||
return cookie_token
|
||||
if req and req.refresh_token:
|
||||
return req.refresh_token
|
||||
raise HTTPException(status_code=401, detail="Missing refresh token")
|
||||
|
||||
|
||||
async def _refresh_tokens_from_value(refresh_token_value: str):
|
||||
try:
|
||||
payload = jwt.decode(refresh_token_value, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
||||
if payload.get("type") != "refresh":
|
||||
raise HTTPException(status_code=401, detail="Invalid token type")
|
||||
username = payload.get("sub")
|
||||
role = payload.get("role", "admin")
|
||||
if username is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid user")
|
||||
if not auth.verify_token_version(payload.get("tv", -1)):
|
||||
raise HTTPException(status_code=401, detail="Token revoked")
|
||||
except jwt.PyJWTError:
|
||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||
|
||||
auth.increment_token_version()
|
||||
access_token = auth.create_access_token(
|
||||
data={"sub": username, "role": role},
|
||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
)
|
||||
refresh_token = auth.create_refresh_token(data={"sub": username, "role": role})
|
||||
return access_token, refresh_token
|
||||
|
||||
|
||||
async def send_failed_login_alert(ip_address: str, username: str, reason: str):
|
||||
logger.info("Triggering Telegram alert for %s from %s: %s", username, ip_address, reason)
|
||||
@@ -41,7 +82,7 @@ async def send_failed_login_alert(ip_address: str, username: str, reason: str):
|
||||
client_options = {}
|
||||
if config.TELEGRAM_PROXY:
|
||||
client_options["proxy"] = config.TELEGRAM_PROXY
|
||||
|
||||
|
||||
async with httpx.AsyncClient(**client_options) as client:
|
||||
res = await client.post(
|
||||
f"https://api.telegram.org/bot{config.TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||
@@ -52,9 +93,10 @@ async def send_failed_login_alert(ip_address: str, username: str, reason: str):
|
||||
except Exception as e:
|
||||
logger.warning("Failed to send Telegram alert: %s", e)
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
@limiter.limit("5/minute")
|
||||
async def login(creds: LoginRequest, request: Request):
|
||||
async def login(creds: LoginRequest, request: Request, response: Response):
|
||||
client_ip = request.client.host
|
||||
# Verify username/password
|
||||
if creds.username != config.ADMIN_USER or not auth.verify_password(creds.password, auth.load_password_hash()):
|
||||
@@ -89,6 +131,7 @@ async def login(creds: LoginRequest, request: Request):
|
||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
)
|
||||
refresh_token = auth.create_refresh_token(data={"sub": creds.username, "role": "admin"})
|
||||
_set_auth_response_tokens(response, access_token, refresh_token)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
@@ -98,8 +141,9 @@ async def login(creds: LoginRequest, request: Request):
|
||||
"role": "admin",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/proxy")
|
||||
async def proxy_auth(request: Request):
|
||||
async def proxy_auth(request: Request, response: Response):
|
||||
"""Auto-login when Authelia has already authenticated the user via forward-auth.
|
||||
Caddy sets Remote-User and Remote-Groups headers after successful Authelia verification."""
|
||||
from rbac import resolve_role
|
||||
@@ -125,6 +169,7 @@ async def proxy_auth(request: Request):
|
||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
)
|
||||
refresh_token = auth.create_refresh_token(data={"sub": remote_user, "role": role})
|
||||
_set_auth_response_tokens(response, access_token, refresh_token)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
@@ -134,8 +179,9 @@ async def proxy_auth(request: Request):
|
||||
"role": role,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def read_users_me(current_user = Depends(auth.get_current_user)):
|
||||
async def read_users_me(current_user=Depends(auth.get_current_user)):
|
||||
totp_secret = auth.load_totp_secret()
|
||||
return {
|
||||
"username": current_user.username,
|
||||
@@ -145,36 +191,31 @@ async def read_users_me(current_user = Depends(auth.get_current_user)):
|
||||
"has_2fa": bool(totp_secret),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
@limiter.limit("10/minute")
|
||||
async def refresh_token(req: RefreshRequest, request: Request):
|
||||
try:
|
||||
payload = jwt.decode(req.refresh_token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
||||
if payload.get("type") != "refresh":
|
||||
raise HTTPException(status_code=401, detail="Invalid token type")
|
||||
username = payload.get("sub")
|
||||
role = payload.get("role", "admin")
|
||||
if username is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid user")
|
||||
if not auth.verify_token_version(payload.get("tv", -1)):
|
||||
raise HTTPException(status_code=401, detail="Token revoked")
|
||||
except jwt.PyJWTError:
|
||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||
auth.increment_token_version()
|
||||
access_token = auth.create_access_token(
|
||||
data={"sub": username, "role": role},
|
||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
)
|
||||
refresh_token = auth.create_refresh_token(data={"sub": username, "role": role})
|
||||
async def refresh_token(request: Request, response: Response, req: Optional[RefreshRequest] = Body(default=None)):
|
||||
refresh_token_value = _extract_refresh_token(request, req)
|
||||
access_token, refresh_token = await _refresh_tokens_from_value(refresh_token_value)
|
||||
_set_auth_response_tokens(response, access_token, refresh_token)
|
||||
return {"access_token": access_token, "refresh_token": refresh_token}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(response: Response):
|
||||
auth.increment_token_version()
|
||||
auth.clear_auth_cookies(response)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
@limiter.limit("5/minute")
|
||||
async def change_password(req: ChangePasswordRequest, request: Request, current_user = Depends(auth.get_current_user)):
|
||||
async def change_password(req: ChangePasswordRequest, request: Request, current_user=Depends(auth.get_current_user)):
|
||||
if not auth.verify_password(req.current_password, auth.load_password_hash()):
|
||||
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||
if len(req.new_password) < 8:
|
||||
@@ -182,16 +223,18 @@ async def change_password(req: ChangePasswordRequest, request: Request, current_
|
||||
auth.save_password_hash(auth.get_password_hash(req.new_password))
|
||||
return {"message": "Password changed successfully"}
|
||||
|
||||
|
||||
# RBAC admin endpoints
|
||||
@router.get("/rbac/config")
|
||||
async def rbac_config(current_user = Depends(auth.get_current_user)):
|
||||
async def rbac_config(current_user=Depends(auth.get_current_user)):
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
from rbac import load_rbac
|
||||
return load_rbac()
|
||||
|
||||
|
||||
@router.put("/rbac/overrides/{username}")
|
||||
async def rbac_set_override(username: str, request: Request, current_user = Depends(auth.get_current_user)):
|
||||
async def rbac_set_override(username: str, request: Request, current_user=Depends(auth.get_current_user)):
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
from rbac import update_rbac
|
||||
@@ -208,14 +251,16 @@ async def rbac_set_override(username: str, request: Request, current_user = Depe
|
||||
update_rbac(mutator)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/preferences")
|
||||
async def get_preferences(current_user = Depends(auth.get_current_user)):
|
||||
async def get_preferences(current_user=Depends(auth.get_current_user)):
|
||||
from rbac import get_sidebar_order
|
||||
order = get_sidebar_order(current_user.username)
|
||||
return {"sidebar_order": order}
|
||||
|
||||
|
||||
@router.put("/preferences")
|
||||
async def save_preferences(request: Request, current_user = Depends(auth.get_current_user)):
|
||||
async def save_preferences(request: Request, current_user=Depends(auth.get_current_user)):
|
||||
import traceback
|
||||
from rbac import save_sidebar_order
|
||||
try:
|
||||
@@ -232,8 +277,9 @@ async def save_preferences(request: Request, current_user = Depends(auth.get_cur
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/rbac/overrides/{username}")
|
||||
async def rbac_delete_override(username: str, current_user = Depends(auth.get_current_user)):
|
||||
async def rbac_delete_override(username: str, current_user=Depends(auth.get_current_user)):
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
from rbac import update_rbac
|
||||
@@ -244,8 +290,9 @@ async def rbac_delete_override(username: str, current_user = Depends(auth.get_cu
|
||||
update_rbac(mutator)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.put("/rbac/roles/{role}")
|
||||
async def rbac_update_role(role: str, request: Request, current_user = Depends(auth.get_current_user)):
|
||||
async def rbac_update_role(role: str, request: Request, current_user=Depends(auth.get_current_user)):
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
from rbac import update_rbac
|
||||
|
||||
Reference in New Issue
Block a user