mirror of
https://github.com/hansjone/netx.git
synced 2026-10-10 06:10:47 +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
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue