Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10df54f2fe | |||
| bab7fddb90 | |||
| 6e751ef92f | |||
| 23598a5af1 | |||
| 9252f5d28a | |||
| 77c676c566 | |||
| 161f43528a | |||
| 0de936b920 | |||
| a496e7f3bc | |||
| b4d79918be | |||
| fa8f12f67a | |||
| 2a5d8c77e8 | |||
| d8b8944234 | |||
| aac297ab3e | |||
| b0b54b2ebb | |||
| 12ca6c7af4 | |||
| c5a3702496 | |||
| 677997fb75 | |||
| fbf5f6908e | |||
| 01394d709a | |||
| e0d5e59ce9 | |||
| 22e35698f3 | |||
| a3bac56275 | |||
| 3306c8ce51 |
+21
-47
@@ -6,7 +6,7 @@ import tempfile
|
|||||||
import jwt
|
import jwt
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
import pyotp
|
import pyotp
|
||||||
from fastapi import Depends, HTTPException, Response, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
import config
|
import config
|
||||||
|
|
||||||
@@ -14,12 +14,6 @@ import config
|
|||||||
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
|
||||||
|
|
||||||
ACCESS_COOKIE_NAME = "nas_access_token"
|
|
||||||
REFRESH_COOKIE_NAME = "nas_refresh_token"
|
|
||||||
COOKIE_SECURE = True
|
|
||||||
COOKIE_SAMESITE = "lax"
|
|
||||||
COOKIE_PATH = "/"
|
|
||||||
|
|
||||||
def verify_password(plain_password, hashed_password):
|
def verify_password(plain_password, hashed_password):
|
||||||
return pwd_context.verify(plain_password, hashed_password)
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
@@ -32,49 +26,18 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
|||||||
to_encode.update({"exp": expire, "type": "access"})
|
to_encode.update({"exp": expire, "type": "access"})
|
||||||
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
def create_refresh_token(data: dict):
|
def create_refresh_token(data: dict):
|
||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
expire = datetime.now(timezone.utc) + timedelta(minutes=config.REFRESH_TOKEN_EXPIRE_MINUTES)
|
expire = datetime.now(timezone.utc) + timedelta(minutes=config.REFRESH_TOKEN_EXPIRE_MINUTES)
|
||||||
to_encode.update({"exp": expire, "type": "refresh", "tv": _load_token_version()})
|
to_encode.update({"exp": expire, "type": "refresh", "tv": _load_token_version()})
|
||||||
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
||||||
|
|
||||||
|
async def get_current_user(token: str = Depends(oauth2_scheme)):
|
||||||
def _cookie_max_age(minutes: int) -> int:
|
|
||||||
return max(60, int(minutes * 60))
|
|
||||||
|
|
||||||
|
|
||||||
def set_auth_cookies(response: Response, access_token: str, refresh_token: str):
|
|
||||||
response.set_cookie(
|
|
||||||
key=ACCESS_COOKIE_NAME,
|
|
||||||
value=access_token,
|
|
||||||
httponly=True,
|
|
||||||
secure=COOKIE_SECURE,
|
|
||||||
samesite=COOKIE_SAMESITE,
|
|
||||||
path=COOKIE_PATH,
|
|
||||||
max_age=_cookie_max_age(config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
|
||||||
)
|
|
||||||
response.set_cookie(
|
|
||||||
key=REFRESH_COOKIE_NAME,
|
|
||||||
value=refresh_token,
|
|
||||||
httponly=True,
|
|
||||||
secure=COOKIE_SECURE,
|
|
||||||
samesite=COOKIE_SAMESITE,
|
|
||||||
path=COOKIE_PATH,
|
|
||||||
max_age=_cookie_max_age(config.REFRESH_TOKEN_EXPIRE_MINUTES),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def clear_auth_cookies(response: Response):
|
|
||||||
response.delete_cookie(ACCESS_COOKIE_NAME, path=COOKIE_PATH)
|
|
||||||
response.delete_cookie(REFRESH_COOKIE_NAME, path=COOKIE_PATH)
|
|
||||||
|
|
||||||
def user_from_access_token(token: str, include_auth_header: bool = True):
|
|
||||||
from rbac import User, get_pages, get_sidebar_links
|
from rbac import User, get_pages, get_sidebar_links
|
||||||
credentials_exception = HTTPException(
|
credentials_exception = HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Could not validate credentials",
|
detail="Could not validate credentials",
|
||||||
headers={"WWW-Authenticate": "Bearer"} if include_auth_header else None,
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
||||||
@@ -86,18 +49,29 @@ def user_from_access_token(token: str, include_auth_header: bool = True):
|
|||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
except jwt.PyJWTError:
|
except jwt.PyJWTError:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
|
|
||||||
pages = get_pages(username, role)
|
pages = get_pages(username, role)
|
||||||
sidebar_links = get_sidebar_links(username, role)
|
sidebar_links = get_sidebar_links(username, role)
|
||||||
return User(username=username, role=role, pages=pages, sidebar_links=sidebar_links)
|
return User(username=username, role=role, pages=pages, sidebar_links=sidebar_links)
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(token: str = Depends(oauth2_scheme)):
|
|
||||||
return user_from_access_token(token)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user_ws(token: str):
|
async def get_current_user_ws(token: str):
|
||||||
return user_from_access_token(token, include_auth_header=False)
|
from rbac import User, get_pages, get_sidebar_links
|
||||||
|
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])
|
||||||
|
if payload.get("type") != "access":
|
||||||
|
raise credentials_exception
|
||||||
|
username: str = payload.get("sub")
|
||||||
|
role: str = payload.get("role", "admin")
|
||||||
|
if username is None:
|
||||||
|
raise credentials_exception
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
raise credentials_exception
|
||||||
|
pages = get_pages(username, role)
|
||||||
|
sidebar_links = get_sidebar_links(username, role)
|
||||||
|
return User(username=username, role=role, pages=pages, sidebar_links=sidebar_links)
|
||||||
|
|
||||||
# TOTP Persistence
|
# TOTP Persistence
|
||||||
AUTH_FILE = config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json"
|
AUTH_FILE = config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json"
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
|||||||
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
|
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
|
||||||
TELEGRAM_PROXY = os.environ.get("TELEGRAM_PROXY", "")
|
TELEGRAM_PROXY = os.environ.get("TELEGRAM_PROXY", "")
|
||||||
|
|
||||||
WEBAUTHN_RP_ID = os.environ.get("WEBAUTHN_RP_ID", "jimmygan.com")
|
WEBAUTHN_RP_ID = os.environ.get("WEBAUTHN_RP_ID", "nas.jimmygan.com")
|
||||||
WEBAUTHN_ORIGINS = os.environ.get("WEBAUTHN_ORIGINS", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split(",")
|
WEBAUTHN_ORIGINS = os.environ.get("WEBAUTHN_ORIGIN", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split(",")
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split(",")
|
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split(",")
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from slowapi.errors import RateLimitExceeded
|
|||||||
from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp, litellm, cc_connect, info_engine
|
from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp, litellm, cc_connect, info_engine
|
||||||
import auth as auth_module
|
import auth as auth_module
|
||||||
import config
|
import config
|
||||||
from rbac import require_page
|
from rbac import require_page, require_write
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import httpx
|
import httpx
|
||||||
@@ -160,11 +160,11 @@ app.include_router(totp.router, prefix="/api/auth", tags=["totp"])
|
|||||||
|
|
||||||
# Protected endpoints with page-level RBAC
|
# Protected endpoints with page-level RBAC
|
||||||
app.include_router(docker_router.router, prefix="/api/docker",
|
app.include_router(docker_router.router, prefix="/api/docker",
|
||||||
dependencies=[Depends(_inject_user), Depends(require_page("docker"))])
|
dependencies=[Depends(_inject_user), Depends(require_page("docker")), Depends(require_write())])
|
||||||
app.include_router(gitea.router, prefix="/api/gitea",
|
app.include_router(gitea.router, prefix="/api/gitea",
|
||||||
dependencies=[Depends(_inject_user), Depends(require_page("gitea"))])
|
dependencies=[Depends(_inject_user), Depends(require_page("gitea"))])
|
||||||
app.include_router(files.router, prefix="/api/files",
|
app.include_router(files.router, prefix="/api/files",
|
||||||
dependencies=[Depends(_inject_user), Depends(require_page("files"))])
|
dependencies=[Depends(_inject_user), Depends(require_page("files")), Depends(require_write())])
|
||||||
app.include_router(system.router, prefix="/api/system",
|
app.include_router(system.router, prefix="/api/system",
|
||||||
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))])
|
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))])
|
||||||
app.include_router(litellm.router, prefix="/api/litellm",
|
app.include_router(litellm.router, prefix="/api/litellm",
|
||||||
@@ -178,7 +178,7 @@ app.include_router(info_engine.router, prefix="/api/info-engine",
|
|||||||
app.include_router(security.router, prefix="/api/security",
|
app.include_router(security.router, prefix="/api/security",
|
||||||
dependencies=[Depends(_inject_user), Depends(require_page("security"))])
|
dependencies=[Depends(_inject_user), Depends(require_page("security"))])
|
||||||
|
|
||||||
# WebSocket endpoint (auth handled inside handler via auth cookie; query token fallback temporary)
|
# WebSocket endpoint (auth handled inside handler via Query param)
|
||||||
app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint)
|
app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint)
|
||||||
|
|
||||||
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")
|
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import asyncio
|
import asyncio
|
||||||
import httpx
|
import httpx
|
||||||
from typing import Optional
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException, status, Request, Response
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from slowapi import Limiter
|
from slowapi import Limiter
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
@@ -16,13 +15,11 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
limiter = Limiter(key_func=get_remote_address)
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
totp_code: str = ""
|
totp_code: str = ""
|
||||||
|
|
||||||
|
|
||||||
class Token(BaseModel):
|
class Token(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
refresh_token: str
|
refresh_token: str
|
||||||
@@ -31,46 +28,8 @@ class Token(BaseModel):
|
|||||||
has_2fa: bool
|
has_2fa: bool
|
||||||
role: str = "admin"
|
role: str = "admin"
|
||||||
|
|
||||||
|
|
||||||
class RefreshRequest(BaseModel):
|
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):
|
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)
|
logger.info("Triggering Telegram alert for %s from %s: %s", username, ip_address, reason)
|
||||||
@@ -93,10 +52,9 @@ async def send_failed_login_alert(ip_address: str, username: str, reason: str):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to send Telegram alert: %s", e)
|
logger.warning("Failed to send Telegram alert: %s", e)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=Token)
|
@router.post("/login", response_model=Token)
|
||||||
@limiter.limit("5/minute")
|
@limiter.limit("5/minute")
|
||||||
async def login(creds: LoginRequest, request: Request, response: Response):
|
async def login(creds: LoginRequest, request: Request):
|
||||||
client_ip = request.client.host
|
client_ip = request.client.host
|
||||||
# Verify username/password
|
# Verify username/password
|
||||||
if creds.username != config.ADMIN_USER or not auth.verify_password(creds.password, auth.load_password_hash()):
|
if creds.username != config.ADMIN_USER or not auth.verify_password(creds.password, auth.load_password_hash()):
|
||||||
@@ -131,7 +89,6 @@ async def login(creds: LoginRequest, request: Request, response: Response):
|
|||||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||||
)
|
)
|
||||||
refresh_token = auth.create_refresh_token(data={"sub": creds.username, "role": "admin"})
|
refresh_token = auth.create_refresh_token(data={"sub": creds.username, "role": "admin"})
|
||||||
_set_auth_response_tokens(response, access_token, refresh_token)
|
|
||||||
return {
|
return {
|
||||||
"access_token": access_token,
|
"access_token": access_token,
|
||||||
"refresh_token": refresh_token,
|
"refresh_token": refresh_token,
|
||||||
@@ -141,9 +98,8 @@ async def login(creds: LoginRequest, request: Request, response: Response):
|
|||||||
"role": "admin",
|
"role": "admin",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/proxy")
|
@router.get("/proxy")
|
||||||
async def proxy_auth(request: Request, response: Response):
|
async def proxy_auth(request: Request):
|
||||||
"""Auto-login when Authelia has already authenticated the user via forward-auth.
|
"""Auto-login when Authelia has already authenticated the user via forward-auth.
|
||||||
Caddy sets Remote-User and Remote-Groups headers after successful Authelia verification."""
|
Caddy sets Remote-User and Remote-Groups headers after successful Authelia verification."""
|
||||||
from rbac import resolve_role
|
from rbac import resolve_role
|
||||||
@@ -169,7 +125,6 @@ async def proxy_auth(request: Request, response: Response):
|
|||||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||||
)
|
)
|
||||||
refresh_token = auth.create_refresh_token(data={"sub": remote_user, "role": role})
|
refresh_token = auth.create_refresh_token(data={"sub": remote_user, "role": role})
|
||||||
_set_auth_response_tokens(response, access_token, refresh_token)
|
|
||||||
return {
|
return {
|
||||||
"access_token": access_token,
|
"access_token": access_token,
|
||||||
"refresh_token": refresh_token,
|
"refresh_token": refresh_token,
|
||||||
@@ -179,9 +134,8 @@ async def proxy_auth(request: Request, response: Response):
|
|||||||
"role": role,
|
"role": role,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me")
|
@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()
|
totp_secret = auth.load_totp_secret()
|
||||||
return {
|
return {
|
||||||
"username": current_user.username,
|
"username": current_user.username,
|
||||||
@@ -191,31 +145,36 @@ async def read_users_me(current_user=Depends(auth.get_current_user)):
|
|||||||
"has_2fa": bool(totp_secret),
|
"has_2fa": bool(totp_secret),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refresh")
|
@router.post("/refresh")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def refresh_token(request: Request, response: Response, req: Optional[RefreshRequest] = Body(default=None)):
|
async def refresh_token(req: RefreshRequest, request: Request):
|
||||||
refresh_token_value = _extract_refresh_token(request, req)
|
try:
|
||||||
access_token, refresh_token = await _refresh_tokens_from_value(refresh_token_value)
|
payload = jwt.decode(req.refresh_token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
||||||
_set_auth_response_tokens(response, access_token, refresh_token)
|
if payload.get("type") != "refresh":
|
||||||
return {"access_token": access_token, "refresh_token": refresh_token}
|
raise HTTPException(status_code=401, detail="Invalid token type")
|
||||||
|
username = payload.get("sub")
|
||||||
|
role = payload.get("role", "admin")
|
||||||
@router.post("/logout")
|
if username is None:
|
||||||
async def logout(response: Response):
|
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()
|
auth.increment_token_version()
|
||||||
auth.clear_auth_cookies(response)
|
access_token = auth.create_access_token(
|
||||||
return {"ok": True}
|
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": access_token, "refresh_token": refresh_token}
|
||||||
|
|
||||||
class ChangePasswordRequest(BaseModel):
|
class ChangePasswordRequest(BaseModel):
|
||||||
current_password: str
|
current_password: str
|
||||||
new_password: str
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
@router.post("/change-password")
|
@router.post("/change-password")
|
||||||
@limiter.limit("5/minute")
|
@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()):
|
if not auth.verify_password(req.current_password, auth.load_password_hash()):
|
||||||
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||||
if len(req.new_password) < 8:
|
if len(req.new_password) < 8:
|
||||||
@@ -223,18 +182,16 @@ async def change_password(req: ChangePasswordRequest, request: Request, current_
|
|||||||
auth.save_password_hash(auth.get_password_hash(req.new_password))
|
auth.save_password_hash(auth.get_password_hash(req.new_password))
|
||||||
return {"message": "Password changed successfully"}
|
return {"message": "Password changed successfully"}
|
||||||
|
|
||||||
|
|
||||||
# RBAC admin endpoints
|
# RBAC admin endpoints
|
||||||
@router.get("/rbac/config")
|
@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":
|
if current_user.role != "admin":
|
||||||
raise HTTPException(status_code=403, detail="Admin only")
|
raise HTTPException(status_code=403, detail="Admin only")
|
||||||
from rbac import load_rbac
|
from rbac import load_rbac
|
||||||
return load_rbac()
|
return load_rbac()
|
||||||
|
|
||||||
|
|
||||||
@router.put("/rbac/overrides/{username}")
|
@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":
|
if current_user.role != "admin":
|
||||||
raise HTTPException(status_code=403, detail="Admin only")
|
raise HTTPException(status_code=403, detail="Admin only")
|
||||||
from rbac import update_rbac
|
from rbac import update_rbac
|
||||||
@@ -251,16 +208,14 @@ async def rbac_set_override(username: str, request: Request, current_user=Depend
|
|||||||
update_rbac(mutator)
|
update_rbac(mutator)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/preferences")
|
@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
|
from rbac import get_sidebar_order
|
||||||
order = get_sidebar_order(current_user.username)
|
order = get_sidebar_order(current_user.username)
|
||||||
return {"sidebar_order": order}
|
return {"sidebar_order": order}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/preferences")
|
@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
|
import traceback
|
||||||
from rbac import save_sidebar_order
|
from rbac import save_sidebar_order
|
||||||
try:
|
try:
|
||||||
@@ -277,9 +232,8 @@ async def save_preferences(request: Request, current_user=Depends(auth.get_curre
|
|||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/rbac/overrides/{username}")
|
@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":
|
if current_user.role != "admin":
|
||||||
raise HTTPException(status_code=403, detail="Admin only")
|
raise HTTPException(status_code=403, detail="Admin only")
|
||||||
from rbac import update_rbac
|
from rbac import update_rbac
|
||||||
@@ -290,9 +244,8 @@ async def rbac_delete_override(username: str, current_user=Depends(auth.get_curr
|
|||||||
update_rbac(mutator)
|
update_rbac(mutator)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/rbac/roles/{role}")
|
@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":
|
if current_user.role != "admin":
|
||||||
raise HTTPException(status_code=403, detail="Admin only")
|
raise HTTPException(status_code=403, detail="Admin only")
|
||||||
from rbac import update_rbac
|
from rbac import update_rbac
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import docker
|
import docker
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Query
|
||||||
from config import DOCKER_HOST
|
from config import DOCKER_HOST
|
||||||
from rbac import require_admin
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
client = docker.DockerClient(base_url=DOCKER_HOST)
|
client = docker.DockerClient(base_url=DOCKER_HOST)
|
||||||
@@ -22,7 +21,7 @@ def list_containers():
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/containers/{container_id}/{action}", dependencies=[Depends(require_admin())])
|
@router.post("/containers/{container_id}/{action}")
|
||||||
def container_action(container_id: str, action: str):
|
def container_action(container_id: str, action: str):
|
||||||
if action not in ("start", "stop", "restart"):
|
if action not in ("start", "stop", "restart"):
|
||||||
return {"error": "invalid action"}
|
return {"error": "invalid action"}
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
|
from fastapi import APIRouter, UploadFile, File, HTTPException
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from config import VOLUME_ROOT
|
from config import VOLUME_ROOT
|
||||||
from rbac import require_admin
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
BASE = Path(VOLUME_ROOT)
|
BASE = Path(VOLUME_ROOT)
|
||||||
@@ -80,7 +79,7 @@ def download(path: str):
|
|||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload", dependencies=[Depends(require_admin())])
|
@router.post("/upload")
|
||||||
async def upload(path: str, file: UploadFile = File(...)):
|
async def upload(path: str, file: UploadFile = File(...)):
|
||||||
try:
|
try:
|
||||||
safe_name = _sanitize_filename(file.filename)
|
safe_name = _sanitize_filename(file.filename)
|
||||||
@@ -100,7 +99,7 @@ async def upload(path: str, file: UploadFile = File(...)):
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/delete", dependencies=[Depends(require_admin())])
|
@router.delete("/delete")
|
||||||
def delete(path: str, recursive: bool = False):
|
def delete(path: str, recursive: bool = False):
|
||||||
try:
|
try:
|
||||||
target = _safe_path(path)
|
target = _safe_path(path)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from slowapi import Limiter
|
from slowapi import Limiter
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
@@ -112,7 +112,7 @@ async def passkey_login_options(request: Request):
|
|||||||
|
|
||||||
@router.post("/passkey/login/verify")
|
@router.post("/passkey/login/verify")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def passkey_login_verify(request: Request, response: Response):
|
async def passkey_login_verify(request: Request):
|
||||||
"""Verify passkey authentication and issue tokens."""
|
"""Verify passkey authentication and issue tokens."""
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
challenge = _get_challenge(body.get("response", {}).get("clientDataJSON", ""))
|
challenge = _get_challenge(body.get("response", {}).get("clientDataJSON", ""))
|
||||||
@@ -146,7 +146,6 @@ async def passkey_login_verify(request: Request, response: Response):
|
|||||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||||
)
|
)
|
||||||
refresh_token = auth.create_refresh_token(data={"sub": username, "role": "admin"})
|
refresh_token = auth.create_refresh_token(data={"sub": username, "role": "admin"})
|
||||||
auth.set_auth_cookies(response, access_token, refresh_token)
|
|
||||||
return {
|
return {
|
||||||
"access_token": access_token,
|
"access_token": access_token,
|
||||||
"refresh_token": refresh_token,
|
"refresh_token": refresh_token,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from collections import Counter
|
|||||||
from fastapi import WebSocket, Query
|
from fastapi import WebSocket, Query
|
||||||
import asyncssh
|
import asyncssh
|
||||||
import config
|
import config
|
||||||
import auth
|
from auth import get_current_user_ws
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -95,13 +95,12 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str
|
|||||||
await websocket.close(code=1008, reason="Unknown host")
|
await websocket.close(code=1008, reason="Unknown host")
|
||||||
return
|
return
|
||||||
|
|
||||||
access_token = websocket.cookies.get(auth.ACCESS_COOKIE_NAME) or token
|
if not token:
|
||||||
if not access_token:
|
|
||||||
await websocket.close(code=1008, reason="Unauthorized")
|
await websocket.close(code=1008, reason="Unauthorized")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = await auth.get_current_user_ws(access_token)
|
user = await get_current_user_ws(token)
|
||||||
except Exception:
|
except Exception:
|
||||||
await websocket.close(code=1008, reason="Unauthorized")
|
await websocket.close(code=1008, reason="Unauthorized")
|
||||||
return
|
return
|
||||||
@@ -125,22 +124,13 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str
|
|||||||
|
|
||||||
h = HOSTS[host]
|
h = HOSTS[host]
|
||||||
try:
|
try:
|
||||||
conn = await asyncio.wait_for(
|
conn = await asyncssh.connect(
|
||||||
asyncssh.connect(
|
h["host"], username=h["user"],
|
||||||
h["host"], username=h["user"],
|
client_keys=[h["key"]], known_hosts=_known_hosts,
|
||||||
client_keys=[h["key"]], known_hosts=_known_hosts,
|
keepalive_interval=30,
|
||||||
keepalive_interval=15,
|
keepalive_count_max=3,
|
||||||
keepalive_count_max=8,
|
|
||||||
),
|
|
||||||
timeout=10.0
|
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except Exception:
|
||||||
logger.error("SSH connection to %s timed out after 10s", host)
|
|
||||||
await _release_session(session_id)
|
|
||||||
await websocket.close(code=1011, reason="SSH connection timeout")
|
|
||||||
return
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("SSH connection to %s failed: %s", host, e)
|
|
||||||
await _release_session(session_id)
|
await _release_session(session_id)
|
||||||
await websocket.close(code=1011, reason="SSH connection failed")
|
await websocket.close(code=1011, reason="SSH connection failed")
|
||||||
return
|
return
|
||||||
@@ -158,12 +148,8 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str
|
|||||||
if not data:
|
if not data:
|
||||||
break
|
break
|
||||||
await websocket.send_bytes(data)
|
await websocket.send_bytes(data)
|
||||||
except asyncssh.Error as e:
|
except (asyncssh.Error, asyncio.CancelledError):
|
||||||
logger.warning("SSH read error for %s: %s", host, e)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
|
||||||
logger.error("Unexpected error reading SSH for %s: %s", host, e)
|
|
||||||
|
|
||||||
read_task = asyncio.create_task(read_ssh())
|
read_task = asyncio.create_task(read_ssh())
|
||||||
|
|
||||||
@@ -182,8 +168,8 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str
|
|||||||
if msg["text"] == "__ping__":
|
if msg["text"] == "__ping__":
|
||||||
continue
|
continue
|
||||||
process.stdin.write(msg["text"].encode())
|
process.stdin.write(msg["text"].encode())
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.info("WebSocket receive loop ended for %s: %s", host, e)
|
pass
|
||||||
finally:
|
finally:
|
||||||
read_task.cancel()
|
read_task.cancel()
|
||||||
process.close()
|
process.close()
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ services:
|
|||||||
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
|
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
|
||||||
- TELEGRAM_PROXY=${TELEGRAM_PROXY:-}
|
- TELEGRAM_PROXY=${TELEGRAM_PROXY:-}
|
||||||
- CORS_ORIGINS=https://dev.nas.jimmygan.com,https://dev.nas.jimmygan.com:8443
|
- CORS_ORIGINS=https://dev.nas.jimmygan.com,https://dev.nas.jimmygan.com:8443
|
||||||
- WEBAUTHN_RP_ID=jimmygan.com
|
|
||||||
- WEBAUTHN_ORIGINS=https://dev.nas.jimmygan.com,https://dev.nas.jimmygan.com:8443,https://auth.jimmygan.com:8443
|
|
||||||
- LITELLM_URL=http://litellm:4005
|
- LITELLM_URL=http://litellm:4005
|
||||||
- LITELLM_HEALTH_API_KEY=${LITELLM_HEALTH_API_KEY:-}
|
- LITELLM_HEALTH_API_KEY=${LITELLM_HEALTH_API_KEY:-}
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -47,8 +47,6 @@ services:
|
|||||||
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
|
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
|
||||||
- TELEGRAM_PROXY=${TELEGRAM_PROXY:-}
|
- TELEGRAM_PROXY=${TELEGRAM_PROXY:-}
|
||||||
- CORS_ORIGINS=https://nas.jimmygan.com
|
- CORS_ORIGINS=https://nas.jimmygan.com
|
||||||
- WEBAUTHN_RP_ID=jimmygan.com
|
|
||||||
- WEBAUTHN_ORIGINS=https://nas.jimmygan.com,https://auth.jimmygan.com:8443
|
|
||||||
- LITELLM_HEALTH_API_KEY=${LITELLM_HEALTH_API_KEY:-}
|
- LITELLM_HEALTH_API_KEY=${LITELLM_HEALTH_API_KEY:-}
|
||||||
- LITELLM_URL=http://litellm:4005
|
- LITELLM_URL=http://litellm:4005
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -14,22 +14,7 @@
|
|||||||
import InfoEngine from "./routes/InfoEngine.svelte";
|
import InfoEngine from "./routes/InfoEngine.svelte";
|
||||||
import Login from "./routes/Login.svelte";
|
import Login from "./routes/Login.svelte";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { getToken, checkAuth, setToken, currentUser, setCurrentUser, getPreferences, savePreferences, tryRefreshSession, logout as logoutSession } from "./lib/api.js";
|
import { getToken, checkAuth, setToken, setRefreshToken, currentUser, setCurrentUser, getPreferences, savePreferences } from "./lib/api.js";
|
||||||
|
|
||||||
const KNOWN_PAGES = new Set([
|
|
||||||
"dashboard",
|
|
||||||
"docker",
|
|
||||||
"gitea",
|
|
||||||
"files",
|
|
||||||
"terminal",
|
|
||||||
"openclaw",
|
|
||||||
"chat-digest",
|
|
||||||
"settings",
|
|
||||||
"security",
|
|
||||||
"litellm",
|
|
||||||
"cc-connect",
|
|
||||||
"info-engine",
|
|
||||||
]);
|
|
||||||
|
|
||||||
let page = $state("dashboard");
|
let page = $state("dashboard");
|
||||||
let dark = $state(false);
|
let dark = $state(false);
|
||||||
@@ -42,26 +27,6 @@
|
|||||||
let sidebarOrder = $state(null);
|
let sidebarOrder = $state(null);
|
||||||
let orderSaveTimer = null;
|
let orderSaveTimer = null;
|
||||||
|
|
||||||
function getRequestedPage() {
|
|
||||||
const requestedPage = new URLSearchParams(window.location.search).get("page");
|
|
||||||
return KNOWN_PAGES.has(requestedPage) ? requestedPage : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function canOpenPage(pageId) {
|
|
||||||
if (!pageId || !KNOWN_PAGES.has(pageId)) return false;
|
|
||||||
if (pageId === "settings") return userRole === "admin";
|
|
||||||
if (["litellm", "cc-connect", "info-engine"].includes(pageId)) return hasPageAccess("dashboard");
|
|
||||||
return hasPageAccess(pageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncPageQuery(pageId) {
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
if (pageId === "dashboard") url.searchParams.delete("page");
|
|
||||||
else url.searchParams.set("page", pageId);
|
|
||||||
if (pageId !== "terminal") url.searchParams.delete("host");
|
|
||||||
history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchUserInfo() {
|
async function fetchUserInfo() {
|
||||||
try {
|
try {
|
||||||
const data = await checkAuth();
|
const data = await checkAuth();
|
||||||
@@ -106,14 +71,13 @@
|
|||||||
|
|
||||||
// Try proxy auth first (Authelia forward-auth sets Remote-User header)
|
// Try proxy auth first (Authelia forward-auth sets Remote-User header)
|
||||||
try {
|
try {
|
||||||
const proxyRes = await fetch("/api/auth/proxy", { credentials: "same-origin" });
|
const proxyRes = await fetch("/api/auth/proxy");
|
||||||
if (proxyRes.ok) {
|
if (proxyRes.ok) {
|
||||||
const data = await proxyRes.json();
|
const data = await proxyRes.json();
|
||||||
setToken(data.access_token);
|
setToken(data.access_token);
|
||||||
|
setRefreshToken(data.refresh_token);
|
||||||
authorized = true;
|
authorized = true;
|
||||||
await fetchUserInfo();
|
await fetchUserInfo();
|
||||||
const requestedPage = getRequestedPage();
|
|
||||||
if (canOpenPage(requestedPage)) page = requestedPage;
|
|
||||||
loading = false;
|
loading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -122,20 +86,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Regular token auth check
|
// Regular token auth check
|
||||||
if (!getToken()) {
|
const token = getToken();
|
||||||
const refreshed = await tryRefreshSession();
|
if (!token) {
|
||||||
if (!refreshed) {
|
|
||||||
loading = false;
|
loading = false;
|
||||||
authorized = false;
|
authorized = false;
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetchUserInfo();
|
await fetchUserInfo();
|
||||||
authorized = true;
|
authorized = true;
|
||||||
const requestedPage = getRequestedPage();
|
|
||||||
if (canOpenPage(requestedPage)) page = requestedPage;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Auth check failed:", e);
|
console.error("Auth check failed:", e);
|
||||||
setToken("");
|
setToken("");
|
||||||
@@ -145,11 +105,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!authorized || loading || !canOpenPage(page)) return;
|
|
||||||
syncPageQuery(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
dark = !dark;
|
dark = !dark;
|
||||||
if (dark) {
|
if (dark) {
|
||||||
@@ -161,13 +116,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
function logout() {
|
||||||
try {
|
|
||||||
await logoutSession();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Logout failed:", e);
|
|
||||||
}
|
|
||||||
setToken("");
|
setToken("");
|
||||||
|
setRefreshToken("");
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const BASE = "/api";
|
const BASE = "/api";
|
||||||
let token = "";
|
let token = localStorage.getItem("token") || "";
|
||||||
|
|
||||||
// Current user info populated after auth
|
// Current user info populated after auth
|
||||||
export let currentUser = { username: "", role: "", pages: [] };
|
export let currentUser = { username: "", role: "", pages: [] };
|
||||||
@@ -18,51 +18,33 @@ export function getToken() {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh token is cookie-managed server-side.
|
export function setRefreshToken(t) {
|
||||||
export function setRefreshToken() {}
|
if (t) localStorage.setItem("refresh_token", t);
|
||||||
|
else localStorage.removeItem("refresh_token");
|
||||||
|
}
|
||||||
|
|
||||||
let refreshPromise = null;
|
function getRefreshToken() {
|
||||||
|
|
||||||
function getLegacyRefreshToken() {
|
|
||||||
return localStorage.getItem("refresh_token") || "";
|
return localStorage.getItem("refresh_token") || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearLegacyRefreshToken() {
|
let refreshPromise = null;
|
||||||
localStorage.removeItem("refresh_token");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestRefresh(body) {
|
async function tryRefresh() {
|
||||||
const r = await fetch(BASE + "/auth/refresh", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
credentials: "same-origin",
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
});
|
|
||||||
if (!r.ok) return false;
|
|
||||||
const data = await r.json();
|
|
||||||
setToken(data.access_token || "");
|
|
||||||
return !!data.access_token;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function tryRefreshSession() {
|
|
||||||
if (refreshPromise) return refreshPromise;
|
if (refreshPromise) return refreshPromise;
|
||||||
|
const rt = getRefreshToken();
|
||||||
|
if (!rt) return false;
|
||||||
refreshPromise = (async () => {
|
refreshPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const cookieRefreshed = await requestRefresh();
|
const r = await fetch(BASE + "/auth/refresh", {
|
||||||
if (cookieRefreshed) {
|
method: "POST",
|
||||||
clearLegacyRefreshToken();
|
headers: { "Content-Type": "application/json" },
|
||||||
return true;
|
body: JSON.stringify({ refresh_token: rt }),
|
||||||
}
|
});
|
||||||
|
if (!r.ok) return false;
|
||||||
const legacyRefreshToken = getLegacyRefreshToken();
|
const data = await r.json();
|
||||||
if (!legacyRefreshToken) return false;
|
setToken(data.access_token);
|
||||||
const legacyRefreshed = await requestRefresh({ refresh_token: legacyRefreshToken });
|
setRefreshToken(data.refresh_token);
|
||||||
if (legacyRefreshed) {
|
return true;
|
||||||
clearLegacyRefreshToken();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
clearLegacyRefreshToken();
|
|
||||||
return false;
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -82,29 +64,27 @@ async function request(path, opts = {}) {
|
|||||||
delete opts.json;
|
delete opts.json;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchOpts = { ...opts, headers };
|
|
||||||
if (path.startsWith("/auth/")) fetchOpts.credentials = "same-origin";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let r = await fetch(BASE + path, fetchOpts);
|
let r = await fetch(BASE + path, { ...opts, headers });
|
||||||
|
|
||||||
if (r.status === 401 && !path.includes("/auth/refresh")) {
|
if (r.status === 401 && !path.includes("/auth/refresh")) {
|
||||||
const refreshed = await tryRefreshSession();
|
const refreshed = await tryRefresh();
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
r = await fetch(BASE + path, fetchOpts);
|
r = await fetch(BASE + path, { ...opts, headers });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (r.status === 401) {
|
if (r.status === 401) {
|
||||||
setToken("");
|
setToken("");
|
||||||
|
setRefreshToken("");
|
||||||
throw new Error("Unauthorized");
|
throw new Error("Unauthorized");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
const error = new Error(`HTTP ${r.status}`);
|
const error = new Error(`HTTP ${r.status}`);
|
||||||
error.status = r.status;
|
error.status = r.status;
|
||||||
try { error.body = await r.json(); } catch {}
|
try { error.body = await r.json(); } catch { }
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return r.json();
|
return r.json();
|
||||||
@@ -151,10 +131,6 @@ export function checkAuth() {
|
|||||||
return request("/auth/me");
|
return request("/auth/me");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logout() {
|
|
||||||
return request("/auth/logout", { method: "POST" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPreferences() {
|
export function getPreferences() {
|
||||||
return get("/auth/preferences");
|
return get("/auth/preferences");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { login, setToken } from "../lib/api.js";
|
import { login, setToken, setRefreshToken, get, post } from "../lib/api.js";
|
||||||
import { fade, fly } from "svelte/transition";
|
import { fade, fly } from "svelte/transition";
|
||||||
|
|
||||||
let username = $state("");
|
let username = $state("");
|
||||||
@@ -56,6 +56,7 @@
|
|||||||
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || "Passkey verification failed"); }
|
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || "Passkey verification failed"); }
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setToken(data.access_token);
|
setToken(data.access_token);
|
||||||
|
setRefreshToken(data.refresh_token);
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.name === "NotAllowedError") { error = "Passkey authentication cancelled"; }
|
if (e.name === "NotAllowedError") { error = "Passkey authentication cancelled"; }
|
||||||
@@ -78,6 +79,7 @@
|
|||||||
const res = await login(payload); // api.js helper sends JSON
|
const res = await login(payload); // api.js helper sends JSON
|
||||||
|
|
||||||
setToken(res.access_token);
|
setToken(res.access_token);
|
||||||
|
setRefreshToken(res.refresh_token);
|
||||||
// Determine if we need to reload or just update state.
|
// Determine if we need to reload or just update state.
|
||||||
// Reload is safest to clear any stale state.
|
// Reload is safest to clear any stale state.
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
|
|||||||
@@ -36,27 +36,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function registerPasskey() {
|
async function registerPasskey() {
|
||||||
alert("registerPasskey called!");
|
|
||||||
passkeyLoading = true;
|
passkeyLoading = true;
|
||||||
passkeyMsg = ""; passkeyErr = "";
|
passkeyMsg = ""; passkeyErr = "";
|
||||||
try {
|
try {
|
||||||
alert("Checking PublicKeyCredential support...");
|
|
||||||
if (!window.PublicKeyCredential) {
|
if (!window.PublicKeyCredential) {
|
||||||
alert("PublicKeyCredential NOT supported!");
|
|
||||||
throw new Error("Passkeys are not supported in this browser or context");
|
throw new Error("Passkeys are not supported in this browser or context");
|
||||||
}
|
}
|
||||||
alert("Fetching registration options...");
|
|
||||||
const opts = await post("/auth/passkey/register/options");
|
const opts = await post("/auth/passkey/register/options");
|
||||||
alert("Got options: " + JSON.stringify(opts).substring(0, 100));
|
|
||||||
opts.challenge = base64urlToBuffer(opts.challenge);
|
opts.challenge = base64urlToBuffer(opts.challenge);
|
||||||
opts.user.id = base64urlToBuffer(opts.user.id);
|
opts.user.id = base64urlToBuffer(opts.user.id);
|
||||||
if (opts.excludeCredentials) {
|
if (opts.excludeCredentials) {
|
||||||
opts.excludeCredentials = opts.excludeCredentials.map(c => ({ ...c, id: base64urlToBuffer(c.id) }));
|
opts.excludeCredentials = opts.excludeCredentials.map(c => ({ ...c, id: base64urlToBuffer(c.id) }));
|
||||||
}
|
}
|
||||||
const cred = await navigator.credentials.create({ publicKey: opts });
|
const cred = await navigator.credentials.create({ publicKey: opts });
|
||||||
if (!cred) {
|
|
||||||
throw new Error("Passkey creation was cancelled");
|
|
||||||
}
|
|
||||||
const name = prompt("Name this passkey (e.g. MacBook, iPhone):", "Passkey") || "Passkey";
|
const name = prompt("Name this passkey (e.g. MacBook, iPhone):", "Passkey") || "Passkey";
|
||||||
const body = {
|
const body = {
|
||||||
id: cred.id,
|
id: cred.id,
|
||||||
@@ -72,13 +64,9 @@
|
|||||||
passkeyMsg = "Passkey registered successfully";
|
passkeyMsg = "Passkey registered successfully";
|
||||||
await loadPasskeys();
|
await loadPasskeys();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Passkey registration error:", e);
|
passkeyErr = e.body?.detail || e.message || "Failed to register passkey";
|
||||||
const errorMsg = e.body?.detail || e.message || "Failed to register passkey";
|
|
||||||
passkeyErr = errorMsg;
|
|
||||||
alert("Passkey Error: " + errorMsg + "\n\nFull error: " + JSON.stringify(e, null, 2));
|
|
||||||
} finally {
|
|
||||||
passkeyLoading = false;
|
|
||||||
}
|
}
|
||||||
|
passkeyLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearPasskeys() {
|
async function clearPasskeys() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { VoiceSession } from "../lib/voice.js";
|
import { VoiceSession } from "../lib/voice.js";
|
||||||
import { tryRefreshSession } from "../lib/api.js";
|
import { getToken } from "../lib/api.js";
|
||||||
|
|
||||||
const PERSISTENCE_STATUS_PREFIX = "__DASHBOARD_PERSISTENCE__:";
|
const PERSISTENCE_STATUS_PREFIX = "__DASHBOARD_PERSISTENCE__:";
|
||||||
const PERSISTENCE_STATUS_PREFIX_BYTES = new TextEncoder().encode(PERSISTENCE_STATUS_PREFIX);
|
const PERSISTENCE_STATUS_PREFIX_BYTES = new TextEncoder().encode(PERSISTENCE_STATUS_PREFIX);
|
||||||
@@ -189,13 +189,11 @@
|
|||||||
fit.fit();
|
fit.fit();
|
||||||
|
|
||||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const token = getToken() || "";
|
||||||
let heartbeat = null;
|
let heartbeat = null;
|
||||||
let reconnectTimer = null;
|
let reconnectTimer = null;
|
||||||
let closedManually = false;
|
let closedManually = false;
|
||||||
let reconnecting = false;
|
let reconnecting = false;
|
||||||
let authRecoveryInFlight = false;
|
|
||||||
let reconnectAttempts = 0;
|
|
||||||
const MAX_RECONNECT_DELAY = 30000;
|
|
||||||
|
|
||||||
function clearHeartbeat() {
|
function clearHeartbeat() {
|
||||||
if (heartbeat) {
|
if (heartbeat) {
|
||||||
@@ -204,46 +202,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAuthExpired(reason = "Session expired — please log in again") {
|
|
||||||
clearHeartbeat();
|
|
||||||
if (reconnectTimer) {
|
|
||||||
clearTimeout(reconnectTimer);
|
|
||||||
reconnectTimer = null;
|
|
||||||
}
|
|
||||||
reconnecting = false;
|
|
||||||
updateTab(tabId, {
|
|
||||||
connected: false,
|
|
||||||
error: reason,
|
|
||||||
statusBanner: "",
|
|
||||||
});
|
|
||||||
term.write(`\r\n\x1b[90m[${reason}]\x1b[0m`);
|
|
||||||
const d = tabData.get(tabId);
|
|
||||||
if (d) {
|
|
||||||
d.ws = null;
|
|
||||||
d.heartbeat = heartbeat;
|
|
||||||
d.reconnectTimer = reconnectTimer;
|
|
||||||
d.closedManually = closedManually;
|
|
||||||
d.reconnecting = reconnecting;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleReconnect(reason) {
|
function scheduleReconnect(reason) {
|
||||||
clearHeartbeat();
|
clearHeartbeat();
|
||||||
if (closedManually || reconnectTimer) return;
|
if (closedManually || reconnectTimer) return;
|
||||||
reconnecting = true;
|
reconnecting = true;
|
||||||
reconnectAttempts++;
|
|
||||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), MAX_RECONNECT_DELAY);
|
|
||||||
updateTab(tabId, {
|
updateTab(tabId, {
|
||||||
connected: false,
|
connected: false,
|
||||||
error: `${reason} — reconnecting in ${Math.round(delay / 1000)}s (attempt ${reconnectAttempts})...`,
|
error: `${reason} — reconnecting...`,
|
||||||
statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "",
|
statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "",
|
||||||
});
|
});
|
||||||
term.write(`\r\n\x1b[90m[${reason} — reconnecting in ${Math.round(delay / 1000)}s...]\x1b[0m`);
|
term.write(`\r\n\x1b[90m[${reason} — reconnecting...]\x1b[0m`);
|
||||||
reconnectTimer = setTimeout(() => {
|
reconnectTimer = setTimeout(() => {
|
||||||
reconnectTimer = null;
|
reconnectTimer = null;
|
||||||
if (!tabData.has(tabId) || closedManually) return;
|
if (!tabData.has(tabId) || closedManually) return;
|
||||||
connect(true);
|
connect();
|
||||||
}, delay);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendSize(ws) {
|
function sendSize(ws) {
|
||||||
@@ -256,22 +229,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connect(forceRefresh = false) {
|
function connect() {
|
||||||
if (forceRefresh) {
|
const token = getToken() || "";
|
||||||
const refreshed = await tryRefreshSession();
|
|
||||||
if (!refreshed) {
|
|
||||||
handleAuthExpired();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ws = new WebSocket(
|
const ws = new WebSocket(
|
||||||
`${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}`
|
`${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}&token=${encodeURIComponent(token)}`
|
||||||
);
|
);
|
||||||
ws.binaryType = "arraybuffer";
|
ws.binaryType = "arraybuffer";
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
reconnectAttempts = 0;
|
|
||||||
updateTab(tabId, {
|
updateTab(tabId, {
|
||||||
connected: true,
|
connected: true,
|
||||||
error: "",
|
error: "",
|
||||||
@@ -281,7 +246,7 @@
|
|||||||
clearHeartbeat();
|
clearHeartbeat();
|
||||||
heartbeat = setInterval(() => {
|
heartbeat = setInterval(() => {
|
||||||
if (ws.readyState === 1) ws.send("__ping__");
|
if (ws.readyState === 1) ws.send("__ping__");
|
||||||
}, 12000);
|
}, 25000);
|
||||||
const d = tabData.get(tabId);
|
const d = tabData.get(tabId);
|
||||||
if (d) {
|
if (d) {
|
||||||
d.ws = ws;
|
d.ws = ws;
|
||||||
@@ -307,30 +272,10 @@
|
|||||||
}
|
}
|
||||||
term.write(data);
|
term.write(data);
|
||||||
};
|
};
|
||||||
ws.onclose = async (e) => {
|
ws.onclose = (e) => {
|
||||||
const reason = e.reason || `Connection closed (code ${e.code})`;
|
const reason = e.reason || `Connection closed (code ${e.code})`;
|
||||||
const d = tabData.get(tabId);
|
const d = tabData.get(tabId);
|
||||||
if (d) d.ws = null;
|
if (d) d.ws = null;
|
||||||
if (e.code === 1008 && reason === "Unauthorized") {
|
|
||||||
if (!closedManually && !authRecoveryInFlight) {
|
|
||||||
authRecoveryInFlight = true;
|
|
||||||
const refreshed = await tryRefreshSession();
|
|
||||||
authRecoveryInFlight = false;
|
|
||||||
if (refreshed && !closedManually) {
|
|
||||||
reconnecting = true;
|
|
||||||
updateTab(tabId, {
|
|
||||||
connected: false,
|
|
||||||
error: "Refreshing session...",
|
|
||||||
statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "",
|
|
||||||
});
|
|
||||||
term.write(`\r\n\x1b[90m[Refreshing session...]\x1b[0m`);
|
|
||||||
void connect(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
handleAuthExpired("Session expired — please log in again");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!closedManually) scheduleReconnect(reason);
|
if (!closedManually) scheduleReconnect(reason);
|
||||||
else {
|
else {
|
||||||
clearHeartbeat();
|
clearHeartbeat();
|
||||||
@@ -356,11 +301,7 @@
|
|||||||
return ws;
|
return ws;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ws = null;
|
let ws = connect();
|
||||||
void connect().then((connectedWs) => {
|
|
||||||
ws = connectedWs;
|
|
||||||
if (connectedWs) voice.attach(term, connectedWs);
|
|
||||||
});
|
|
||||||
term.onData((data) => {
|
term.onData((data) => {
|
||||||
const currentWs = tabData.get(tabId)?.ws;
|
const currentWs = tabData.get(tabId)?.ws;
|
||||||
if (currentWs?.readyState === 1) currentWs.send(new TextEncoder().encode(data));
|
if (currentWs?.readyState === 1) currentWs.send(new TextEncoder().encode(data));
|
||||||
|
|||||||
@@ -28,12 +28,6 @@ nas.jimmygan.com {
|
|||||||
reverse_proxy 100.78.131.124:4000
|
reverse_proxy 100.78.131.124:4000
|
||||||
}
|
}
|
||||||
|
|
||||||
dev.nas.jimmygan.com {
|
|
||||||
import security_headers
|
|
||||||
import authelia
|
|
||||||
reverse_proxy 100.78.131.124:4001
|
|
||||||
}
|
|
||||||
|
|
||||||
music.jimmygan.com {
|
music.jimmygan.com {
|
||||||
import security_headers
|
import security_headers
|
||||||
import authelia
|
import authelia
|
||||||
|
|||||||
Reference in New Issue
Block a user