mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 21:03:18 +08:00
Harden auth with revocable sessions, cookies, and single-login default.
Issue short-lived access JWTs backed by AuthSession rows, HttpOnly cookies with refresh rotation, idle timeout, session management UI, WebCRT ownership caps, and optional Redis login rate limits; new logins revoke other sessions by default. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
ba33ab5c4f
commit
20c2fcd496
34 changed files with 1462 additions and 149 deletions
40
alembic/versions/20260806_auth_refresh.py
Normal file
40
alembic/versions/20260806_auth_refresh.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""Add refresh token columns on auth_session.
|
||||
|
||||
Revision ID: 20260806_auth_refresh
|
||||
Revises: 20260806_auth_session
|
||||
Create Date: 2026-08-06
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260806_auth_refresh"
|
||||
down_revision: Union[str, Sequence[str], None] = "20260806_auth_session"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
from netx_api.schema_patches import apply_auth_schema_patches
|
||||
|
||||
apply_auth_schema_patches(op.get_bind())
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
dialect = bind.dialect.name
|
||||
if dialect == "postgresql":
|
||||
op.execute("ALTER TABLE auth_session DROP COLUMN IF EXISTS refresh_expires_at")
|
||||
op.execute("ALTER TABLE auth_session DROP COLUMN IF EXISTS refresh_token_hash")
|
||||
else:
|
||||
try:
|
||||
op.drop_column("auth_session", "refresh_expires_at")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
op.drop_column("auth_session", "refresh_token_hash")
|
||||
except Exception:
|
||||
pass
|
||||
35
alembic/versions/20260806_auth_session.py
Normal file
35
alembic/versions/20260806_auth_session.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""Create auth_session table for revocable JWT logins.
|
||||
|
||||
Revision ID: 20260806_auth_session
|
||||
Revises: 20260802_legacy
|
||||
Create Date: 2026-08-06
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260806_auth_session"
|
||||
down_revision: Union[str, Sequence[str], None] = "20260802_legacy"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
from netx_api.schema_patches import apply_auth_schema_patches
|
||||
|
||||
apply_auth_schema_patches(op.get_bind())
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
dialect = bind.dialect.name
|
||||
if dialect == "postgresql":
|
||||
op.execute("DROP TABLE IF EXISTS auth_session")
|
||||
else:
|
||||
try:
|
||||
op.drop_table("auth_session")
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -43,3 +43,5 @@ If Alembic history was never stamped and you prefer to mark current without re-r
|
|||
|----------|---------|
|
||||
| `20260802_scopes` | `app_user.scopes` / `api_token.scopes` |
|
||||
| `20260802_legacy` | Shared brownfield patches (alarms, inventory, managed_ne, topology, port traffic, key-alert, …) |
|
||||
| `20260806_auth_session` | Revocable JWT login sessions (`auth_session`) |
|
||||
| `20260806_auth_refresh` | Refresh token columns on `auth_session` |
|
||||
|
|
|
|||
94
netx_api/auth_cookies.py
Normal file
94
netx_api/auth_cookies.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""Auth cookie helpers (HttpOnly access + refresh)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Response
|
||||
from starlette.requests import Request
|
||||
|
||||
from .config import settings
|
||||
|
||||
COOKIE_ACCESS = "netx_at"
|
||||
COOKIE_REFRESH = "netx_rt"
|
||||
|
||||
|
||||
def _cookie_secure(request: Request | None = None) -> bool:
|
||||
configured = getattr(settings, "auth_cookie_secure", None)
|
||||
if configured is True:
|
||||
return True
|
||||
if configured is False:
|
||||
return False
|
||||
# Auto: secure when request is HTTPS (or behind TLS-terminating proxy).
|
||||
if request is None:
|
||||
return False
|
||||
if request.url.scheme == "https":
|
||||
return True
|
||||
fwd = str(request.headers.get("x-forwarded-proto") or "").split(",")[0].strip().lower()
|
||||
return fwd == "https"
|
||||
|
||||
|
||||
def _cookie_samesite() -> str:
|
||||
raw = str(getattr(settings, "auth_cookie_samesite", "lax") or "lax").strip().lower()
|
||||
if raw in ("lax", "strict", "none"):
|
||||
return raw
|
||||
return "lax"
|
||||
|
||||
|
||||
def access_cookie_max_age() -> int:
|
||||
return max(300, int(getattr(settings, "auth_token_ttl_sec", 3600) or 3600))
|
||||
|
||||
|
||||
def refresh_cookie_max_age() -> int:
|
||||
return max(3600, int(getattr(settings, "auth_refresh_ttl_sec", 604800) or 604800))
|
||||
|
||||
|
||||
def set_auth_cookies(
|
||||
response: Response,
|
||||
*,
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
request: Request | None = None,
|
||||
) -> None:
|
||||
if not bool(getattr(settings, "auth_cookie_enabled", True)):
|
||||
return
|
||||
secure = _cookie_secure(request)
|
||||
samesite = _cookie_samesite()
|
||||
# SameSite=None requires Secure.
|
||||
if samesite == "none":
|
||||
secure = True
|
||||
common: dict[str, Any] = {
|
||||
"httponly": True,
|
||||
"secure": secure,
|
||||
"samesite": samesite,
|
||||
"path": "/",
|
||||
}
|
||||
response.set_cookie(
|
||||
COOKIE_ACCESS,
|
||||
access_token,
|
||||
max_age=access_cookie_max_age(),
|
||||
**common,
|
||||
)
|
||||
response.set_cookie(
|
||||
COOKIE_REFRESH,
|
||||
refresh_token,
|
||||
max_age=refresh_cookie_max_age(),
|
||||
**common,
|
||||
)
|
||||
|
||||
|
||||
def clear_auth_cookies(response: Response, *, request: Request | None = None) -> None:
|
||||
secure = _cookie_secure(request)
|
||||
samesite = _cookie_samesite()
|
||||
if samesite == "none":
|
||||
secure = True
|
||||
for name in (COOKIE_ACCESS, COOKIE_REFRESH):
|
||||
response.delete_cookie(name, path="/", secure=secure, httponly=True, samesite=samesite)
|
||||
|
||||
|
||||
def read_access_cookie(request: Request) -> str:
|
||||
return str(request.cookies.get(COOKIE_ACCESS) or "").strip()
|
||||
|
||||
|
||||
def read_refresh_cookie(request: Request) -> str:
|
||||
return str(request.cookies.get(COOKIE_REFRESH) or "").strip()
|
||||
|
|
@ -8,6 +8,7 @@ from typing import Annotated, Callable
|
|||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .auth_cookies import read_access_cookie
|
||||
from .auth_scopes import (
|
||||
ALL_SCOPES,
|
||||
effective_token_scopes,
|
||||
|
|
@ -16,7 +17,7 @@ from .auth_scopes import (
|
|||
has_scope,
|
||||
normalize_scopes,
|
||||
)
|
||||
from .auth_service import get_user_by_id, resolve_api_token_row
|
||||
from .auth_service import get_auth_session, get_user_by_id, resolve_api_token_row, touch_auth_session
|
||||
from .auth_tokens import decode_access_token
|
||||
from .config import settings
|
||||
from .db import get_db
|
||||
|
|
@ -29,23 +30,25 @@ class AuthContext:
|
|||
auth_via: str # jwt | api_token | disabled
|
||||
scopes: frozenset[str] = field(default_factory=frozenset)
|
||||
api_token_id: str = ""
|
||||
session_jti: str = ""
|
||||
|
||||
|
||||
def _extract_bearer(request: Request) -> str:
|
||||
auth = str(request.headers.get("authorization") or "").strip()
|
||||
if auth.lower().startswith("bearer "):
|
||||
return auth[7:].strip()
|
||||
# Prefer Header; query access_token is deprecated (WebSocket may still use short-lived tickets).
|
||||
q = request.query_params.get("access_token")
|
||||
return str(q or "").strip()
|
||||
# Prefer Authorization; fall back to HttpOnly access cookie for browser sessions.
|
||||
return read_access_cookie(request)
|
||||
|
||||
|
||||
def user_scopes(user: AppUser) -> frozenset[str]:
|
||||
return effective_user_scopes(role=str(user.role or "user"), override=getattr(user, "scopes", None) or [])
|
||||
|
||||
|
||||
def resolve_user_from_token(db: Session, token: str) -> tuple[AppUser, str, frozenset[str], str] | None:
|
||||
"""Return (user, via, scopes, api_token_id) or None."""
|
||||
def resolve_user_from_token(
|
||||
db: Session, token: str
|
||||
) -> tuple[AppUser, str, frozenset[str], str, str] | None:
|
||||
"""Return (user, via, scopes, api_token_id, session_jti) or None."""
|
||||
raw = str(token or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
|
@ -60,17 +63,26 @@ def resolve_user_from_token(db: Session, token: str) -> tuple[AppUser, str, froz
|
|||
user_scopes=user_scopes(user),
|
||||
token_scopes=getattr(row, "scopes", None) or [],
|
||||
)
|
||||
return user, "api_token", scopes, str(row.id)
|
||||
return user, "api_token", scopes, str(row.id), ""
|
||||
try:
|
||||
payload = decode_access_token(raw)
|
||||
except Exception:
|
||||
return None
|
||||
if str(payload.get("typ") or "") not in ("", "access"):
|
||||
return None
|
||||
jti = str(payload.get("jti") or "").strip()
|
||||
if not jti:
|
||||
return None
|
||||
sess = get_auth_session(db, jti)
|
||||
if sess is None:
|
||||
return None
|
||||
user = get_user_by_id(db, str(payload.get("sub") or ""))
|
||||
if user is None or not user.is_active:
|
||||
return None
|
||||
return user, "jwt", user_scopes(user), ""
|
||||
if str(sess.user_id) != str(user.id):
|
||||
return None
|
||||
touch_auth_session(db, jti)
|
||||
return user, "jwt", user_scopes(user), "", jti
|
||||
|
||||
|
||||
def get_optional_user(
|
||||
|
|
@ -86,19 +98,25 @@ def get_optional_user(
|
|||
if not isinstance(scopes, frozenset):
|
||||
scopes = user_scopes(cached)
|
||||
token_id = str(getattr(request.state, "auth_api_token_id", "") or "")
|
||||
return AuthContext(user=cached, auth_via=via, scopes=scopes, api_token_id=token_id)
|
||||
jti = str(getattr(request.state, "auth_session_jti", "") or "")
|
||||
return AuthContext(
|
||||
user=cached, auth_via=via, scopes=scopes, api_token_id=token_id, session_jti=jti
|
||||
)
|
||||
token = _extract_bearer(request)
|
||||
if not token:
|
||||
return None
|
||||
resolved = resolve_user_from_token(db, token)
|
||||
if resolved is None:
|
||||
return None
|
||||
user, via, scopes, token_id = resolved
|
||||
user, via, scopes, token_id, jti = resolved
|
||||
request.state.auth_user = user
|
||||
request.state.auth_via = via
|
||||
request.state.auth_scopes = scopes
|
||||
request.state.auth_api_token_id = token_id
|
||||
return AuthContext(user=user, auth_via=via, scopes=scopes, api_token_id=token_id)
|
||||
request.state.auth_session_jti = jti
|
||||
return AuthContext(
|
||||
user=user, auth_via=via, scopes=scopes, api_token_id=token_id, session_jti=jti
|
||||
)
|
||||
|
||||
|
||||
def require_user(
|
||||
|
|
|
|||
|
|
@ -29,12 +29,23 @@ _PUBLIC_EXACT = frozenset(
|
|||
"/v1/metrics/json",
|
||||
"/favicon.ico",
|
||||
"/v1/auth/login",
|
||||
"/v1/auth/refresh",
|
||||
}
|
||||
)
|
||||
_PUBLIC_PREFIXES = (
|
||||
"/assets",
|
||||
)
|
||||
|
||||
# While must_change_password is true, only these authenticated endpoints are allowed.
|
||||
_PASSWORD_CHANGE_ALLOW = frozenset(
|
||||
{
|
||||
("GET", "/v1/auth/me"),
|
||||
("POST", "/v1/auth/change-password"),
|
||||
("POST", "/v1/auth/logout"),
|
||||
("GET", "/v1/auth/sessions"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _docs_public() -> bool:
|
||||
return bool(getattr(settings, "docs_enabled", False))
|
||||
|
|
@ -92,10 +103,11 @@ class AuthAuditMiddleware(BaseHTTPMiddleware):
|
|||
auth = str(request.headers.get("authorization") or "").strip()
|
||||
if auth.lower().startswith("bearer "):
|
||||
token = auth[7:].strip()
|
||||
# Query access_token: only allow for non-webcrt paths as deprecated fallback;
|
||||
# WebCRT HTTP must use Authorization header (see webcrt_router).
|
||||
if not token and not path.startswith("/v1/webcrt"):
|
||||
token = str(request.query_params.get("access_token") or "").strip()
|
||||
if not token:
|
||||
from .auth_cookies import read_access_cookie
|
||||
|
||||
token = read_access_cookie(request)
|
||||
# Query-string access_token is no longer accepted (leaks via logs/proxies).
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
|
|
@ -112,7 +124,26 @@ class AuthAuditMiddleware(BaseHTTPMiddleware):
|
|||
detail={},
|
||||
)
|
||||
return JSONResponse(status_code=401, content={"detail": "unauthorized"})
|
||||
user, via, scopes, token_id = resolved
|
||||
user, via, scopes, token_id, jti = resolved
|
||||
if bool(getattr(user, "must_change_password", False)):
|
||||
allow_key = (request.method.upper(), path.rstrip("/") if len(path) > 1 else path)
|
||||
if allow_key not in _PASSWORD_CHANGE_ALLOW:
|
||||
write_audit(
|
||||
db,
|
||||
action="auth.password_change_required",
|
||||
actor_user_id=str(user.id),
|
||||
actor_username=str(user.username),
|
||||
method=request.method,
|
||||
path=path,
|
||||
status_code=403,
|
||||
client_ip=_client_ip(request),
|
||||
user_agent=str(request.headers.get("user-agent") or "")[:512],
|
||||
detail={"auth_via": via},
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": "password_change_required"},
|
||||
)
|
||||
need = required_scope_for_request(request.method, path)
|
||||
if need and not has_scope(scopes, need):
|
||||
write_audit(
|
||||
|
|
@ -141,6 +172,7 @@ class AuthAuditMiddleware(BaseHTTPMiddleware):
|
|||
request.state.auth_via = via
|
||||
request.state.auth_scopes = scopes
|
||||
request.state.auth_api_token_id = token_id
|
||||
request.state.auth_session_jti = jti
|
||||
actor_id = str(user.id)
|
||||
actor_name = str(user.username)
|
||||
auth_via = via
|
||||
|
|
|
|||
139
netx_api/auth_rate_limit.py
Normal file
139
netx_api/auth_rate_limit.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"""Login brute-force protection: Redis when configured, else in-process."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .config import settings
|
||||
|
||||
_log = logging.getLogger("netx.auth.rate")
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Bucket:
|
||||
window_start: float
|
||||
failures: int
|
||||
locked_until: float = 0.0
|
||||
|
||||
|
||||
_buckets: dict[str, _Bucket] = {}
|
||||
_redis_client: Any | None = None
|
||||
_redis_failed = False
|
||||
|
||||
|
||||
def _key(username: str, client_ip: str) -> str:
|
||||
return f"{str(client_ip or '').strip().lower()}|{str(username or '').strip().lower()}"
|
||||
|
||||
|
||||
def _limits() -> tuple[int, int, int]:
|
||||
max_fail = max(3, int(getattr(settings, "auth_login_max_failures", 10) or 10))
|
||||
window = max(60, int(getattr(settings, "auth_login_window_sec", 300) or 300))
|
||||
lockout = max(60, int(getattr(settings, "auth_login_lockout_sec", 900) or 900))
|
||||
return max_fail, window, lockout
|
||||
|
||||
|
||||
def _get_redis() -> Any | None:
|
||||
global _redis_client, _redis_failed
|
||||
url = str(getattr(settings, "auth_redis_url", "") or "").strip()
|
||||
if not url:
|
||||
return None
|
||||
if _redis_failed:
|
||||
return None
|
||||
if _redis_client is not None:
|
||||
return _redis_client
|
||||
try:
|
||||
import redis # type: ignore
|
||||
|
||||
client = redis.Redis.from_url(url, decode_responses=True, socket_connect_timeout=0.5)
|
||||
client.ping()
|
||||
_redis_client = client
|
||||
_log.info("auth login rate-limit using Redis")
|
||||
return _redis_client
|
||||
except Exception:
|
||||
_redis_failed = True
|
||||
_log.warning("auth Redis unavailable; falling back to in-process rate-limit", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def login_lock_remaining(username: str, client_ip: str) -> float:
|
||||
"""Seconds remaining on lockout, or 0 if not locked."""
|
||||
k = _key(username, client_ip)
|
||||
r = _get_redis()
|
||||
if r is not None:
|
||||
try:
|
||||
ttl = r.ttl(f"netx:auth:lock:{k}")
|
||||
if isinstance(ttl, int) and ttl > 0:
|
||||
return float(ttl)
|
||||
return 0.0
|
||||
except Exception:
|
||||
_log.debug("redis lock_remaining failed", exc_info=True)
|
||||
now = time.time()
|
||||
with _lock:
|
||||
row = _buckets.get(k)
|
||||
if row is None:
|
||||
return 0.0
|
||||
return max(0.0, float(row.locked_until) - now)
|
||||
|
||||
|
||||
def register_login_failure(username: str, client_ip: str) -> float:
|
||||
"""Record a failed login. Returns lock remaining seconds (0 if not yet locked)."""
|
||||
max_fail, window, lockout = _limits()
|
||||
k = _key(username, client_ip)
|
||||
r = _get_redis()
|
||||
if r is not None:
|
||||
try:
|
||||
lock_key = f"netx:auth:lock:{k}"
|
||||
fail_key = f"netx:auth:fail:{k}"
|
||||
existing = r.ttl(lock_key)
|
||||
if isinstance(existing, int) and existing > 0:
|
||||
return float(existing)
|
||||
count = int(r.incr(fail_key))
|
||||
if count == 1:
|
||||
r.expire(fail_key, window)
|
||||
if count >= max_fail:
|
||||
r.setex(lock_key, lockout, "1")
|
||||
r.delete(fail_key)
|
||||
return float(lockout)
|
||||
return 0.0
|
||||
except Exception:
|
||||
_log.debug("redis register_failure failed; using memory", exc_info=True)
|
||||
|
||||
now = time.time()
|
||||
with _lock:
|
||||
row = _buckets.get(k)
|
||||
if row is None or (now - row.window_start) > window:
|
||||
row = _Bucket(window_start=now, failures=0)
|
||||
_buckets[k] = row
|
||||
if row.locked_until > now:
|
||||
return float(row.locked_until - now)
|
||||
row.failures += 1
|
||||
if row.failures >= max_fail:
|
||||
row.locked_until = now + lockout
|
||||
return float(lockout)
|
||||
return 0.0
|
||||
|
||||
|
||||
def clear_login_failures(username: str, client_ip: str) -> None:
|
||||
k = _key(username, client_ip)
|
||||
r = _get_redis()
|
||||
if r is not None:
|
||||
try:
|
||||
r.delete(f"netx:auth:fail:{k}", f"netx:auth:lock:{k}")
|
||||
except Exception:
|
||||
_log.debug("redis clear_failures failed", exc_info=True)
|
||||
with _lock:
|
||||
_buckets.pop(k, None)
|
||||
|
||||
|
||||
def reset_login_rate_limit_for_tests() -> None:
|
||||
global _redis_client, _redis_failed
|
||||
with _lock:
|
||||
_buckets.clear()
|
||||
_redis_client = None
|
||||
_redis_failed = False
|
||||
|
|
@ -4,15 +4,17 @@ from __future__ import annotations
|
|||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .auth_cookies import clear_auth_cookies, read_refresh_cookie, set_auth_cookies
|
||||
from .auth_deps import AuthContext, require_admin, require_user
|
||||
from .auth_schemas import (
|
||||
ApiTokenCreateRequest,
|
||||
ApiTokenUpdateRequest,
|
||||
ChangePasswordRequest,
|
||||
LoginRequest,
|
||||
RefreshRequest,
|
||||
UserCreateRequest,
|
||||
UserUpdateRequest,
|
||||
)
|
||||
|
|
@ -23,14 +25,23 @@ from .auth_service import (
|
|||
create_user,
|
||||
list_api_tokens,
|
||||
list_audit_logs,
|
||||
list_auth_sessions,
|
||||
list_users,
|
||||
login_issue_token,
|
||||
refresh_login_tokens,
|
||||
revoke_api_token,
|
||||
revoke_auth_session_for_user,
|
||||
revoke_auth_sessions,
|
||||
update_api_token,
|
||||
update_user,
|
||||
user_public,
|
||||
write_audit,
|
||||
)
|
||||
from .auth_rate_limit import (
|
||||
clear_login_failures,
|
||||
login_lock_remaining,
|
||||
register_login_failure,
|
||||
)
|
||||
from .db import get_db
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
|
|
@ -42,11 +53,45 @@ def _client_meta(request: Request) -> tuple[str, str]:
|
|||
return ip, ua
|
||||
|
||||
|
||||
def _token_response(response: Response, request: Request, out: dict[str, Any]) -> dict[str, Any]:
|
||||
access = str(out.get("access_token") or "")
|
||||
refresh = str(out.get("refresh_token") or "")
|
||||
if access and refresh:
|
||||
set_auth_cookies(response, access_token=access, refresh_token=refresh, request=request)
|
||||
# Keep tokens in JSON for API clients / scripts; browsers rely on HttpOnly cookies.
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/v1/auth/login")
|
||||
def api_login(body: LoginRequest, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
def api_login(
|
||||
body: LoginRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
from fastapi import HTTPException
|
||||
|
||||
ip, ua = _client_meta(request)
|
||||
locked = login_lock_remaining(body.username, ip)
|
||||
if locked > 0:
|
||||
write_audit(
|
||||
db,
|
||||
action="auth.login_locked",
|
||||
actor_username=str(body.username or "").strip(),
|
||||
method="POST",
|
||||
path="/v1/auth/login",
|
||||
status_code=429,
|
||||
client_ip=ip,
|
||||
user_agent=ua,
|
||||
detail={"retry_after_sec": int(locked)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail={"error": "login_locked", "retry_after_sec": int(locked)},
|
||||
)
|
||||
user = authenticate_user(db, body.username, body.password)
|
||||
if user is None:
|
||||
remaining = register_login_failure(body.username, ip)
|
||||
write_audit(
|
||||
db,
|
||||
action="auth.login_failed",
|
||||
|
|
@ -56,12 +101,16 @@ def api_login(body: LoginRequest, request: Request, db: Session = Depends(get_db
|
|||
status_code=401,
|
||||
client_ip=ip,
|
||||
user_agent=ua,
|
||||
detail={},
|
||||
detail={"locked": remaining > 0, "retry_after_sec": int(remaining)},
|
||||
)
|
||||
from fastapi import HTTPException
|
||||
|
||||
if remaining > 0:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail={"error": "login_locked", "retry_after_sec": int(remaining)},
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="invalid_credentials")
|
||||
out = login_issue_token(user)
|
||||
clear_login_failures(body.username, ip)
|
||||
out = login_issue_token(db, user, client_ip=ip, user_agent=ua)
|
||||
write_audit(
|
||||
db,
|
||||
action="auth.login",
|
||||
|
|
@ -74,16 +123,71 @@ def api_login(body: LoginRequest, request: Request, db: Session = Depends(get_db
|
|||
user_agent=ua,
|
||||
detail={"role": user.role},
|
||||
)
|
||||
return out
|
||||
return _token_response(response, request, out)
|
||||
|
||||
|
||||
@router.post("/v1/auth/refresh")
|
||||
def api_refresh(
|
||||
body: RefreshRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
from fastapi import HTTPException
|
||||
|
||||
ip, ua = _client_meta(request)
|
||||
refresh = str(body.refresh_token or "").strip() or read_refresh_cookie(request)
|
||||
if not refresh:
|
||||
raise HTTPException(status_code=401, detail="invalid_refresh_token")
|
||||
try:
|
||||
out = refresh_login_tokens(db, refresh_token=refresh, client_ip=ip, user_agent=ua)
|
||||
except Exception:
|
||||
write_audit(
|
||||
db,
|
||||
action="auth.refresh_failed",
|
||||
method="POST",
|
||||
path="/v1/auth/refresh",
|
||||
status_code=401,
|
||||
client_ip=ip,
|
||||
user_agent=ua,
|
||||
detail={},
|
||||
)
|
||||
raise
|
||||
user = out.get("user") or {}
|
||||
write_audit(
|
||||
db,
|
||||
action="auth.refresh",
|
||||
actor_user_id=str(user.get("id") or ""),
|
||||
actor_username=str(user.get("username") or ""),
|
||||
method="POST",
|
||||
path="/v1/auth/refresh",
|
||||
status_code=200,
|
||||
client_ip=ip,
|
||||
user_agent=ua,
|
||||
detail={},
|
||||
)
|
||||
return _token_response(response, request, out)
|
||||
|
||||
|
||||
@router.post("/v1/auth/logout")
|
||||
def api_logout(
|
||||
request: Request,
|
||||
response: Response,
|
||||
ctx: Annotated[AuthContext, Depends(require_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
ip, ua = _client_meta(request)
|
||||
revoked = 0
|
||||
if ctx.auth_via == "jwt" and ctx.session_jti:
|
||||
revoked = revoke_auth_sessions(db, user_id=str(ctx.user.id), only_jti=ctx.session_jti)
|
||||
closed_webcrt = 0
|
||||
try:
|
||||
from .webcrt_session_registry import close_sessions_for_user
|
||||
|
||||
closed_webcrt = close_sessions_for_user(str(ctx.user.id), reason="auth_logout")
|
||||
except Exception:
|
||||
pass
|
||||
clear_auth_cookies(response, request=request)
|
||||
write_audit(
|
||||
db,
|
||||
action="auth.logout",
|
||||
|
|
@ -94,9 +198,75 @@ def api_logout(
|
|||
status_code=200,
|
||||
client_ip=ip,
|
||||
user_agent=ua,
|
||||
detail={"auth_via": ctx.auth_via},
|
||||
detail={"auth_via": ctx.auth_via, "revoked": revoked, "webcrt_closed": closed_webcrt},
|
||||
)
|
||||
return {"ok": True}
|
||||
return {"ok": True, "revoked": revoked, "webcrt_closed": closed_webcrt}
|
||||
|
||||
|
||||
@router.get("/v1/auth/sessions")
|
||||
def api_list_sessions(ctx: Annotated[AuthContext, Depends(require_user)], db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
items = list_auth_sessions(db, user_id=str(ctx.user.id), current_jti=ctx.session_jti)
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
||||
@router.delete("/v1/auth/sessions/{session_id}")
|
||||
def api_revoke_session(
|
||||
session_id: str,
|
||||
request: Request,
|
||||
ctx: Annotated[AuthContext, Depends(require_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
from fastapi import HTTPException
|
||||
|
||||
ok = revoke_auth_session_for_user(
|
||||
db,
|
||||
user_id=str(ctx.user.id),
|
||||
session_id=session_id,
|
||||
current_jti=ctx.session_jti,
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
ip, ua = _client_meta(request)
|
||||
write_audit(
|
||||
db,
|
||||
action="auth.session_revoke",
|
||||
actor_user_id=ctx.user.id,
|
||||
actor_username=ctx.user.username,
|
||||
method="DELETE",
|
||||
path=f"/v1/auth/sessions/{session_id}",
|
||||
status_code=200,
|
||||
client_ip=ip,
|
||||
user_agent=ua,
|
||||
detail={"session_id": session_id, "current": session_id == ctx.session_jti},
|
||||
)
|
||||
return {"ok": True, "revoked": True, "session_id": session_id}
|
||||
|
||||
|
||||
@router.post("/v1/auth/sessions/revoke-others")
|
||||
def api_revoke_other_sessions(
|
||||
request: Request,
|
||||
ctx: Annotated[AuthContext, Depends(require_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
if not ctx.session_jti:
|
||||
# API-token auth has no JWT session to keep.
|
||||
n = revoke_auth_sessions(db, user_id=str(ctx.user.id))
|
||||
else:
|
||||
n = revoke_auth_sessions(db, user_id=str(ctx.user.id), except_jti=ctx.session_jti)
|
||||
ip, ua = _client_meta(request)
|
||||
write_audit(
|
||||
db,
|
||||
action="auth.session_revoke_others",
|
||||
actor_user_id=ctx.user.id,
|
||||
actor_username=ctx.user.username,
|
||||
method="POST",
|
||||
path="/v1/auth/sessions/revoke-others",
|
||||
status_code=200,
|
||||
client_ip=ip,
|
||||
user_agent=ua,
|
||||
detail={"revoked": n},
|
||||
)
|
||||
return {"ok": True, "revoked": n}
|
||||
|
||||
|
||||
@router.get("/v1/auth/me")
|
||||
|
|
@ -118,7 +288,13 @@ def api_change_password(
|
|||
ctx: Annotated[AuthContext, Depends(require_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
change_password(db, user=ctx.user, old_password=body.old_password, new_password=body.new_password)
|
||||
change_password(
|
||||
db,
|
||||
user=ctx.user,
|
||||
old_password=body.old_password,
|
||||
new_password=body.new_password,
|
||||
keep_jti=ctx.session_jti or None,
|
||||
)
|
||||
ip, ua = _client_meta(request)
|
||||
write_audit(
|
||||
db,
|
||||
|
|
|
|||
|
|
@ -12,12 +12,17 @@ class LoginRequest(BaseModel):
|
|||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
old_password: str = Field(min_length=1, max_length=256)
|
||||
new_password: str = Field(min_length=6, max_length=256)
|
||||
new_password: str = Field(min_length=8, max_length=256)
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
# Optional when refresh token is sent via HttpOnly cookie.
|
||||
refresh_token: str = Field(default="", max_length=512)
|
||||
|
||||
|
||||
class UserCreateRequest(BaseModel):
|
||||
username: str = Field(min_length=2, max_length=64)
|
||||
password: str = Field(min_length=6, max_length=256)
|
||||
password: str = Field(min_length=8, max_length=256)
|
||||
role: str = Field(default="user")
|
||||
scopes: list[str] | None = None
|
||||
|
||||
|
|
@ -25,7 +30,7 @@ class UserCreateRequest(BaseModel):
|
|||
class UserUpdateRequest(BaseModel):
|
||||
is_active: bool | None = None
|
||||
role: str | None = None
|
||||
password: str | None = Field(default=None, min_length=6, max_length=256)
|
||||
password: str | None = Field(default=None, min_length=8, max_length=256)
|
||||
scopes: list[str] | None = None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -18,9 +18,14 @@ from .auth_scopes import (
|
|||
effective_user_scopes,
|
||||
normalize_scopes,
|
||||
)
|
||||
from .auth_tokens import hash_api_token, issue_access_token, new_api_token_plaintext
|
||||
from .auth_tokens import (
|
||||
hash_api_token,
|
||||
issue_access_token,
|
||||
new_api_token_plaintext,
|
||||
new_refresh_token_plaintext,
|
||||
)
|
||||
from .config import settings
|
||||
from .models import ApiToken, AppUser, AuditLog
|
||||
from .models import ApiToken, AppUser, AuditLog, AuthSession
|
||||
from .timeutil import utcnow_naive
|
||||
|
||||
_log = logging.getLogger("netx.auth")
|
||||
|
|
@ -33,6 +38,7 @@ _SECRET_KEYS = frozenset(
|
|||
"hop_password",
|
||||
"enable_secret",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"token",
|
||||
"authorization",
|
||||
"secret",
|
||||
|
|
@ -41,6 +47,17 @@ _SECRET_KEYS = frozenset(
|
|||
)
|
||||
|
||||
|
||||
def _password_min_len() -> int:
|
||||
return max(8, int(getattr(settings, "auth_password_min_len", 8) or 8))
|
||||
|
||||
|
||||
def _require_password_strength(pwd: str) -> str:
|
||||
raw = str(pwd or "")
|
||||
if len(raw) < _password_min_len():
|
||||
raise HTTPException(status_code=400, detail="password_too_short")
|
||||
return raw
|
||||
|
||||
|
||||
def user_public(user: AppUser) -> dict[str, Any]:
|
||||
scopes = sorted(
|
||||
effective_user_scopes(role=str(user.role or "user"), override=getattr(user, "scopes", None) or [])
|
||||
|
|
@ -256,15 +273,201 @@ def authenticate_user(db: Session, username: str, password: str) -> AppUser | No
|
|||
return user
|
||||
|
||||
|
||||
def login_issue_token(user: AppUser) -> dict[str, Any]:
|
||||
token = issue_access_token(user_id=user.id, username=user.username, role=user.role)
|
||||
def revoke_auth_sessions(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: str,
|
||||
except_jti: str | None = None,
|
||||
only_jti: str | None = None,
|
||||
) -> int:
|
||||
"""Revoke JWT sessions. Returns count newly revoked."""
|
||||
now = utcnow_naive()
|
||||
q = db.query(AuthSession).filter(
|
||||
AuthSession.user_id == str(user_id),
|
||||
AuthSession.revoked_at.is_(None),
|
||||
)
|
||||
if only_jti:
|
||||
q = q.filter(AuthSession.id == str(only_jti))
|
||||
if except_jti:
|
||||
q = q.filter(AuthSession.id != str(except_jti))
|
||||
rows = q.all()
|
||||
for row in rows:
|
||||
row.revoked_at = now
|
||||
if rows:
|
||||
db.commit()
|
||||
return len(rows)
|
||||
|
||||
|
||||
def get_auth_session(db: Session, jti: str) -> AuthSession | None:
|
||||
sid = str(jti or "").strip()
|
||||
if not sid:
|
||||
return None
|
||||
row = db.query(AuthSession).filter(AuthSession.id == sid).first()
|
||||
if row is None:
|
||||
return None
|
||||
if row.revoked_at is not None:
|
||||
return None
|
||||
now = utcnow_naive()
|
||||
exp = row.expires_at
|
||||
if exp is not None and exp < now:
|
||||
return None
|
||||
idle = max(0, int(getattr(settings, "auth_idle_timeout_sec", 7200) or 0))
|
||||
if idle > 0:
|
||||
seen = row.last_seen_at or row.created_at
|
||||
if seen is not None and (now - seen).total_seconds() > idle:
|
||||
row.revoked_at = now
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
return None
|
||||
return row
|
||||
|
||||
|
||||
def touch_auth_session(db: Session, jti: str) -> None:
|
||||
row = db.query(AuthSession).filter(AuthSession.id == str(jti)).first()
|
||||
if row is None or row.revoked_at is not None:
|
||||
return
|
||||
row.last_seen_at = utcnow_naive()
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
|
||||
def list_auth_sessions(db: Session, *, user_id: str, current_jti: str = "") -> list[dict[str, Any]]:
|
||||
now = utcnow_naive()
|
||||
rows = (
|
||||
db.query(AuthSession)
|
||||
.filter(AuthSession.user_id == str(user_id), AuthSession.revoked_at.is_(None))
|
||||
.order_by(AuthSession.created_at.desc())
|
||||
.limit(100)
|
||||
.all()
|
||||
)
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
if row.expires_at is not None and row.expires_at < now:
|
||||
continue
|
||||
refresh_alive = bool(row.refresh_expires_at and row.refresh_expires_at >= now)
|
||||
access_alive = bool(row.expires_at and row.expires_at >= now)
|
||||
if not access_alive and not refresh_alive:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"id": row.id,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"expires_at": row.expires_at.isoformat() if row.expires_at else None,
|
||||
"refresh_expires_at": row.refresh_expires_at.isoformat() if row.refresh_expires_at else None,
|
||||
"last_seen_at": row.last_seen_at.isoformat() if row.last_seen_at else None,
|
||||
"client_ip": row.client_ip or "",
|
||||
"user_agent": (row.user_agent or "")[:200],
|
||||
"current": bool(current_jti) and row.id == str(current_jti),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def revoke_auth_session_for_user(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
current_jti: str = "",
|
||||
) -> bool:
|
||||
"""Revoke one session owned by user. Returns True if newly revoked."""
|
||||
sid = str(session_id or "").strip()
|
||||
if not sid:
|
||||
return False
|
||||
row = (
|
||||
db.query(AuthSession)
|
||||
.filter(
|
||||
AuthSession.id == sid,
|
||||
AuthSession.user_id == str(user_id),
|
||||
AuthSession.revoked_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if row is None:
|
||||
return False
|
||||
row.revoked_at = utcnow_naive()
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def login_issue_token(
|
||||
db: Session,
|
||||
user: AppUser,
|
||||
*,
|
||||
client_ip: str = "",
|
||||
user_agent: str = "",
|
||||
) -> dict[str, Any]:
|
||||
token, jti, ttl = issue_access_token(
|
||||
user_id=user.id, username=user.username, role=user.role
|
||||
)
|
||||
if bool(getattr(settings, "auth_single_session", False)):
|
||||
revoke_auth_sessions(db, user_id=str(user.id), except_jti=jti)
|
||||
refresh_ttl = max(3600, int(getattr(settings, "auth_refresh_ttl_sec", 604800) or 604800))
|
||||
refresh_plain = new_refresh_token_plaintext()
|
||||
now = utcnow_naive()
|
||||
db.add(
|
||||
AuthSession(
|
||||
id=jti,
|
||||
user_id=str(user.id),
|
||||
created_at=now,
|
||||
expires_at=now + timedelta(seconds=ttl),
|
||||
client_ip=str(client_ip or "")[:128],
|
||||
user_agent=str(user_agent or "")[:512],
|
||||
last_seen_at=now,
|
||||
refresh_token_hash=hash_api_token(refresh_plain),
|
||||
refresh_expires_at=now + timedelta(seconds=refresh_ttl),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return {
|
||||
"access_token": token,
|
||||
"refresh_token": refresh_plain,
|
||||
"token_type": "bearer",
|
||||
"expires_in": ttl,
|
||||
"refresh_expires_in": refresh_ttl,
|
||||
"user": user_public(user),
|
||||
}
|
||||
|
||||
|
||||
def refresh_login_tokens(
|
||||
db: Session,
|
||||
*,
|
||||
refresh_token: str,
|
||||
client_ip: str = "",
|
||||
user_agent: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Rotate refresh token and mint a new access JWT (old session revoked)."""
|
||||
raw = str(refresh_token or "").strip()
|
||||
if not raw.startswith("nxr_"):
|
||||
raise HTTPException(status_code=401, detail="invalid_refresh_token")
|
||||
th = hash_api_token(raw)
|
||||
now = utcnow_naive()
|
||||
row = (
|
||||
db.query(AuthSession)
|
||||
.filter(AuthSession.refresh_token_hash == th, AuthSession.revoked_at.is_(None))
|
||||
.first()
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=401, detail="invalid_refresh_token")
|
||||
refresh_exp = row.refresh_expires_at
|
||||
if refresh_exp is None or refresh_exp < now:
|
||||
row.revoked_at = now
|
||||
db.commit()
|
||||
raise HTTPException(status_code=401, detail="refresh_token_expired")
|
||||
user = get_user_by_id(db, str(row.user_id))
|
||||
if user is None or not user.is_active:
|
||||
row.revoked_at = now
|
||||
db.commit()
|
||||
raise HTTPException(status_code=401, detail="invalid_refresh_token")
|
||||
row.revoked_at = now
|
||||
db.commit()
|
||||
return login_issue_token(db, user, client_ip=client_ip, user_agent=user_agent)
|
||||
|
||||
|
||||
def list_users(db: Session) -> list[dict[str, Any]]:
|
||||
rows = db.query(AppUser).order_by(AppUser.created_at.asc()).all()
|
||||
return [user_public(u) for u in rows]
|
||||
|
|
@ -282,9 +485,7 @@ def create_user(
|
|||
name = str(username or "").strip()
|
||||
if not _USERNAME_RE.match(name):
|
||||
raise HTTPException(status_code=400, detail="invalid_username")
|
||||
pwd = str(password or "")
|
||||
if len(pwd) < 6:
|
||||
raise HTTPException(status_code=400, detail="password_too_short")
|
||||
pwd = _require_password_strength(password)
|
||||
role_n = str(role or "user").strip().lower()
|
||||
if role_n not in ("admin", "user"):
|
||||
raise HTTPException(status_code=400, detail="invalid_role")
|
||||
|
|
@ -327,29 +528,38 @@ def update_user(
|
|||
if user.id == actor.id and role_n != "admin":
|
||||
raise HTTPException(status_code=400, detail="cannot_demote_self")
|
||||
user.role = role_n
|
||||
revoke_all = False
|
||||
if is_active is not None:
|
||||
user.is_active = bool(is_active)
|
||||
if not user.is_active:
|
||||
revoke_all = True
|
||||
if password is not None:
|
||||
pwd = str(password)
|
||||
if len(pwd) < 6:
|
||||
raise HTTPException(status_code=400, detail="password_too_short")
|
||||
pwd = _require_password_strength(password)
|
||||
user.password_hash = hash_password(pwd)
|
||||
user.must_change_password = True
|
||||
revoke_all = True
|
||||
if scopes is not None:
|
||||
user.scopes = normalize_scopes(scopes)
|
||||
user.updated_at = utcnow_naive()
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
if revoke_all:
|
||||
revoke_auth_sessions(db, user_id=str(user.id))
|
||||
return user
|
||||
|
||||
|
||||
def change_password(db: Session, *, user: AppUser, old_password: str, new_password: str) -> None:
|
||||
def change_password(
|
||||
db: Session,
|
||||
*,
|
||||
user: AppUser,
|
||||
old_password: str,
|
||||
new_password: str,
|
||||
keep_jti: str | None = None,
|
||||
) -> None:
|
||||
row = get_user_by_id(db, str(user.id)) or user
|
||||
if not verify_password(old_password, row.password_hash):
|
||||
raise HTTPException(status_code=400, detail="old_password_incorrect")
|
||||
pwd = str(new_password or "")
|
||||
if len(pwd) < 6:
|
||||
raise HTTPException(status_code=400, detail="password_too_short")
|
||||
pwd = _require_password_strength(new_password)
|
||||
default_pwd = str(settings.bootstrap_admin_password or "admin123").strip() or "admin123"
|
||||
if pwd == default_pwd or pwd == old_password:
|
||||
raise HTTPException(status_code=400, detail="password_must_differ_from_default")
|
||||
|
|
@ -357,6 +567,8 @@ def change_password(db: Session, *, user: AppUser, old_password: str, new_passwo
|
|||
row.must_change_password = False
|
||||
row.updated_at = utcnow_naive()
|
||||
db.commit()
|
||||
# Drop other browser sessions; keep current jti so force-change flow can continue.
|
||||
revoke_auth_sessions(db, user_id=str(row.id), except_jti=keep_jti)
|
||||
|
||||
|
||||
def create_api_token(
|
||||
|
|
|
|||
|
|
@ -81,18 +81,29 @@ def auth_secret() -> str:
|
|||
return ensure_auth_secret()
|
||||
|
||||
|
||||
def issue_access_token(*, user_id: str, username: str, role: str) -> str:
|
||||
def issue_access_token(
|
||||
*,
|
||||
user_id: str,
|
||||
username: str,
|
||||
role: str,
|
||||
jti: str | None = None,
|
||||
) -> tuple[str, str, int]:
|
||||
"""Return (token, jti, ttl_sec). jti is required for server-side revocation."""
|
||||
ttl = max(300, int(settings.auth_token_ttl_sec or 86400))
|
||||
now = datetime.now(timezone.utc)
|
||||
sid = str(jti or secrets.token_urlsafe(24)).strip()
|
||||
if not sid:
|
||||
sid = secrets.token_urlsafe(24)
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"username": str(username),
|
||||
"role": str(role),
|
||||
"typ": "access",
|
||||
"jti": sid,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + timedelta(seconds=ttl)).timestamp()),
|
||||
}
|
||||
return jwt.encode(payload, auth_secret(), algorithm="HS256")
|
||||
return jwt.encode(payload, auth_secret(), algorithm="HS256"), sid, ttl
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict[str, Any]:
|
||||
|
|
@ -100,7 +111,7 @@ def decode_access_token(token: str) -> dict[str, Any]:
|
|||
str(token or ""),
|
||||
auth_secret(),
|
||||
algorithms=["HS256"],
|
||||
options={"require": ["exp", "sub"]},
|
||||
options={"require": ["exp", "sub", "jti"]},
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -109,5 +120,10 @@ def new_api_token_plaintext() -> str:
|
|||
return "nxt_" + secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def new_refresh_token_plaintext() -> str:
|
||||
"""Opaque refresh token (shown once). Prefix distinguishes from API tokens."""
|
||||
return "nxr_" + secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def hash_api_token(plaintext: str) -> str:
|
||||
return hashlib.sha256(str(plaintext or "").encode("utf-8")).hexdigest()
|
||||
|
|
|
|||
|
|
@ -97,6 +97,8 @@ class Settings(BaseSettings):
|
|||
ne_exec_max_commands: int = 5
|
||||
# WebCRT interactive terminal sessions (multi-operator concurrent terminals).
|
||||
webcrt_max_sessions: int = 40
|
||||
# Per-user cap (0 = unlimited beyond global max).
|
||||
webcrt_max_sessions_per_user: int = 5
|
||||
webcrt_idle_timeout_sec: int = 1800
|
||||
webcrt_connect_timeout_sec: int = 90
|
||||
webcrt_attach_timeout_sec: int = 60
|
||||
|
|
@ -126,7 +128,25 @@ class Settings(BaseSettings):
|
|||
# Set explicitly only when you want a shared/ops-managed secret.
|
||||
auth_secret: str = ""
|
||||
auth_secret_file: str = "data/auth/jwt_secret"
|
||||
auth_token_ttl_sec: int = 86400
|
||||
auth_token_ttl_sec: int = 3600
|
||||
# Refresh token lifetime (default 7 days). Used with POST /v1/auth/refresh.
|
||||
auth_refresh_ttl_sec: int = 604800
|
||||
# When true, each login revokes other JWT sessions for that user (single active browser login).
|
||||
auth_single_session: bool = True
|
||||
# Login brute-force protection (in-process; resets on restart).
|
||||
auth_login_max_failures: int = 10
|
||||
auth_login_window_sec: int = 300
|
||||
auth_login_lockout_sec: int = 900
|
||||
auth_password_min_len: int = 8
|
||||
# Idle revoke: if last_seen_at older than this, JWT session is revoked (0 = off).
|
||||
auth_idle_timeout_sec: int = 7200
|
||||
# Browser session cookies (HttpOnly). Bearer header still works for API tokens / scripts.
|
||||
auth_cookie_enabled: bool = True
|
||||
# None = auto (HTTPS / X-Forwarded-Proto); True/False force.
|
||||
auth_cookie_secure: bool | None = None
|
||||
auth_cookie_samesite: str = "lax"
|
||||
# Optional Redis URL for shared login rate-limit (empty = in-process only).
|
||||
auth_redis_url: str = ""
|
||||
bootstrap_admin_username: str = "admin"
|
||||
bootstrap_admin_password: str = "admin123"
|
||||
# Written on first boot for MCP; path relative to cwd / absolute
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from .alarms import (
|
|||
ImportErrorRow,
|
||||
ImportJob,
|
||||
)
|
||||
from .auth import ApiToken, AppUser, AuditLog
|
||||
from .auth import ApiToken, AppUser, AuditLog, AuthSession
|
||||
from .config_sync import (
|
||||
ConfigSyncCycle,
|
||||
ConfigSyncPolicy,
|
||||
|
|
@ -93,6 +93,7 @@ __all__ = [
|
|||
"AppUser",
|
||||
"AuditLog",
|
||||
"ApiToken",
|
||||
"AuthSession",
|
||||
"ConfigSyncPolicy",
|
||||
"ConfigSyncCycle",
|
||||
"ConfigSyncTask",
|
||||
|
|
|
|||
|
|
@ -61,3 +61,21 @@ class ApiToken(Base):
|
|||
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class AuthSession(Base):
|
||||
"""Server-side JWT session (jti). Logout / password change can revoke without waiting for exp."""
|
||||
|
||||
__tablename__ = "auth_session"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True) # JWT jti
|
||||
user_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime, index=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||
client_ip: Mapped[str] = mapped_column(String(128), default="")
|
||||
user_agent: Mapped[str] = mapped_column(String(512), default="")
|
||||
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
# Opaque refresh token (hashed); longer-lived than access JWT.
|
||||
refresh_token_hash: Mapped[str] = mapped_column(String(128), default="", index=True)
|
||||
refresh_expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||
|
|
|
|||
|
|
@ -91,6 +91,36 @@ def apply_auth_schema_patches(conn: Connection) -> None:
|
|||
_run_sql(conn, "ALTER TABLE app_user ADD COLUMN IF NOT EXISTS scopes JSON DEFAULT '[]'")
|
||||
_run_sql(conn, "ALTER TABLE api_token ADD COLUMN IF NOT EXISTS expires_at TIMESTAMP")
|
||||
_run_sql(conn, "ALTER TABLE api_token ADD COLUMN IF NOT EXISTS scopes JSON DEFAULT '[]'")
|
||||
_run_sql(
|
||||
conn,
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS auth_session (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMP,
|
||||
expires_at TIMESTAMP,
|
||||
revoked_at TIMESTAMP,
|
||||
client_ip VARCHAR(128) DEFAULT '',
|
||||
user_agent VARCHAR(512) DEFAULT '',
|
||||
last_seen_at TIMESTAMP
|
||||
)
|
||||
""",
|
||||
)
|
||||
_run_sql(conn, "CREATE INDEX IF NOT EXISTS ix_auth_session_user_id ON auth_session (user_id)")
|
||||
_run_sql(conn, "CREATE INDEX IF NOT EXISTS ix_auth_session_expires_at ON auth_session (expires_at)")
|
||||
_run_sql(conn, "CREATE INDEX IF NOT EXISTS ix_auth_session_revoked_at ON auth_session (revoked_at)")
|
||||
_run_sql(
|
||||
conn,
|
||||
"ALTER TABLE auth_session ADD COLUMN IF NOT EXISTS refresh_token_hash VARCHAR(128) DEFAULT ''",
|
||||
)
|
||||
_run_sql(
|
||||
conn,
|
||||
"ALTER TABLE auth_session ADD COLUMN IF NOT EXISTS refresh_expires_at TIMESTAMP",
|
||||
)
|
||||
_run_sql(
|
||||
conn,
|
||||
"CREATE INDEX IF NOT EXISTS ix_auth_session_refresh_token_hash ON auth_session (refresh_token_hash)",
|
||||
)
|
||||
|
||||
|
||||
def apply_key_alert_schema_patches(
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from .webcrt_service import (
|
|||
list_sessions,
|
||||
mark_attached,
|
||||
read_session_log_tail,
|
||||
session_access_allowed,
|
||||
wait_session_ready,
|
||||
_decode_bytes,
|
||||
_encode_text,
|
||||
|
|
@ -134,8 +135,9 @@ def _client_label(request: Request | None = None, websocket: WebSocket | None =
|
|||
|
||||
|
||||
@router.get("/sessions")
|
||||
def api_list_sessions() -> dict[str, Any]:
|
||||
return list_sessions()
|
||||
def api_list_sessions(ctx: AuthContext = Depends(require_user)) -> dict[str, Any]:
|
||||
is_admin = str(ctx.user.role or "") == "admin"
|
||||
return list_sessions(for_user_id=str(ctx.user.id), admin=is_admin)
|
||||
|
||||
|
||||
@router.get("/meta/device-types")
|
||||
|
|
@ -150,6 +152,7 @@ def api_create_session(
|
|||
body: WebcrtSessionCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
ctx: AuthContext = Depends(require_user),
|
||||
) -> dict[str, Any]:
|
||||
mid = str(body.ne_id or "").strip()
|
||||
uid = str(body.ume_ne_id or "").strip()
|
||||
|
|
@ -168,6 +171,8 @@ def api_create_session(
|
|||
async_connect=bool(body.async_connect),
|
||||
username_override=body.username,
|
||||
password_override=body.password,
|
||||
owner_user_id=str(ctx.user.id),
|
||||
owner_username=str(ctx.user.username),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -176,6 +181,7 @@ def api_quick_connect(
|
|||
body: WebcrtQuickConnectBody,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
ctx: AuthContext = Depends(require_user),
|
||||
) -> dict[str, Any]:
|
||||
from .ne_service import upsert_webcrt_session_host
|
||||
|
||||
|
|
@ -217,6 +223,8 @@ def api_quick_connect(
|
|||
async_connect=async_connect,
|
||||
username_override=user_override,
|
||||
password_override=pwd_override,
|
||||
owner_user_id=str(ctx.user.id),
|
||||
owner_username=str(ctx.user.username),
|
||||
)
|
||||
except HTTPException as exc:
|
||||
# NE row already exists; return it so the UI retries in place (no duplicate hosts).
|
||||
|
|
@ -241,7 +249,16 @@ def api_quick_connect(
|
|||
|
||||
|
||||
@router.delete("/sessions/{session_id}")
|
||||
def api_close_session(session_id: str, request: Request) -> dict[str, Any]:
|
||||
def api_close_session(
|
||||
session_id: str,
|
||||
request: Request,
|
||||
ctx: AuthContext = Depends(require_user),
|
||||
) -> dict[str, Any]:
|
||||
sess = get_session(session_id)
|
||||
if sess is not None:
|
||||
is_admin = str(ctx.user.role or "") == "admin"
|
||||
if not session_access_allowed(sess, user_id=str(ctx.user.id), is_admin=is_admin):
|
||||
raise HTTPException(status_code=403, detail="webcrt_session_forbidden")
|
||||
return close_session(session_id, reason="client_delete", client=_client_label(request=request))
|
||||
|
||||
|
||||
|
|
@ -359,6 +376,8 @@ async def api_sftp_upload(
|
|||
|
||||
@router.websocket("/sessions/{session_id}/ws")
|
||||
async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||
actor_user_id = ""
|
||||
actor_is_admin = False
|
||||
if bool(settings.auth_enabled):
|
||||
# Prefer one-time ws_ticket (query). Bearer header also accepted (non-browser clients).
|
||||
# Long-lived access_token in query is rejected.
|
||||
|
|
@ -368,6 +387,8 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
|||
if info is None or not has_scope(info.scopes, SCOPE_WEBCRT):
|
||||
await websocket.close(code=4403 if info is not None else 4401)
|
||||
return
|
||||
actor_user_id = str(info.user_id)
|
||||
actor_is_admin = has_scope(info.scopes, "admin:users")
|
||||
else:
|
||||
if str(websocket.query_params.get("access_token") or "").strip():
|
||||
await websocket.close(code=4401)
|
||||
|
|
@ -384,10 +405,21 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
|||
if resolved is None:
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
_user, _via, scopes, _tid = resolved
|
||||
user, _via, scopes, _tid, _jti = resolved
|
||||
if not has_scope(scopes, SCOPE_WEBCRT):
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
actor_user_id = str(user.id)
|
||||
actor_is_admin = str(user.role or "") == "admin" or has_scope(scopes, "admin:users")
|
||||
|
||||
# Ownership check before accept when session already exists.
|
||||
existing = get_session(session_id)
|
||||
if existing is not None and bool(settings.auth_enabled):
|
||||
if not session_access_allowed(
|
||||
existing, user_id=actor_user_id, is_admin=actor_is_admin
|
||||
):
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
attach_gen = 0
|
||||
|
|
@ -398,6 +430,13 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
|||
await websocket.close(code=4404 if exc.status_code == 404 else 4409)
|
||||
return
|
||||
|
||||
if bool(settings.auth_enabled) and not session_access_allowed(
|
||||
sess, user_id=actor_user_id, is_admin=actor_is_admin
|
||||
):
|
||||
await websocket.send_json({"type": "status", "state": "error", "message": "forbidden"})
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "status",
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from .webcrt_session import (
|
|||
_webcrt_creds_ready,
|
||||
active_session_count,
|
||||
close_session,
|
||||
close_sessions_for_user,
|
||||
create_session,
|
||||
detach_session,
|
||||
find_ssh_session_for_ne,
|
||||
|
|
@ -41,6 +42,7 @@ from .webcrt_session_registry import (
|
|||
_reap_sessions,
|
||||
_sessions,
|
||||
_sessions_lock,
|
||||
session_access_allowed,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
|
|
@ -63,6 +65,7 @@ __all__ = [
|
|||
"active_session_count",
|
||||
"channel_return",
|
||||
"close_session",
|
||||
"close_sessions_for_user",
|
||||
"create_session",
|
||||
"detach_session",
|
||||
"find_ssh_session_for_ne",
|
||||
|
|
@ -76,6 +79,7 @@ __all__ = [
|
|||
"open_netmiko_connection",
|
||||
"prepare_bootstrap_output",
|
||||
"read_session_log_tail",
|
||||
"session_access_allowed",
|
||||
"settings",
|
||||
"uses_network_cli_keymap",
|
||||
"wait_session_ready",
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@ from .webcrt_session_registry import (
|
|||
_webcrt_creds_ready,
|
||||
active_session_count,
|
||||
close_session,
|
||||
close_sessions_for_user,
|
||||
create_session,
|
||||
detach_session,
|
||||
find_ssh_session_for_ne,
|
||||
get_session,
|
||||
list_sessions,
|
||||
mark_attached,
|
||||
session_access_allowed,
|
||||
wait_session_ready,
|
||||
)
|
||||
|
||||
|
|
@ -20,11 +22,13 @@ __all__ = [
|
|||
"_webcrt_creds_ready",
|
||||
"active_session_count",
|
||||
"close_session",
|
||||
"close_sessions_for_user",
|
||||
"create_session",
|
||||
"detach_session",
|
||||
"find_ssh_session_for_ne",
|
||||
"get_session",
|
||||
"list_sessions",
|
||||
"mark_attached",
|
||||
"session_access_allowed",
|
||||
"wait_session_ready",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ class WebcrtSession:
|
|||
cli_keymap: bool = True
|
||||
encoding: str = "utf-8"
|
||||
keepalive_sec: int = 0
|
||||
# Owning netx user; empty = legacy unbound (tests / auth_disabled).
|
||||
owner_user_id: str = ""
|
||||
owner_username: str = ""
|
||||
conn: ConnectHandler | None = None
|
||||
created_at: float = field(default_factory=time.time)
|
||||
last_activity: float = field(default_factory=time.time)
|
||||
|
|
|
|||
|
|
@ -125,6 +125,37 @@ def active_session_count() -> int:
|
|||
return sum(1 for s in _sessions.values() if not s.closed)
|
||||
|
||||
|
||||
def active_session_count_for_user(user_id: str) -> int:
|
||||
uid = str(user_id or "").strip()
|
||||
if not uid:
|
||||
return 0
|
||||
with _sessions_lock:
|
||||
return sum(
|
||||
1
|
||||
for s in _sessions.values()
|
||||
if (not s.closed) and str(s.owner_user_id or "").strip() == uid
|
||||
)
|
||||
|
||||
|
||||
def close_sessions_for_user(user_id: str, *, reason: str = "owner_logout") -> int:
|
||||
"""Close all WebCRT sessions owned by user_id. Returns count closed."""
|
||||
uid = str(user_id or "").strip()
|
||||
if not uid:
|
||||
return 0
|
||||
with _sessions_lock:
|
||||
ids = [
|
||||
sid
|
||||
for sid, s in _sessions.items()
|
||||
if (not s.closed) and str(s.owner_user_id or "").strip() == uid
|
||||
]
|
||||
closed = 0
|
||||
for sid in ids:
|
||||
out = close_session(sid, reason=reason, client="auth_logout")
|
||||
if out.get("closed"):
|
||||
closed += 1
|
||||
return closed
|
||||
|
||||
|
||||
def get_session(session_id: str) -> WebcrtSession | None:
|
||||
with _sessions_lock:
|
||||
sess = _sessions.get(session_id)
|
||||
|
|
@ -374,6 +405,8 @@ def create_session(
|
|||
async_connect: bool = True,
|
||||
username_override: str | None = None,
|
||||
password_override: str | None = None,
|
||||
owner_user_id: str = "",
|
||||
owner_username: str = "",
|
||||
) -> dict[str, Any]:
|
||||
from .cli_resolve import resolve_cli_target
|
||||
|
||||
|
|
@ -381,6 +414,10 @@ def create_session(
|
|||
max_sessions = max(1, int(settings.webcrt_max_sessions or 20))
|
||||
if active_session_count() >= max_sessions:
|
||||
raise HTTPException(status_code=429, detail="webcrt_session_limit")
|
||||
owner_id = str(owner_user_id or "").strip()
|
||||
per_user = int(getattr(settings, "webcrt_max_sessions_per_user", 5) or 0)
|
||||
if owner_id and per_user > 0 and active_session_count_for_user(owner_id) >= per_user:
|
||||
raise HTTPException(status_code=429, detail="webcrt_user_session_limit")
|
||||
|
||||
mid = str(ne_id or "").strip()
|
||||
uid = str(ume_ne_id or "").strip()
|
||||
|
|
@ -425,6 +462,7 @@ def create_session(
|
|||
else:
|
||||
ka = max(0, min(600, int(keepalive_sec)))
|
||||
|
||||
owner_name = str(owner_username or "").strip()
|
||||
sess = WebcrtSession(
|
||||
session_id=session_id,
|
||||
ne_id=target_id,
|
||||
|
|
@ -438,6 +476,8 @@ def create_session(
|
|||
cli_keymap=cli_keymap,
|
||||
encoding=enc,
|
||||
keepalive_sec=ka,
|
||||
owner_user_id=owner_id,
|
||||
owner_username=owner_name,
|
||||
state="connecting",
|
||||
post_login_commands=list(post_login_commands or [])[:20],
|
||||
)
|
||||
|
|
@ -497,9 +537,26 @@ def create_session(
|
|||
"ws_path": f"/v1/webcrt/sessions/{session_id}/ws",
|
||||
"cli_hop": bool(sess.cli_hop_guard),
|
||||
"sftp_ready": bool(sess.sftp_ready),
|
||||
"owner_user_id": sess.owner_user_id,
|
||||
"owner_username": sess.owner_username,
|
||||
}
|
||||
|
||||
|
||||
def session_access_allowed(
|
||||
sess: WebcrtSession,
|
||||
*,
|
||||
user_id: str,
|
||||
is_admin: bool = False,
|
||||
) -> bool:
|
||||
"""Owner or admin may attach/close. Unbound sessions (empty owner) stay open for lab/tests."""
|
||||
owner = str(sess.owner_user_id or "").strip()
|
||||
if not owner:
|
||||
return True
|
||||
if is_admin:
|
||||
return True
|
||||
return owner == str(user_id or "").strip()
|
||||
|
||||
|
||||
def mark_attached(session_id: str) -> tuple[WebcrtSession, int]:
|
||||
sess = get_session(session_id)
|
||||
if sess is None:
|
||||
|
|
@ -595,12 +652,21 @@ def close_all_sessions(*, reason: str = "shutdown") -> int:
|
|||
return closed
|
||||
|
||||
|
||||
def list_sessions() -> dict[str, Any]:
|
||||
def list_sessions(
|
||||
*,
|
||||
for_user_id: str | None = None,
|
||||
admin: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""List active sessions. Non-admin callers only see their own owned sessions."""
|
||||
viewer = str(for_user_id or "").strip()
|
||||
with _sessions_lock:
|
||||
items = []
|
||||
for s in _sessions.values():
|
||||
if s.closed:
|
||||
continue
|
||||
owner = str(s.owner_user_id or "").strip()
|
||||
if viewer and not admin and owner and owner != viewer:
|
||||
continue
|
||||
state = str(s.state or "unknown")
|
||||
attached = bool(s.attached)
|
||||
# Lifecycle for ops UI: distinguish login vs live vs grace-period detach.
|
||||
|
|
@ -643,6 +709,8 @@ def list_sessions() -> dict[str, Any]:
|
|||
if s.connect_finished_at
|
||||
else None
|
||||
),
|
||||
"owner_user_id": s.owner_user_id,
|
||||
"owner_username": s.owner_username,
|
||||
}
|
||||
)
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ dependencies = [
|
|||
"PyJWT>=2.8.0",
|
||||
"alembic>=1.13.0",
|
||||
"psutil>=5.9.0",
|
||||
"redis>=5.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
|
|||
|
|
@ -21,3 +21,4 @@ bcrypt>=4.1.0
|
|||
PyJWT>=2.8.0
|
||||
alembic>=1.13.0
|
||||
psutil>=5.9.0
|
||||
redis>=5.0.0
|
||||
|
|
|
|||
|
|
@ -30,11 +30,13 @@ class AuthUnitTests(unittest.TestCase):
|
|||
with patch("netx_api.auth_tokens.settings") as st:
|
||||
st.auth_secret = "test-secret-key-for-jwt"
|
||||
st.auth_token_ttl_sec = 3600
|
||||
tok = issue_access_token(user_id="u1", username="admin", role="admin")
|
||||
tok, jti, ttl = issue_access_token(user_id="u1", username="admin", role="admin")
|
||||
payload = decode_access_token(tok)
|
||||
self.assertEqual(payload["sub"], "u1")
|
||||
self.assertEqual(payload["username"], "admin")
|
||||
self.assertEqual(payload["role"], "admin")
|
||||
self.assertEqual(payload["jti"], jti)
|
||||
self.assertEqual(ttl, 3600)
|
||||
|
||||
|
||||
class AuthApiTests(unittest.TestCase):
|
||||
|
|
@ -81,6 +83,10 @@ class AuthApiTests(unittest.TestCase):
|
|||
db = self.Session()
|
||||
try:
|
||||
bootstrap_admin_if_needed(db)
|
||||
# Most tests exercise normal APIs; password-change gate is covered separately.
|
||||
admin = db.query(AppUser).filter(AppUser.username == "admin").one()
|
||||
admin.must_change_password = False
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
|
@ -114,12 +120,17 @@ class AuthApiTests(unittest.TestCase):
|
|||
db = self.Session()
|
||||
try:
|
||||
admin = db.query(AppUser).filter(AppUser.username == "admin").one()
|
||||
admin.must_change_password = True
|
||||
db.commit()
|
||||
self.assertTrue(admin.must_change_password)
|
||||
finally:
|
||||
db.close()
|
||||
token = self._login()
|
||||
me = self.client.get("/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||
self.assertTrue(me.json()["user"]["must_change_password"])
|
||||
blocked = self.client.get("/v1/probe", headers={"Authorization": f"Bearer {token}"})
|
||||
self.assertEqual(blocked.status_code, 403)
|
||||
self.assertEqual(blocked.json()["detail"], "password_change_required")
|
||||
bad = self.client.post(
|
||||
"/v1/auth/change-password",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
|
|
@ -134,6 +145,154 @@ class AuthApiTests(unittest.TestCase):
|
|||
self.assertEqual(ok.status_code, 200, ok.text)
|
||||
me2 = self.client.get("/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||
self.assertFalse(me2.json()["user"]["must_change_password"])
|
||||
probe = self.client.get("/v1/probe", headers={"Authorization": f"Bearer {token}"})
|
||||
self.assertEqual(probe.status_code, 200)
|
||||
|
||||
def test_logout_revokes_jwt(self) -> None:
|
||||
token = self._login()
|
||||
r = self.client.get("/v1/probe", headers={"Authorization": f"Bearer {token}"})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
out = self.client.post("/v1/auth/logout", headers={"Authorization": f"Bearer {token}"})
|
||||
self.assertEqual(out.status_code, 200, out.text)
|
||||
self.assertGreaterEqual(int(out.json().get("revoked") or 0), 1)
|
||||
r2 = self.client.get("/v1/probe", headers={"Authorization": f"Bearer {token}"})
|
||||
self.assertEqual(r2.status_code, 401)
|
||||
|
||||
def test_refresh_rotates_tokens(self) -> None:
|
||||
login = self.client.post("/v1/auth/login", json={"username": "admin", "password": "adminpass"})
|
||||
self.assertEqual(login.status_code, 200, login.text)
|
||||
body = login.json()
|
||||
access = body["access_token"]
|
||||
refresh = body["refresh_token"]
|
||||
self.assertTrue(str(refresh).startswith("nxr_"))
|
||||
# Access works
|
||||
self.assertEqual(
|
||||
self.client.get("/v1/probe", headers={"Authorization": f"Bearer {access}"}).status_code,
|
||||
200,
|
||||
)
|
||||
rotated = self.client.post("/v1/auth/refresh", json={"refresh_token": refresh})
|
||||
self.assertEqual(rotated.status_code, 200, rotated.text)
|
||||
new_access = rotated.json()["access_token"]
|
||||
new_refresh = rotated.json()["refresh_token"]
|
||||
self.assertNotEqual(access, new_access)
|
||||
self.assertNotEqual(refresh, new_refresh)
|
||||
# Old access revoked
|
||||
self.assertEqual(
|
||||
self.client.get("/v1/probe", headers={"Authorization": f"Bearer {access}"}).status_code,
|
||||
401,
|
||||
)
|
||||
# New access works
|
||||
self.assertEqual(
|
||||
self.client.get("/v1/probe", headers={"Authorization": f"Bearer {new_access}"}).status_code,
|
||||
200,
|
||||
)
|
||||
# Old refresh cannot be reused
|
||||
reuse = self.client.post("/v1/auth/refresh", json={"refresh_token": refresh})
|
||||
self.assertEqual(reuse.status_code, 401)
|
||||
|
||||
def test_single_session_login_revokes_others(self) -> None:
|
||||
token = self._login()
|
||||
token2 = self._login()
|
||||
# Default auth_single_session=True: first login is kicked.
|
||||
self.assertEqual(
|
||||
self.client.get("/v1/probe", headers={"Authorization": f"Bearer {token}"}).status_code,
|
||||
401,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.client.get("/v1/probe", headers={"Authorization": f"Bearer {token2}"}).status_code,
|
||||
200,
|
||||
)
|
||||
listed = self.client.get("/v1/auth/sessions", headers={"Authorization": f"Bearer {token2}"})
|
||||
self.assertEqual(listed.status_code, 200, listed.text)
|
||||
self.assertEqual(listed.json()["total"], 1)
|
||||
self.assertTrue(listed.json()["items"][0].get("current"))
|
||||
|
||||
def test_list_and_revoke_sessions(self) -> None:
|
||||
with patch("netx_api.auth_service.settings.auth_single_session", False):
|
||||
token = self._login()
|
||||
listed = self.client.get("/v1/auth/sessions", headers={"Authorization": f"Bearer {token}"})
|
||||
self.assertEqual(listed.status_code, 200, listed.text)
|
||||
items = listed.json()["items"]
|
||||
self.assertGreaterEqual(len(items), 1)
|
||||
self.assertTrue(any(i.get("current") for i in items))
|
||||
# Multi-session mode: second login keeps the first alive.
|
||||
token2 = self._login()
|
||||
listed2 = self.client.get("/v1/auth/sessions", headers={"Authorization": f"Bearer {token2}"})
|
||||
self.assertGreaterEqual(listed2.json()["total"], 2)
|
||||
revoked = self.client.post(
|
||||
"/v1/auth/sessions/revoke-others",
|
||||
headers={"Authorization": f"Bearer {token2}"},
|
||||
)
|
||||
self.assertEqual(revoked.status_code, 200, revoked.text)
|
||||
self.assertGreaterEqual(int(revoked.json().get("revoked") or 0), 1)
|
||||
self.assertEqual(
|
||||
self.client.get("/v1/probe", headers={"Authorization": f"Bearer {token}"}).status_code,
|
||||
401,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.client.get("/v1/probe", headers={"Authorization": f"Bearer {token2}"}).status_code,
|
||||
200,
|
||||
)
|
||||
|
||||
def test_idle_timeout_revokes(self) -> None:
|
||||
from datetime import timedelta
|
||||
|
||||
from netx_api.models import AuthSession
|
||||
from netx_api.timeutil import utcnow_naive
|
||||
|
||||
token = self._login()
|
||||
with patch("netx_api.auth_service.settings.auth_idle_timeout_sec", 60):
|
||||
db = self.Session()
|
||||
try:
|
||||
row = db.query(AuthSession).filter(AuthSession.revoked_at.is_(None)).first()
|
||||
self.assertIsNotNone(row)
|
||||
row.last_seen_at = utcnow_naive() - timedelta(seconds=120)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
self.assertEqual(
|
||||
self.client.get("/v1/probe", headers={"Authorization": f"Bearer {token}"}).status_code,
|
||||
401,
|
||||
)
|
||||
|
||||
def test_login_sets_auth_cookies(self) -> None:
|
||||
r = self.client.post("/v1/auth/login", json={"username": "admin", "password": "adminpass"})
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
# Starlette TestClient exposes set cookies
|
||||
self.assertIn("netx_at", r.cookies)
|
||||
self.assertIn("netx_rt", r.cookies)
|
||||
me = self.client.get("/v1/auth/me") # cookie auth
|
||||
self.assertEqual(me.status_code, 200, me.text)
|
||||
self.assertEqual(me.json()["user"]["username"], "admin")
|
||||
|
||||
def test_query_access_token_rejected(self) -> None:
|
||||
token = self._login()
|
||||
# Drop HttpOnly session cookies so only the deprecated query param remains.
|
||||
self.client.cookies.clear()
|
||||
r = self.client.get(f"/v1/probe?access_token={token}")
|
||||
self.assertEqual(r.status_code, 401)
|
||||
# Same token still works via Bearer header.
|
||||
r2 = self.client.get("/v1/probe", headers={"Authorization": f"Bearer {token}"})
|
||||
self.assertEqual(r2.status_code, 200)
|
||||
|
||||
def test_login_lockout(self) -> None:
|
||||
from netx_api.auth_rate_limit import reset_login_rate_limit_for_tests
|
||||
|
||||
reset_login_rate_limit_for_tests()
|
||||
with patch("netx_api.auth_rate_limit.settings.auth_login_max_failures", 3):
|
||||
with patch("netx_api.auth_rate_limit.settings.auth_login_lockout_sec", 120):
|
||||
for _ in range(3):
|
||||
r = self.client.post(
|
||||
"/v1/auth/login", json={"username": "admin", "password": "wrong"}
|
||||
)
|
||||
self.assertIn(r.status_code, (401, 429))
|
||||
locked = self.client.post(
|
||||
"/v1/auth/login", json={"username": "admin", "password": "wrong"}
|
||||
)
|
||||
self.assertEqual(locked.status_code, 429)
|
||||
detail = locked.json()["detail"]
|
||||
self.assertEqual(detail["error"], "login_locked")
|
||||
reset_login_rate_limit_for_tests()
|
||||
|
||||
def test_login_and_me(self) -> None:
|
||||
token = self._login()
|
||||
|
|
@ -167,10 +326,10 @@ class AuthApiTests(unittest.TestCase):
|
|||
db = self.Session()
|
||||
try:
|
||||
admin = db.query(AppUser).filter(AppUser.username == "admin").one()
|
||||
create_user(db, username="alice", password="alice12", role="user", actor=admin)
|
||||
create_user(db, username="alice", password="alice123", role="user", actor=admin)
|
||||
finally:
|
||||
db.close()
|
||||
token = self._login("alice", "alice12")
|
||||
token = self._login("alice", "alice123")
|
||||
r = self.client.post(
|
||||
"/v1/users",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
|
|
@ -209,7 +368,7 @@ class AuthApiTests(unittest.TestCase):
|
|||
self.client.post(
|
||||
"/v1/users",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"username": "carol", "password": "carol12", "role": "user"},
|
||||
json={"username": "carol", "password": "carol123", "role": "user"},
|
||||
)
|
||||
users = self.client.get("/v1/users", headers={"Authorization": f"Bearer {token}"})
|
||||
carol_id = next(u["id"] for u in users.json()["items"] if u["username"] == "carol")
|
||||
|
|
|
|||
|
|
@ -167,7 +167,9 @@ class RbacApiTests(unittest.TestCase):
|
|||
try:
|
||||
bootstrap_admin_if_needed(db)
|
||||
admin = db.query(AppUser).filter(AppUser.username == "admin").one()
|
||||
create_user(db, username="alice", password="alice12", role="user", actor=admin)
|
||||
admin.must_change_password = False
|
||||
db.commit()
|
||||
create_user(db, username="alice", password="alice123", role="user", actor=admin)
|
||||
finally:
|
||||
db.close()
|
||||
self.client = TestClient(self.app)
|
||||
|
|
@ -184,7 +186,7 @@ class RbacApiTests(unittest.TestCase):
|
|||
return str(r.json()["access_token"])
|
||||
|
||||
def test_user_denied_webcrt_and_sql(self) -> None:
|
||||
token = self._login("alice", "alice12")
|
||||
token = self._login("alice", "alice123")
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
self.assertEqual(self.client.post("/v1/webcrt/sessions", headers=h).status_code, 403)
|
||||
self.assertEqual(
|
||||
|
|
@ -209,7 +211,7 @@ class RbacApiTests(unittest.TestCase):
|
|||
self.assertNotEqual(r.status_code, 403)
|
||||
|
||||
def test_me_returns_scopes(self) -> None:
|
||||
token = self._login("alice", "alice12")
|
||||
token = self._login("alice", "alice123")
|
||||
me = self.client.get("/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||
self.assertEqual(me.status_code, 200)
|
||||
scopes = me.json()["scopes"]
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ class SchemaPatchesTests(unittest.TestCase):
|
|||
self.assertIn("must_change_password", user_cols)
|
||||
self.assertIn("scopes", token_cols)
|
||||
self.assertIn("expires_at", token_cols)
|
||||
self.assertIn("auth_session", insp.get_table_names())
|
||||
|
||||
def test_domain_patches_do_not_raise(self) -> None:
|
||||
with self.engine.begin() as conn:
|
||||
|
|
@ -49,6 +50,8 @@ class SchemaPatchesTests(unittest.TestCase):
|
|||
files = sorted(p.name for p in versions.glob("*.py") if p.name != "__init__.py")
|
||||
self.assertIn("20260802_scopes.py", files)
|
||||
self.assertIn("20260802_legacy_schema.py", files)
|
||||
self.assertIn("20260806_auth_session.py", files)
|
||||
self.assertIn("20260806_auth_refresh.py", files)
|
||||
text_legacy = (versions / "20260802_legacy_schema.py").read_text(encoding="utf-8")
|
||||
self.assertIn('down_revision', text_legacy)
|
||||
self.assertIn("20260802_scopes", text_legacy)
|
||||
|
|
|
|||
|
|
@ -54,6 +54,9 @@ const AuditPage = lazy(() => import("./pages/AuditPage").then((m) => ({ default:
|
|||
const ApiTokensPage = lazy(() =>
|
||||
import("./pages/ApiTokensPage").then((m) => ({ default: m.ApiTokensPage })),
|
||||
);
|
||||
const SessionsPage = lazy(() =>
|
||||
import("./pages/SessionsPage").then((m) => ({ default: m.SessionsPage })),
|
||||
);
|
||||
|
||||
/** Preserve query when redirecting legacy /network/webcrt → /webcrt. */
|
||||
function NetworkWebcrtRedirect() {
|
||||
|
|
@ -129,6 +132,7 @@ function ProtectedApp() {
|
|||
<Route path="logs" element={<AuditPage />} />
|
||||
</Route>
|
||||
<Route path="/api-keys" element={<ApiTokensPage />} />
|
||||
<Route path="/sessions" element={<SessionsPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
|
|
|
|||
|
|
@ -7,14 +7,7 @@ import {
|
|||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
AUTH_TOKEN_KEY,
|
||||
apiGet,
|
||||
apiPost,
|
||||
clearAuthToken,
|
||||
getAuthToken,
|
||||
setAuthToken,
|
||||
} from "../services/api";
|
||||
import { apiGet, apiPost, clearAuthToken } from "../services/api";
|
||||
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
|
|
@ -44,25 +37,19 @@ const AuthContext = createContext<AuthState | null>(null);
|
|||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [token, setToken] = useState<string | null>(() => getAuthToken());
|
||||
// token is opaque for UI; cookie session means we only care about user presence.
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [scopes, setScopes] = useState<string[]>([]);
|
||||
|
||||
const refreshMe = useCallback(async () => {
|
||||
const tok = getAuthToken();
|
||||
if (!tok) {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setScopes([]);
|
||||
return;
|
||||
}
|
||||
clearAuthToken(); // drop any legacy localStorage tokens
|
||||
try {
|
||||
const data = await apiGet<{ user: AuthUser; scopes?: string[] }>("/v1/auth/me");
|
||||
setToken(tok);
|
||||
setToken("cookie");
|
||||
setUser(data.user);
|
||||
setScopes(data.scopes || data.user.scopes || []);
|
||||
} catch {
|
||||
clearAuthToken();
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setScopes([]);
|
||||
|
|
@ -76,40 +63,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
})();
|
||||
}, [refreshMe]);
|
||||
|
||||
// Other tabs keep React auth state until they hear localStorage change.
|
||||
// `storage` fires only in *other* documents — used to sync logout/login.
|
||||
useEffect(() => {
|
||||
const onStorage = (ev: StorageEvent) => {
|
||||
if (ev.storageArea && ev.storageArea !== localStorage) return;
|
||||
if (ev.key !== null && ev.key !== AUTH_TOKEN_KEY) return;
|
||||
if (ev.key === null || ev.newValue == null || ev.newValue === "") {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setScopes([]);
|
||||
return;
|
||||
}
|
||||
void refreshMe();
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => window.removeEventListener("storage", onStorage);
|
||||
}, [refreshMe]);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const data = await apiPost<{ access_token: string; user: AuthUser }>("/v1/auth/login", {
|
||||
const data = await apiPost<{ user: AuthUser }>("/v1/auth/login", {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
setAuthToken(data.access_token);
|
||||
setToken(data.access_token);
|
||||
clearAuthToken();
|
||||
setToken("cookie");
|
||||
setUser(data.user);
|
||||
setScopes(data.user.scopes || []);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
if (getAuthToken()) {
|
||||
await apiPost("/v1/auth/logout", {});
|
||||
}
|
||||
await apiPost("/v1/auth/logout", {});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,6 +130,16 @@ export const MODULES: readonly ModuleDefinition[] = [
|
|||
iconKind: "key",
|
||||
titleKey: "layout.titleApiKeys",
|
||||
},
|
||||
{
|
||||
moduleId: "sessions",
|
||||
path: "/sessions",
|
||||
section: "system",
|
||||
labelKey: "workbench.cards.sessions",
|
||||
descKey: "workbench.cards.sessionsDesc",
|
||||
iconTone: "slate",
|
||||
iconKind: "key",
|
||||
titleKey: "layout.titleSessions",
|
||||
},
|
||||
] as const satisfies readonly ModuleDefinition[];
|
||||
|
||||
export function getModuleById(moduleId: string): ModuleDefinition | undefined {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ const en = {
|
|||
logs: "Audit logs",
|
||||
users: "User admin",
|
||||
apiKeys: "API keys",
|
||||
sessions: "Sessions",
|
||||
},
|
||||
announce: {
|
||||
a1: "Dark workbench shell is live — other modules follow the same palette.",
|
||||
|
|
@ -97,6 +98,8 @@ const en = {
|
|||
auditDesc: "Live task overview and operation logs",
|
||||
apiKeys: "API Keys",
|
||||
apiKeysDesc: "Issue MCP/script tokens per user with expiry",
|
||||
sessions: "Login sessions",
|
||||
sessionsDesc: "Review and revoke logins on other devices",
|
||||
},
|
||||
},
|
||||
network: {
|
||||
|
|
@ -519,6 +522,7 @@ const en = {
|
|||
titleUsers: "Users",
|
||||
titleAudit: "Audit",
|
||||
titleApiKeys: "API Keys",
|
||||
titleSessions: "Login sessions",
|
||||
navUme: "UME",
|
||||
netxApi: "netx api",
|
||||
oclawBridge: "oclaw WSS",
|
||||
|
|
@ -538,6 +542,18 @@ const en = {
|
|||
loggingIn: "Signing in…",
|
||||
loginFailed: "Login failed",
|
||||
logout: "Sign out",
|
||||
sessionsTitle: "Login sessions",
|
||||
sessionsHint: "Manage browser/device logins for this account. Revoked sessions must sign in again.",
|
||||
sessionsEmpty: "No active sessions.",
|
||||
revokeOtherSessions: "Revoke other sessions",
|
||||
revokeSession: "Revoke",
|
||||
sessionCurrent: "Current",
|
||||
sessionRevoked: "Session revoked",
|
||||
sessionsRevokedOthers: "Revoked {{count}} other session(s)",
|
||||
revokeCurrentConfirm: "This is your current session; revoking it requires signing in again. Continue?",
|
||||
colSession: "Session",
|
||||
colLastSeen: "Last seen",
|
||||
colCreated: "Created",
|
||||
usersTitle: "User management",
|
||||
usersHint: "Only admins can create and manage local accounts.",
|
||||
addUser: "Add user",
|
||||
|
|
@ -614,7 +630,7 @@ const en = {
|
|||
confirmPassword: "Confirm new password",
|
||||
savePassword: "Save new password",
|
||||
savingPassword: "Saving…",
|
||||
passwordTooShort: "New password must be at least 6 characters",
|
||||
passwordTooShort: "New password must be at least 8 characters",
|
||||
passwordMismatch: "New passwords do not match",
|
||||
passwordMustChange: "New password must differ from the default/old password",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ const zh = {
|
|||
logs: "操作日志",
|
||||
users: "用户管理",
|
||||
apiKeys: "API Key",
|
||||
sessions: "登录会话",
|
||||
},
|
||||
announce: {
|
||||
a1: "深色工作台已上线,其它模块将沿用同一套深色体系。",
|
||||
|
|
@ -97,6 +98,8 @@ const zh = {
|
|||
auditDesc: "任务概览与操作日志",
|
||||
apiKeys: "API Key",
|
||||
apiKeysDesc: "为用户生成 MCP/脚本用 Token,可设有效期",
|
||||
sessions: "登录会话",
|
||||
sessionsDesc: "查看并踢掉其他设备上的登录",
|
||||
},
|
||||
},
|
||||
network: {
|
||||
|
|
@ -515,6 +518,7 @@ const zh = {
|
|||
titleUsers: "用户管理",
|
||||
titleAudit: "操作审计",
|
||||
titleApiKeys: "API Key",
|
||||
titleSessions: "登录会话",
|
||||
navUme: "UME 对接",
|
||||
netxApi: "netx api",
|
||||
oclawBridge: "oclaw WSS",
|
||||
|
|
@ -534,6 +538,18 @@ const zh = {
|
|||
loggingIn: "登录中…",
|
||||
loginFailed: "登录失败",
|
||||
logout: "退出",
|
||||
sessionsTitle: "登录会话",
|
||||
sessionsHint: "管理当前账号在各浏览器/设备上的登录。踢掉会话后对方需重新登录。",
|
||||
sessionsEmpty: "当前没有活跃会话。",
|
||||
revokeOtherSessions: "踢掉其他会话",
|
||||
revokeSession: "踢掉",
|
||||
sessionCurrent: "当前",
|
||||
sessionRevoked: "会话已吊销",
|
||||
sessionsRevokedOthers: "已踢掉 {{count}} 个其他会话",
|
||||
revokeCurrentConfirm: "这是当前会话,踢掉后需要重新登录。继续?",
|
||||
colSession: "会话",
|
||||
colLastSeen: "最近活动",
|
||||
colCreated: "创建时间",
|
||||
usersTitle: "用户管理",
|
||||
usersHint: "仅管理员可创建与管理本地账号。",
|
||||
addUser: "添加用户",
|
||||
|
|
@ -609,7 +625,7 @@ const zh = {
|
|||
confirmPassword: "确认新密码",
|
||||
savePassword: "保存新密码",
|
||||
savingPassword: "保存中…",
|
||||
passwordTooShort: "新密码至少 6 位",
|
||||
passwordTooShort: "新密码至少 8 位",
|
||||
passwordMismatch: "两次输入的新密码不一致",
|
||||
passwordMustChange: "新密码不能与默认/旧密码相同",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export function ForceChangePasswordPage() {
|
|||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
if (newPassword.length < 6) {
|
||||
if (newPassword.length < 8) {
|
||||
setError(t("auth.passwordTooShort"));
|
||||
return;
|
||||
}
|
||||
|
|
@ -77,7 +77,7 @@ export function ForceChangePasswordPage() {
|
|||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={busy}
|
||||
required
|
||||
minLength={6}
|
||||
minLength={8}
|
||||
/>
|
||||
</label>
|
||||
<label className="login-card__label">
|
||||
|
|
@ -90,7 +90,7 @@ export function ForceChangePasswordPage() {
|
|||
onChange={(e) => setConfirm(e.target.value)}
|
||||
disabled={busy}
|
||||
required
|
||||
minLength={6}
|
||||
minLength={8}
|
||||
/>
|
||||
</label>
|
||||
{error ? (
|
||||
|
|
|
|||
118
web/src/pages/SessionsPage.tsx
Normal file
118
web/src/pages/SessionsPage.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import { apiDelete, apiGet, apiPost } from "../services/api";
|
||||
import { formatSystemTime } from "../utils/time";
|
||||
|
||||
type SessionRow = {
|
||||
id: string;
|
||||
created_at: string | null;
|
||||
expires_at: string | null;
|
||||
refresh_expires_at: string | null;
|
||||
last_seen_at: string | null;
|
||||
client_ip: string;
|
||||
user_agent: string;
|
||||
current: boolean;
|
||||
};
|
||||
|
||||
export function SessionsPage() {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
queryKey: ["auth-sessions"],
|
||||
queryFn: () => apiGet<{ items: SessionRow[]; total: number }>("/v1/auth/sessions"),
|
||||
});
|
||||
|
||||
const revokeMut = useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/v1/auth/sessions/${encodeURIComponent(id)}`),
|
||||
onSuccess: () => {
|
||||
showOk(t("auth.sessionRevoked"));
|
||||
void qc.invalidateQueries({ queryKey: ["auth-sessions"] });
|
||||
},
|
||||
onError: (err) => showError(String(err instanceof Error ? err.message : err)),
|
||||
});
|
||||
|
||||
const revokeOthersMut = useMutation({
|
||||
mutationFn: () => apiPost<{ revoked: number }>("/v1/auth/sessions/revoke-others", {}),
|
||||
onSuccess: (data) => {
|
||||
showOk(t("auth.sessionsRevokedOthers", { count: data.revoked ?? 0 }));
|
||||
void qc.invalidateQueries({ queryKey: ["auth-sessions"] });
|
||||
},
|
||||
onError: (err) => showError(String(err instanceof Error ? err.message : err)),
|
||||
});
|
||||
|
||||
const items = sessionsQuery.data?.items || [];
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="page-header">
|
||||
<h1>{t("auth.sessionsTitle")}</h1>
|
||||
<p className="panel__hint">{t("auth.sessionsHint")}</p>
|
||||
</header>
|
||||
|
||||
<div className="panel">
|
||||
<div className="filter-inline" style={{ marginBottom: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={revokeOthersMut.isPending || items.filter((s) => !s.current).length === 0}
|
||||
onClick={() => revokeOthersMut.mutate()}
|
||||
>
|
||||
{t("auth.revokeOtherSessions")}
|
||||
</button>
|
||||
<button type="button" onClick={() => void sessionsQuery.refetch()} disabled={sessionsQuery.isFetching}>
|
||||
{t("common.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sessionsQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
|
||||
{!items.length && !sessionsQuery.isLoading ? (
|
||||
<p className="muted">{t("auth.sessionsEmpty")}</p>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("auth.colSession")}</th>
|
||||
<th>{t("auth.colIp")}</th>
|
||||
<th>{t("auth.colLastSeen")}</th>
|
||||
<th>{t("auth.colCreated")}</th>
|
||||
<th>{t("auth.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<code title={row.user_agent}>{row.id.slice(0, 10)}…</code>
|
||||
{row.current ? (
|
||||
<span className="pt-list-status pt-list-status--ok" style={{ marginLeft: 8 }}>
|
||||
{t("auth.sessionCurrent")}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td>{row.client_ip || "—"}</td>
|
||||
<td>{row.last_seen_at ? formatSystemTime(row.last_seen_at) : "—"}</td>
|
||||
<td>{row.created_at ? formatSystemTime(row.created_at) : "—"}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
disabled={revokeMut.isPending}
|
||||
onClick={() => {
|
||||
if (row.current && !window.confirm(t("auth.revokeCurrentConfirm"))) return;
|
||||
revokeMut.mutate(row.id);
|
||||
}}
|
||||
>
|
||||
{t("auth.revokeSession")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -83,7 +83,7 @@ export function UsersPage() {
|
|||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
minLength={8}
|
||||
/>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value)}>
|
||||
<option value="user">{t("auth.roleUser")}</option>
|
||||
|
|
@ -155,7 +155,7 @@ export function UsersPage() {
|
|||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!resetPwd[u.id] || resetPwd[u.id].length < 6}
|
||||
disabled={!resetPwd[u.id] || resetPwd[u.id].length < 8}
|
||||
onClick={() => {
|
||||
const pwd = resetPwd[u.id];
|
||||
patchMut.mutate({ id: u.id, body: { password: pwd } });
|
||||
|
|
|
|||
|
|
@ -50,36 +50,40 @@ import type {
|
|||
|
||||
|
||||
export const AUTH_TOKEN_KEY = "netx_access_token";
|
||||
export const AUTH_REFRESH_KEY = "netx_refresh_token";
|
||||
|
||||
export const getAuthToken = (): string | null => {
|
||||
try {
|
||||
return localStorage.getItem(AUTH_TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const setAuthToken = (token: string): void => {
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, String(token || ""));
|
||||
};
|
||||
|
||||
/** Clear legacy localStorage tokens; browser auth now uses HttpOnly cookies. */
|
||||
export const clearAuthToken = (): void => {
|
||||
try {
|
||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
localStorage.removeItem(AUTH_REFRESH_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
/** @deprecated Cookie session — always null for browser UI. */
|
||||
export const getAuthToken = (): string | null => null;
|
||||
|
||||
/** No-op kept for call-site compatibility during cookie migration. */
|
||||
export const setAuthToken = (_token: string): void => {
|
||||
clearAuthToken();
|
||||
};
|
||||
|
||||
export const setAuthTokens = (_access: string, _refresh?: string | null): void => {
|
||||
clearAuthToken();
|
||||
};
|
||||
|
||||
const fetchCreds: RequestCredentials = "include";
|
||||
|
||||
const authHeaders = (extra?: Record<string, string>): Record<string, string> => {
|
||||
const h: Record<string, string> = { accept: "application/json", ...(extra || {}) };
|
||||
const tok = getAuthToken();
|
||||
if (tok) h.authorization = `Bearer ${tok}`;
|
||||
return h;
|
||||
// Browser JWT rides HttpOnly cookies (credentials: include).
|
||||
// Authorization Bearer is only needed for non-browser API tokens if ever injected.
|
||||
return { accept: "application/json", ...(extra || {}) };
|
||||
};
|
||||
|
||||
const handleUnauthorized = (path: string): void => {
|
||||
if (path.startsWith("/v1/auth/login")) return;
|
||||
if (path.startsWith("/v1/auth/login") || path.startsWith("/v1/auth/refresh")) return;
|
||||
clearAuthToken();
|
||||
if (typeof window !== "undefined" && !window.location.pathname.startsWith("/login")) {
|
||||
const next = `${window.location.pathname}${window.location.search || ""}`;
|
||||
|
|
@ -87,6 +91,38 @@ const handleUnauthorized = (path: string): void => {
|
|||
}
|
||||
};
|
||||
|
||||
let refreshInFlight: Promise<boolean> | null = null;
|
||||
|
||||
const tryRefreshAccessToken = async (): Promise<boolean> => {
|
||||
if (!refreshInFlight) {
|
||||
refreshInFlight = (async () => {
|
||||
try {
|
||||
// Refresh token comes from HttpOnly cookie when body is empty.
|
||||
const res = await fetch("/v1/auth/refresh", {
|
||||
method: "POST",
|
||||
credentials: fetchCreds,
|
||||
headers: { accept: "application/json", "content-type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
clearAuthToken();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
clearAuthToken();
|
||||
return false;
|
||||
} finally {
|
||||
refreshInFlight = null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return refreshInFlight;
|
||||
};
|
||||
|
||||
const shouldAttemptRefresh = (path: string): boolean =>
|
||||
!path.startsWith("/v1/auth/login") && !path.startsWith("/v1/auth/refresh");
|
||||
|
||||
const parseApiResponse = async (res: Response): Promise<Record<string, unknown>> => {
|
||||
const text = await res.text();
|
||||
if (!text) return {};
|
||||
|
|
@ -139,7 +175,10 @@ export function formatApiDetail(detail: unknown): string {
|
|||
}
|
||||
|
||||
export const apiGet = async <T,>(path: string): Promise<T> => {
|
||||
const res = await fetch(path, { headers: authHeaders() });
|
||||
let res = await fetch(path, { headers: authHeaders(), credentials: fetchCreds });
|
||||
if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) {
|
||||
res = await fetch(path, { headers: authHeaders(), credentials: fetchCreds });
|
||||
}
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error("401 unauthorized");
|
||||
|
|
@ -149,11 +188,17 @@ export const apiGet = async <T,>(path: string): Promise<T> => {
|
|||
};
|
||||
|
||||
export const apiPost = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const doFetch = () =>
|
||||
fetch(path, {
|
||||
method: "POST",
|
||||
credentials: fetchCreds,
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
let res = await doFetch();
|
||||
if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) {
|
||||
res = await doFetch();
|
||||
}
|
||||
const data = await parseApiResponse(res);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
|
|
@ -164,11 +209,17 @@ export const apiPost = async <T,>(path: string, body: unknown): Promise<T> => {
|
|||
};
|
||||
|
||||
export const apiPatch = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||
const res = await fetch(path, {
|
||||
method: "PATCH",
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const doFetch = () =>
|
||||
fetch(path, {
|
||||
method: "PATCH",
|
||||
credentials: fetchCreds,
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
let res = await doFetch();
|
||||
if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) {
|
||||
res = await doFetch();
|
||||
}
|
||||
const data = await parseApiResponse(res);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
|
|
@ -179,7 +230,10 @@ export const apiPatch = async <T,>(path: string, body: unknown): Promise<T> => {
|
|||
};
|
||||
|
||||
export const apiDelete = async <T,>(path: string): Promise<T> => {
|
||||
const res = await fetch(path, { method: "DELETE", headers: authHeaders() });
|
||||
let res = await fetch(path, { method: "DELETE", headers: authHeaders(), credentials: fetchCreds });
|
||||
if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) {
|
||||
res = await fetch(path, { method: "DELETE", headers: authHeaders(), credentials: fetchCreds });
|
||||
}
|
||||
const data = await parseApiResponse(res);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
|
|
@ -190,11 +244,17 @@ export const apiDelete = async <T,>(path: string): Promise<T> => {
|
|||
};
|
||||
|
||||
export const apiPut = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||
const res = await fetch(path, {
|
||||
method: "PUT",
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const doFetch = () =>
|
||||
fetch(path, {
|
||||
method: "PUT",
|
||||
credentials: fetchCreds,
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
let res = await doFetch();
|
||||
if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) {
|
||||
res = await doFetch();
|
||||
}
|
||||
const data = await parseApiResponse(res);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
|
|
@ -499,7 +559,7 @@ export const managedNeImportTemplateUrl = (format: "xlsx" | "csv" = "xlsx") =>
|
|||
|
||||
export const downloadManagedNeImportTemplate = async (format: "xlsx" | "csv" = "xlsx"): Promise<void> => {
|
||||
const path = managedNeImportTemplateUrl(format);
|
||||
const res = await fetch(path, { headers: authHeaders() });
|
||||
const res = await fetch(path, { headers: authHeaders(), credentials: fetchCreds });
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error("unauthorized");
|
||||
|
|
@ -527,7 +587,7 @@ export const importManagedNe = async (file: File): Promise<ManagedNeImportResult
|
|||
form.append("file", file);
|
||||
const path = "/v1/managed-ne/import";
|
||||
// Do not set content-type — browser must add multipart boundary.
|
||||
const res = await fetch(path, { method: "POST", headers: authHeaders(), body: form });
|
||||
const res = await fetch(path, { method: "POST", headers: authHeaders(), body: form, credentials: fetchCreds });
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error("unauthorized");
|
||||
|
|
@ -673,7 +733,7 @@ export function closeWebcrtSessionsKeepalive(sessionIds: string[]): void {
|
|||
for (const id of ids) {
|
||||
const path = `/v1/webcrt/sessions/${encodeURIComponent(id)}`;
|
||||
try {
|
||||
void fetch(path, { method: "DELETE", headers, keepalive: true });
|
||||
void fetch(path, { method: "DELETE", headers, credentials: fetchCreds, keepalive: true });
|
||||
} catch {
|
||||
/* ignore unload failures */
|
||||
}
|
||||
|
|
@ -772,6 +832,7 @@ async function webcrtSftpDownloadOnce(
|
|||
const path = "/v1/webcrt/sftp/download";
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
credentials: fetchCreds,
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
signal: opts?.signal,
|
||||
|
|
@ -863,8 +924,7 @@ function webcrtSftpUploadOnce(
|
|||
opts.signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
xhr.open("POST", path);
|
||||
const tok = getAuthToken();
|
||||
if (tok) xhr.setRequestHeader("Authorization", `Bearer ${tok}`);
|
||||
xhr.withCredentials = true;
|
||||
xhr.responseType = "text";
|
||||
xhr.upload.onprogress = (ev) => {
|
||||
if (!opts?.onProgress) return;
|
||||
|
|
@ -1414,7 +1474,7 @@ export const downloadNeConfigSnapshot = async (
|
|||
const path =
|
||||
`/v1/config-sync/snapshots/${encodeURIComponent(source)}/${encodeURIComponent(targetId)}` +
|
||||
`/download?field=${encodeURIComponent(field)}`;
|
||||
const res = await fetch(path, { headers: authHeaders() });
|
||||
const res = await fetch(path, { headers: authHeaders(), credentials: fetchCreds });
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error("unauthorized");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue