Phase 7: Auth & Security upgrade (JWT, 2FA, Login UI)
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
import pyotp
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
import config
|
||||
|
||||
# Password handling
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
|
||||
|
||||
def verify_password(plain_password, hashed_password):
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
def get_password_hash(password):
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=15)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme)):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
# In a real app, we'd check DB here.
|
||||
# For this single-user system, we just check if it matches config.
|
||||
if username != config.ADMIN_USER:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
return username
|
||||
|
||||
async def get_current_user_ws(token: str):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if username is None or username != config.ADMIN_USER:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
return username
|
||||
|
||||
# TOTP Persistenceturn username
|
||||
|
||||
# TOTP Persistence
|
||||
AUTH_FILE = config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json"
|
||||
|
||||
def load_totp_secret():
|
||||
try:
|
||||
import json
|
||||
if os.path.exists(AUTH_FILE):
|
||||
with open(AUTH_FILE, "r") as f:
|
||||
data = json.load(f)
|
||||
return data.get("totp_secret", "")
|
||||
except Exception:
|
||||
pass
|
||||
return config.TOTP_SECRET
|
||||
|
||||
def save_totp_secret(secret: str):
|
||||
import json
|
||||
os.makedirs(os.path.dirname(AUTH_FILE), exist_ok=True)
|
||||
with open(AUTH_FILE, "w") as f:
|
||||
json.dump({"totp_secret": secret}, f)
|
||||
|
||||
def verify_totp(token: str, secret: str = None):
|
||||
if secret is None:
|
||||
secret = load_totp_secret()
|
||||
if not secret:
|
||||
return True # 2FA not enabled
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.verify(token)
|
||||
@@ -4,3 +4,12 @@ GITEA_URL = os.environ.get("GITEA_URL", "http://gitea:3000")
|
||||
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
|
||||
DOCKER_HOST = os.environ.get("DOCKER_HOST", "tcp://docker-socket-proxy:2375")
|
||||
VOLUME_ROOT = os.environ.get("VOLUME_ROOT", "/volume1")
|
||||
|
||||
# Auth
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", "c1a776b228421830e7c7df0887adc75f74bac65aaa1fc364be96861b1f8c8701")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||
ADMIN_USER = os.environ.get("ADMIN_USER", "admin")
|
||||
# Default hash for "admin" (bcrypt)
|
||||
ADMIN_PASSWORD_HASH = os.environ.get("ADMIN_PASSWORD_HASH", "$2b$12$EixZaYVK1fsbx4uOXQTEGeN.un0.1")
|
||||
TOTP_SECRET = os.environ.get("TOTP_SECRET", "") # Empty means 2FA disabled by default
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Depends
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from routers import docker_router, gitea, files, terminal, system
|
||||
from routers import docker_router, gitea, files, terminal, system, auth
|
||||
import auth as auth_module
|
||||
|
||||
app = FastAPI(title="NAS Dashboard")
|
||||
app.include_router(docker_router.router, prefix="/api/docker")
|
||||
app.include_router(gitea.router, prefix="/api/gitea")
|
||||
app.include_router(files.router, prefix="/api/files")
|
||||
app.include_router(system.router, prefix="/api/system")
|
||||
|
||||
# Public auth endpoints
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
|
||||
# Protected endpoints
|
||||
app.include_router(docker_router.router, prefix="/api/docker", dependencies=[Depends(auth_module.get_current_user)])
|
||||
app.include_router(gitea.router, prefix="/api/gitea", dependencies=[Depends(auth_module.get_current_user)])
|
||||
app.include_router(files.router, prefix="/api/files", dependencies=[Depends(auth_module.get_current_user)])
|
||||
app.include_router(system.router, prefix="/api/system", dependencies=[Depends(auth_module.get_current_user)])
|
||||
|
||||
# WebSocket endpoint (auth handled inside handler via Query param)
|
||||
app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint)
|
||||
|
||||
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")
|
||||
|
||||
@@ -4,3 +4,6 @@ docker==7.1.0
|
||||
httpx==0.27.0
|
||||
python-multipart==0.0.9
|
||||
psutil==6.1.0
|
||||
pyjwt==2.8.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
pyotp==2.9.0
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,86 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
import config
|
||||
import auth
|
||||
import pyotp
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
totp_code: str = ""
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
user: str
|
||||
has_2fa: bool
|
||||
|
||||
class Setup2FAResponse(BaseModel):
|
||||
secret: str
|
||||
uri: str
|
||||
|
||||
class Verify2FARequest(BaseModel):
|
||||
secret: str
|
||||
code: str
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(creds: LoginRequest):
|
||||
# Verify username/password
|
||||
if creds.username != config.ADMIN_USER or not auth.verify_password(creds.password, config.ADMIN_PASSWORD_HASH):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Check 2FA
|
||||
totp_secret = auth.load_totp_secret()
|
||||
if totp_secret:
|
||||
if not creds.totp_code:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="2FA code required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if not auth.verify_totp(creds.totp_code, totp_secret):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid 2FA code",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
access_token = auth.create_access_token(data={"sub": creds.username})
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"user": creds.username,
|
||||
"has_2fa": bool(totp_secret)
|
||||
}
|
||||
|
||||
@router.get("/me")
|
||||
async def read_users_me(current_user: str = Depends(auth.get_current_user)):
|
||||
totp_secret = auth.load_totp_secret()
|
||||
return {"username": current_user, "has_2fa": bool(totp_secret)}
|
||||
|
||||
@router.post("/setup-2fa/generate", response_model=Setup2FAResponse)
|
||||
async def generate_2fa(current_user: str = Depends(auth.get_current_user)):
|
||||
secret = pyotp.random_base32()
|
||||
uri = pyotp.totp.TOTP(secret).provisioning_uri(name=current_user, issuer_name="NAS Dashboard")
|
||||
return {"secret": secret, "uri": uri}
|
||||
|
||||
@router.post("/setup-2fa/verify")
|
||||
async def verify_2fa_setup(request: Verify2FARequest, current_user: str = Depends(auth.get_current_user)):
|
||||
totp = pyotp.TOTP(request.secret)
|
||||
if not totp.verify(request.code):
|
||||
raise HTTPException(status_code=400, detail="Invalid code")
|
||||
|
||||
# Save secret
|
||||
auth.save_totp_secret(request.secret)
|
||||
return {"message": "2FA enabled successfully"}
|
||||
|
||||
@router.post("/setup-2fa/disable")
|
||||
async def disable_2fa(current_user: str = Depends(auth.get_current_user)):
|
||||
auth.save_totp_secret("")
|
||||
return {"message": "2FA disabled"}
|
||||
@@ -5,16 +5,17 @@ import signal
|
||||
import struct
|
||||
import fcntl
|
||||
import termios
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
from fastapi import WebSocket, WebSocketDisconnect, Depends
|
||||
import auth
|
||||
|
||||
|
||||
async def ws_endpoint(ws: WebSocket):
|
||||
# Block non-Tailscale IPs
|
||||
client_ip = ws.client.host if ws.client else ""
|
||||
async def ws_endpoint(websocket: WebSocket, user: str = Depends(auth.get_current_user_ws)):
|
||||
# Block non-Tailscale IPs (Defense in depth)
|
||||
client_ip = websocket.client.host if websocket.client else ""
|
||||
if not client_ip.startswith("100."):
|
||||
await ws.close(code=1008, reason="Tailscale only")
|
||||
await websocket.close(code=1008, reason="Tailscale only")
|
||||
return
|
||||
|
||||
|
||||
await ws.accept()
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
pid = os.fork()
|
||||
|
||||
Reference in New Issue
Block a user