feat(auth): LDAPS + OIDC + SAML 2.0 strategies and SSO routers

Phase 2-4 of multi-provider authentication. All three providers slot
into the AuthOrchestrator from phase 1; no further changes to
/auth/login are needed.

LDAPS (app/auth/strategies/ldap_strategy.py):
- ldap3 with strict TLS cert validation (CERT_REQUIRED + CA bundle)
- Service-account search-then-bind flow (DN never exposed to caller)
- Filter chars escaped via ldap3.utils.conv.escape_filter_chars
- AD-flavored defaults: sAMAccountName / memberOf / objectGUID
- Refuses if filter returns >1 entry (anti-impersonation safety)
- Bind password encrypted at rest (Fernet), bootstrapped from env on
  first start then stored in settings table

OIDC (app/auth/strategies/oidc_strategy.py + app/routers/auth_oidc.py):
- Authorization Code + PKCE (S256)
- Strict ID-Token validation via Authlib: signature (JWKS w/ auto-refresh
  on rotation), iss (essential), aud (essential, must == client_id),
  exp (essential), nonce (replay protection)
- State/PKCE-verifier/nonce stored in signed itsdangerous cookie (no
  server-side session store needed)
- Discovery + JWKS cached in-process; JWKS auto-refetched on key miss
- Groups merged from both id_token claims and userinfo endpoint
- Hardened: prompt=select_account to defeat silent IdP reuse

SAML 2.0 (app/auth/strategies/saml_strategy.py + app/routers/auth_saml.py):
- python3-saml (OneLogin) with strict=true; xmlsec1 handles signature
  validation. XSW attacks mitigated via strict assertion/response
  signature position checks plus wantAssertionsSigned=true
- SP-initiated (/auth/saml/login) + IdP-initiated (POST /auth/saml/acs)
- /auth/saml/metadata serves signed SP descriptor
- RelayState same-origin check to prevent open redirect
- IdP metadata loaded from URL or file at startup

Wiring:
- app/main.py imports auth_oidc/auth_saml routers behind try/except so
  the app still starts when authlib or python3-saml aren't installed
- Frontend login page fetches /auth/providers and renders matching
  redirect buttons (Sign in with Entra ID / SAML SSO) plus the local
  credentials form. Adds MFA second-step screen with 6-digit OTP input
  when /auth/login returns mfa_required=true.

.env.example: full provider config blocks with worked examples for
Entra ID, Okta, Keycloak, Google. Each block is commented with the
exact format the corresponding admin needs.
This commit is contained in:
2026-05-12 19:11:32 +02:00
parent 25c8ef0866
commit b8e8870b29
8 changed files with 1414 additions and 61 deletions
+109
View File
@@ -62,3 +62,112 @@ TIMEZONE=Europe/Zurich
# Used in email notifications for links back to the dashboard.
# Set this to your external URL (behind reverse proxy).
# DASHBOARD_URL=https://vuln.example.com
# =============================================================================
# Multi-Provider Authentication (LDAP / OIDC / SAML / TOTP-MFA)
# =============================================================================
# Default config = local only (backwards compatible). Enable providers
# below by listing them in AUTH_PROVIDERS and filling in the matching block.
# =============================================================================
# Comma-separated list of enabled providers. Local is always recommended
# as fallback for emergency admin access.
AUTH_PROVIDERS=local
# AUTH_PROVIDERS=local,ldap,oidc,saml
# Order in which credential-based providers are tried for username/password
# login. SSO providers (saml/oidc) are not in this chain — they have their
# own redirect endpoints.
AUTH_LOOKUP_ORDER=local,ldap
# Auto-create local user stub on first SSO/LDAP login. Re-evaluates role
# from external groups on every subsequent login.
AUTH_JIT_PROVISIONING=true
# Fallback role when no group-mapping rule matches.
# Values: admin, editor, readonly
AUTH_JIT_DEFAULT_ROLE=readonly
# Fernet key for encrypting LDAP bind-pw and TOTP secrets at rest.
# Generate with:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# CHANGE THIS — losing the key invalidates all stored TOTP secrets.
AUTH_PROVIDER_CRYPTO_KEY=CHANGE-ME-FERNET-KEY
# -----------------------------------------------------------------------------
# LDAPS (Active Directory by default)
# -----------------------------------------------------------------------------
LDAP_HOST=ldaps.company.internal
LDAP_PORT=636
LDAP_USE_SSL=true
LDAP_USE_STARTTLS=false # mutually exclusive with USE_SSL
# CA bundle that signed the LDAPS server cert (required for strict validation).
LDAP_CA_CERT_PATH=/etc/ssl/certs/company-ca.pem
LDAP_VALIDATE_CERT=true # NEVER set to false in production
# Service account for the search-then-bind flow.
LDAP_BIND_DN=cn=svc-vulncheck,ou=ServiceAccounts,dc=company,dc=local
# Bootstrap password — loaded once, encrypted, stored in DB.
# Remove from env AFTER the first successful start.
LDAP_BIND_PASSWORD_BOOTSTRAP=
# User search
LDAP_USER_SEARCH_BASE=ou=Users,dc=company,dc=local
# {username} is replaced by the login form input (filter-escaped).
LDAP_USER_SEARCH_FILTER=(&(objectClass=user)(sAMAccountName={username}))
LDAP_USER_ATTR_USERNAME=sAMAccountName
LDAP_USER_ATTR_EMAIL=mail
LDAP_USER_ATTR_GROUPS=memberOf
LDAP_USER_ATTR_GUID=objectGUID # AD stable identifier
LDAP_USER_ATTR_DISPLAY=displayName
# -----------------------------------------------------------------------------
# OIDC (OpenID Connect) — Entra ID / Okta / Keycloak / Google
# -----------------------------------------------------------------------------
OIDC_PROVIDER_NAME=Entra ID
# Examples:
# Entra ID: https://login.microsoftonline.com/<tenant-id>/v2.0/.well-known/openid-configuration
# Okta: https://<your-okta-domain>/.well-known/openid-configuration
# Keycloak: https://kc.company.com/realms/<realm>/.well-known/openid-configuration
# Google: https://accounts.google.com/.well-known/openid-configuration
OIDC_DISCOVERY_URL=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URI=https://vulncheck.company.com/auth/oidc/callback
OIDC_SCOPES=openid profile email groups
# Claim names — defaults work for Entra ID / Keycloak. Adjust per IdP.
OIDC_CLAIM_USERNAME=preferred_username
OIDC_CLAIM_EMAIL=email
OIDC_CLAIM_GROUPS=groups
OIDC_CLAIM_DISPLAY=name
OIDC_CLAIM_SUBJECT=sub
# -----------------------------------------------------------------------------
# SAML 2.0
# -----------------------------------------------------------------------------
SAML_PROVIDER_NAME=Single Sign-On
SAML_SP_ENTITY_ID=https://vulncheck.company.com/auth/saml/metadata
SAML_SP_ACS_URL=https://vulncheck.company.com/auth/saml/acs
SAML_SP_SLO_URL=https://vulncheck.company.com/auth/saml/slo
# SP cert + key — generate a keypair specifically for this SP:
# openssl req -x509 -newkey rsa:2048 -nodes \
# -keyout sp.key -out sp.crt -days 730 \
# -subj "/CN=vulncheck.company.com"
SAML_SP_CERT_PATH=/etc/vulncheck/saml/sp.crt
SAML_SP_PRIVATE_KEY_PATH=/etc/vulncheck/saml/sp.key
# IdP metadata source — exactly one of:
SAML_IDP_METADATA_URL=
# SAML_IDP_METADATA_PATH=/etc/vulncheck/saml/idp-metadata.xml
# Attribute mapping (defaults work for most IdPs)
SAML_ATTR_USERNAME=urn:oid:0.9.2342.19200300.100.1.1
SAML_ATTR_EMAIL=urn:oid:1.2.840.113549.1.9.1
SAML_ATTR_GROUPS=http://schemas.xmlsoap.org/claims/Group
SAML_ATTR_DISPLAY=urn:oid:2.16.840.1.113730.3.1.241
+323
View File
@@ -0,0 +1,323 @@
"""
LDAPS authentication strategy (Active Directory flavoured by default).
Security:
- LDAPS only (TLS 1.2+ enforced via ldap3 Tls). StartTLS as alt.
- Strict server-cert validation: CERT_REQUIRED + CA bundle from env.
- Service-account bind credentials encrypted at rest (Fernet, key in env);
bootstrap from env var on first start.
- Bind-then-search-then-rebind flow: service account locates the user DN,
then we rebind with the user's password to verify.
- User password is NEVER stored or logged. Failed bind raises generic
AuthError with safe_message='Invalid credentials'.
- Group lookup via `memberOf` (AD default). Configurable via env.
Configuration is read from env on init. Admin UI to live-edit lands in
Phase 5; until then a backend restart is required after env changes.
"""
from __future__ import annotations
import json
import logging
import os
import ssl
from typing import List, Optional
import ldap3
from ldap3.core.exceptions import (
LDAPBindError,
LDAPException,
LDAPInvalidCredentialsResult,
LDAPSocketOpenError,
)
from sqlalchemy.orm import Session
from app.auth.strategies.base import AuthError, AuthStrategy, ExternalIdentity
from app.models.setting import Setting
from app.models.user import AuthProvider
logger = logging.getLogger(__name__)
DEFAULT_USER_FILTER = "(&(objectClass=user)(sAMAccountName={username}))"
DEFAULT_USER_ATTR_USERNAME = "sAMAccountName"
DEFAULT_USER_ATTR_EMAIL = "mail"
DEFAULT_USER_ATTR_GROUPS = "memberOf"
DEFAULT_USER_ATTR_OBJECT_GUID = "objectGUID" # AD stable identifier
DEFAULT_USER_ATTR_DISPLAY = "displayName"
# ---------- bind credential storage ----------
SETTINGS_LDAP_BIND_PW_ENCRYPTED = "ldap_bind_password_encrypted"
def _load_or_bootstrap_bind_password(db: Session) -> str:
"""
Return decrypted bind password. On first start, encrypt the bootstrap
value from LDAP_BIND_PASSWORD_BOOTSTRAP and store it in `settings`,
then strongly recommend removing the env var.
"""
from app.auth.totp import decrypt_secret, encrypt_secret # reuse Fernet helpers
row = db.query(Setting).filter(Setting.key == SETTINGS_LDAP_BIND_PW_ENCRYPTED).first()
if row and row.value:
try:
return decrypt_secret(row.value)
except Exception as e:
logger.error("LDAP bind password could not be decrypted: %s", e)
raise AuthError(
detail="LDAP bind password decryption failed (check AUTH_PROVIDER_CRYPTO_KEY).",
safe_message="Authentication service unavailable",
)
bootstrap = os.getenv("LDAP_BIND_PASSWORD_BOOTSTRAP")
if not bootstrap:
raise AuthError(
detail="No LDAP bind password configured (neither in DB nor in LDAP_BIND_PASSWORD_BOOTSTRAP).",
safe_message="Authentication service unavailable",
)
enc = encrypt_secret(bootstrap)
if row:
row.value = enc
else:
row = Setting(
key=SETTINGS_LDAP_BIND_PW_ENCRYPTED,
value=enc,
description="LDAP service-account bind password (Fernet-encrypted)",
)
db.add(row)
db.commit()
logger.warning(
"LDAP bind password bootstrapped from env. "
"Remove LDAP_BIND_PASSWORD_BOOTSTRAP from your environment now."
)
return bootstrap
def _build_tls() -> ldap3.Tls:
"""Build TLS config with strict cert validation."""
ca_path = os.getenv("LDAP_CA_CERT_PATH")
validate = os.getenv("LDAP_VALIDATE_CERT", "true").lower() == "true"
if not validate:
# Loud warning — never default this to off.
logger.error(
"LDAP_VALIDATE_CERT=false: server certificate is NOT validated. "
"This is INSECURE and must not be used in production."
)
return ldap3.Tls(
validate=ssl.CERT_REQUIRED if validate else ssl.CERT_NONE,
version=ssl.PROTOCOL_TLS_CLIENT,
ca_certs_file=ca_path,
)
def _decode_guid(value) -> str:
"""objectGUID is binary; convert to canonical hex string for stable identity."""
if isinstance(value, bytes):
return value.hex()
if isinstance(value, list) and value and isinstance(value[0], bytes):
return value[0].hex()
return str(value)
def _ensure_str(value, fallback: str = "") -> str:
if isinstance(value, list):
return str(value[0]) if value else fallback
if value is None:
return fallback
return str(value)
def _extract_groups(memberof_value) -> List[str]:
if memberof_value is None:
return []
if isinstance(memberof_value, list):
return [str(v) for v in memberof_value if v]
return [str(memberof_value)]
class LdapStrategy(AuthStrategy):
"""
LDAPS strategy. Wired into AuthOrchestrator and invoked when
AUTH_LOOKUP_ORDER lists 'ldap'.
"""
name = AuthProvider.LDAP
def __init__(self, db: Session):
self.db = db
self.host = os.getenv("LDAP_HOST", "").strip()
self.port = int(os.getenv("LDAP_PORT", "636"))
self.use_ssl = os.getenv("LDAP_USE_SSL", "true").lower() == "true"
self.use_starttls = os.getenv("LDAP_USE_STARTTLS", "false").lower() == "true"
self.bind_dn = os.getenv("LDAP_BIND_DN", "").strip()
self.search_base = os.getenv("LDAP_USER_SEARCH_BASE", "").strip()
self.search_filter = os.getenv("LDAP_USER_SEARCH_FILTER", DEFAULT_USER_FILTER)
self.attr_username = os.getenv("LDAP_USER_ATTR_USERNAME", DEFAULT_USER_ATTR_USERNAME)
self.attr_email = os.getenv("LDAP_USER_ATTR_EMAIL", DEFAULT_USER_ATTR_EMAIL)
self.attr_groups = os.getenv("LDAP_USER_ATTR_GROUPS", DEFAULT_USER_ATTR_GROUPS)
self.attr_object_guid = os.getenv("LDAP_USER_ATTR_GUID", DEFAULT_USER_ATTR_OBJECT_GUID)
self.attr_display = os.getenv("LDAP_USER_ATTR_DISPLAY", DEFAULT_USER_ATTR_DISPLAY)
self._tls = _build_tls() if (self.use_ssl or self.use_starttls) else None
# ---------- AuthStrategy API ----------
@property
def is_enabled(self) -> bool:
if not self.host:
return False
if not self.bind_dn:
return False
if not self.search_base:
return False
return True
def authenticate_credentials(self, username: str, password: str) -> ExternalIdentity:
if not self.is_enabled:
raise AuthError(detail="LDAP not configured")
# 1) Look up user DN with service account
user_dn, attrs = self._search_user(username)
# 2) Re-bind with user's password to verify
try:
self._bind_as_user(user_dn, password)
except (LDAPInvalidCredentialsResult, LDAPBindError):
raise AuthError(detail=f"LDAP bind rejected for '{username}'")
except LDAPException as e:
logger.error("LDAP authentication error for '%s': %s", username, e)
raise AuthError(
detail=f"LDAP error verifying '{username}': {type(e).__name__}",
safe_message="Authentication service unavailable",
)
# 3) Build ExternalIdentity from search attrs (we already have them)
return ExternalIdentity(
provider=AuthProvider.LDAP,
external_id=_decode_guid(attrs.get(self.attr_object_guid)) or user_dn,
username=_ensure_str(attrs.get(self.attr_username), fallback=username),
email=_ensure_str(attrs.get(self.attr_email), fallback=f"{username}@unknown.local"),
display_name=_ensure_str(attrs.get(self.attr_display), fallback=username),
groups=_extract_groups(attrs.get(self.attr_groups)),
extra={"dn": user_dn},
)
# ---------- internals ----------
def _server(self) -> ldap3.Server:
return ldap3.Server(
self.host,
port=self.port,
use_ssl=self.use_ssl,
tls=self._tls,
get_info=ldap3.NONE, # don't fetch DSE schema (faster, fewer round-trips)
connect_timeout=10,
)
def _service_connection(self) -> ldap3.Connection:
password = _load_or_bootstrap_bind_password(self.db)
try:
conn = ldap3.Connection(
self._server(),
user=self.bind_dn,
password=password,
authentication=ldap3.SIMPLE,
auto_bind=False,
receive_timeout=10,
)
# auto_bind=False so we can opt into StartTLS first if configured
if self.use_starttls and not self.use_ssl:
conn.open()
conn.start_tls()
if not conn.bind():
raise AuthError(
detail=f"LDAP service bind failed: {conn.result}",
safe_message="Authentication service unavailable",
)
return conn
except LDAPSocketOpenError as e:
logger.error("LDAP server unreachable %s:%s: %s", self.host, self.port, e)
raise AuthError(
detail=f"LDAP server unreachable: {e}",
safe_message="Authentication service unavailable",
)
def _search_user(self, username: str) -> tuple[str, dict]:
# ldap3 escapes filter arguments via str(format) — we MUST escape ourselves.
username_escaped = ldap3.utils.conv.escape_filter_chars(username)
flt = self.search_filter.replace("{username}", username_escaped)
attrs = [
self.attr_username,
self.attr_email,
self.attr_groups,
self.attr_object_guid,
self.attr_display,
]
conn = self._service_connection()
try:
conn.search(
search_base=self.search_base,
search_filter=flt,
search_scope=ldap3.SUBTREE,
attributes=attrs,
size_limit=2,
)
entries = conn.entries
finally:
conn.unbind()
if not entries:
raise AuthError(detail=f"LDAP user '{username}' not found")
if len(entries) > 1:
# Filter is too loose; refuse rather than risk wrong-user auth.
raise AuthError(
detail=f"LDAP filter for '{username}' returned {len(entries)} entries",
safe_message="Authentication service misconfigured",
)
entry = entries[0]
user_dn = entry.entry_dn
# Pull attributes (ldap3 returns LazyType for missing)
attrs_dict: dict = {}
for a in attrs:
try:
v = entry[a].values # always a list
attrs_dict[a] = v if len(v) > 1 else (v[0] if v else None)
except LDAPException:
attrs_dict[a] = None
return user_dn, attrs_dict
def _bind_as_user(self, user_dn: str, password: str) -> None:
"""Verify the user's password by attempting a bind as that DN."""
if not password:
raise AuthError(detail="empty password")
conn = ldap3.Connection(
self._server(),
user=user_dn,
password=password,
authentication=ldap3.SIMPLE,
auto_bind=False,
receive_timeout=10,
)
if self.use_starttls and not self.use_ssl:
conn.open()
conn.start_tls()
ok = conn.bind()
try:
if not ok:
# ldap3 distinguishes invalid-creds from other errors via .result
code = (conn.result or {}).get("result")
description = (conn.result or {}).get("description", "")
if code == 49 or "invalidCredentials" in description:
raise LDAPInvalidCredentialsResult(conn.result)
raise LDAPBindError(f"bind failed: {conn.result}")
finally:
conn.unbind()
+116
View File
@@ -0,0 +1,116 @@
"""
OAuth 2.0 / OpenID Connect strategy.
Uses Authlib (authlib.integrations.starlette_client) for the heavy lifting:
- Discovery (.well-known/openid-configuration)
- PKCE (S256) on the authorization request
- ID-Token validation (signature via JWKS, iss, aud, exp, nbf, nonce)
- Userinfo fetch for claims not present in ID-Token
This strategy does NOT do credential auth — it's a callback-only strategy.
The router (app/routers/auth_oidc.py) drives the flow and hands the
verified `id_token`/`userinfo` payload to `parse_userinfo()` here.
"""
from __future__ import annotations
import logging
import os
from typing import List
from app.auth.strategies.base import AuthError, AuthStrategy, ExternalIdentity
from app.models.user import AuthProvider
logger = logging.getLogger(__name__)
def _split_groups(value) -> List[str]:
"""Groups claim can be list-of-strings (Entra), list-of-objs (some Keycloaks), or comma-string."""
if value is None:
return []
if isinstance(value, list):
out = []
for v in value:
if isinstance(v, str):
out.append(v)
elif isinstance(v, dict):
name = v.get("name") or v.get("displayName") or v.get("id")
if name:
out.append(str(name))
return out
if isinstance(value, str):
return [g.strip() for g in value.split(",") if g.strip()]
return []
class OidcStrategy(AuthStrategy):
"""
OIDC. Configuration via env vars (OIDC_*). Provider is enabled only
when discovery URL and client credentials are set.
"""
name = AuthProvider.OIDC
def __init__(self):
self.discovery_url = os.getenv("OIDC_DISCOVERY_URL", "").strip()
self.client_id = os.getenv("OIDC_CLIENT_ID", "").strip()
self.client_secret = os.getenv("OIDC_CLIENT_SECRET", "").strip()
self.redirect_uri = os.getenv("OIDC_REDIRECT_URI", "").strip()
self.scopes = os.getenv("OIDC_SCOPES", "openid profile email").strip()
self.provider_name = os.getenv("OIDC_PROVIDER_NAME", "OIDC").strip()
# Claim names
self.claim_username = os.getenv("OIDC_CLAIM_USERNAME", "preferred_username")
self.claim_email = os.getenv("OIDC_CLAIM_EMAIL", "email")
self.claim_groups = os.getenv("OIDC_CLAIM_GROUPS", "groups")
self.claim_display = os.getenv("OIDC_CLAIM_DISPLAY", "name")
self.claim_subject = os.getenv("OIDC_CLAIM_SUBJECT", "sub")
@property
def is_enabled(self) -> bool:
return bool(self.discovery_url and self.client_id and self.client_secret and self.redirect_uri)
def authenticate_credentials(self, username: str, password: str) -> ExternalIdentity:
raise NotImplementedError("OIDC is redirect-based; use the /auth/oidc/* router.")
# Called by the router after Authlib has validated the ID token.
def parse_userinfo(self, userinfo: dict, id_token_claims: dict) -> ExternalIdentity:
# Prefer ID-Token claims for security-relevant fields (signed, validated).
sub = id_token_claims.get(self.claim_subject) or userinfo.get("sub")
if not sub:
raise AuthError(
detail="OIDC response missing 'sub' claim",
safe_message="Identity provider error",
)
email = id_token_claims.get(self.claim_email) or userinfo.get(self.claim_email)
username = (
id_token_claims.get(self.claim_username)
or userinfo.get(self.claim_username)
or (email.split("@")[0] if email else None)
or sub
)
display = id_token_claims.get(self.claim_display) or userinfo.get(self.claim_display) or username
# Groups can come from either ID-Token or userinfo. Merge.
groups = list(
set(
_split_groups(id_token_claims.get(self.claim_groups))
+ _split_groups(userinfo.get(self.claim_groups))
)
)
if not email:
# Some IdPs require explicit 'email' scope to populate it. We can still
# provision, but role-mapping by group remains the only path.
logger.warning("OIDC userinfo has no email claim for sub=%s", sub[:8])
email = f"{sub}@oidc.local"
return ExternalIdentity(
provider=AuthProvider.OIDC,
external_id=str(sub),
username=str(username),
email=str(email),
display_name=str(display) if display else None,
groups=groups,
extra={"iss": id_token_claims.get("iss")},
)
+94
View File
@@ -0,0 +1,94 @@
"""
SAML 2.0 authentication strategy.
Uses python3-saml (OneLogin), which delegates XML signature validation to
xmlsec1. python3-saml has built-in protection against:
- XML Signature Wrapping (XSW): strict assertion/response signature
position validation
- Replay (per-NotOnOrAfter window)
- Audience mismatch
This strategy does NOT do credential auth — it's a callback-only strategy.
The router (app/routers/auth_saml.py) drives the SP-initiated and
IdP-initiated flows.
"""
from __future__ import annotations
import logging
import os
from typing import List
from app.auth.strategies.base import AuthError, AuthStrategy, ExternalIdentity
from app.models.user import AuthProvider
logger = logging.getLogger(__name__)
def _split_groups(value) -> List[str]:
if value is None:
return []
if isinstance(value, list):
return [str(v) for v in value if v]
if isinstance(value, str):
return [g.strip() for g in value.split(",") if g.strip()]
return [str(value)]
class SamlStrategy(AuthStrategy):
"""
SAML 2.0 strategy. Most config lives in the SP/IdP YAML used by
python3-saml directly; here we just hold attribute-name mappings.
"""
name = AuthProvider.SAML
def __init__(self):
self.attr_username = os.getenv(
"SAML_ATTR_USERNAME", "urn:oid:0.9.2342.19200300.100.1.1"
)
self.attr_email = os.getenv("SAML_ATTR_EMAIL", "urn:oid:1.2.840.113549.1.9.1")
self.attr_groups = os.getenv("SAML_ATTR_GROUPS", "http://schemas.xmlsoap.org/claims/Group")
self.attr_display = os.getenv("SAML_ATTR_DISPLAY", "urn:oid:2.16.840.1.113730.3.1.241")
self.idp_metadata_url = os.getenv("SAML_IDP_METADATA_URL", "").strip()
self.idp_metadata_path = os.getenv("SAML_IDP_METADATA_PATH", "").strip()
@property
def is_enabled(self) -> bool:
# SAML is enabled if either metadata source is configured AND
# SP cert + key paths exist.
sp_cert = os.getenv("SAML_SP_CERT_PATH", "")
sp_key = os.getenv("SAML_SP_PRIVATE_KEY_PATH", "")
return bool(
(self.idp_metadata_url or self.idp_metadata_path)
and sp_cert
and sp_key
)
def authenticate_credentials(self, username: str, password: str) -> ExternalIdentity:
raise NotImplementedError("SAML is redirect-based; use the /auth/saml/* router.")
def parse_attributes(self, attributes: dict, name_id: str) -> ExternalIdentity:
"""
Called by the router after python3-saml has validated the response
(signature, audience, replay) — we just map attributes → identity.
"""
def _first(attr_name: str, fallback: str = "") -> str:
val = attributes.get(attr_name)
if isinstance(val, list):
return str(val[0]) if val else fallback
return str(val) if val else fallback
username = _first(self.attr_username, fallback=name_id)
email = _first(self.attr_email, fallback=name_id if "@" in name_id else f"{name_id}@saml.local")
display = _first(self.attr_display, fallback=username)
groups = _split_groups(attributes.get(self.attr_groups))
return ExternalIdentity(
provider=AuthProvider.SAML,
external_id=name_id, # IdP-issued, stable
username=username,
email=email,
display_name=display,
groups=groups,
)
+16
View File
@@ -30,6 +30,18 @@ from slowapi.errors import RateLimitExceeded
from app.routers import auth, vulnerabilities, assets, policies, scans, settings, notifications, groups, reports, audit
try:
from app.routers import auth_oidc
_HAS_OIDC = True
except Exception as _e: # authlib missing or misconfigured
auth_oidc = None # type: ignore
_HAS_OIDC = False
try:
from app.routers import auth_saml
_HAS_SAML = True
except Exception:
auth_saml = None # type: ignore
_HAS_SAML = False
from app.database import engine, SessionLocal
from app.models.base import Base
from app.models.user import User, UserRole
@@ -268,6 +280,10 @@ async def root(request: Request):
# ============================================
app.include_router(auth.router)
if _HAS_OIDC and auth_oidc is not None:
app.include_router(auth_oidc.router)
if _HAS_SAML and auth_saml is not None:
app.include_router(auth_saml.router)
app.include_router(vulnerabilities.router)
app.include_router(assets.router)
app.include_router(policies.router)
+322
View File
@@ -0,0 +1,322 @@
"""
OIDC (OAuth 2.0 / OpenID Connect) routes.
Flow (Authorization Code + PKCE):
1. GET /auth/oidc/login → redirect to IdP authorize endpoint with PKCE challenge
2. GET /auth/oidc/callback → IdP redirects here; we exchange code for tokens,
Authlib validates id_token (signature/iss/aud/exp/nonce),
we fetch userinfo, then hand off to AuthOrchestrator
for JIT / role-mapping / cookie issuance.
State + PKCE verifier + nonce are persisted in a signed itsdangerous cookie
(httpOnly, short-lived). No server-side session store needed.
"""
from __future__ import annotations
import json
import logging
import os
import secrets
from datetime import datetime
from typing import Optional
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import JSONResponse, RedirectResponse
from itsdangerous import BadSignature, URLSafeTimedSerializer
from sqlalchemy.orm import Session
from app.auth.jwt_handler import (
JWT_ACCESS_TOKEN_EXPIRE_MINUTES,
JWT_REFRESH_TOKEN_EXPIRE_DAYS,
create_access_token,
create_refresh_token,
)
from app.auth.orchestrator import AuthOrchestrator
from app.auth.strategies.base import AuthError
from app.auth.strategies.oidc_strategy import OidcStrategy
from app.database import get_db
from app.models.audit_log import AuditEventType, AuditLog
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth/oidc", tags=["Authentication"])
OIDC_STATE_COOKIE = "oidc_state"
OIDC_STATE_TTL = 600 # 10 min — must be > user's redirect time at IdP
COOKIE_SAMESITE = os.getenv("AUTH_COOKIE_SAMESITE", "lax")
COOKIE_SECURE = os.getenv("AUTH_COOKIE_SECURE", "true").lower() == "true"
def _serializer() -> URLSafeTimedSerializer:
key = os.getenv("JWT_SECRET_KEY")
if not key:
raise RuntimeError("JWT_SECRET_KEY required for OIDC state signing")
return URLSafeTimedSerializer(secret_key=key, salt="oidc-state")
def _generate_pkce() -> tuple[str, str]:
"""Returns (code_verifier, code_challenge) for PKCE S256."""
import base64
import hashlib
verifier = secrets.token_urlsafe(64)[:128]
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()
return verifier, challenge
_CACHED_DISCOVERY: dict = {}
def _fetch_discovery(strategy: OidcStrategy) -> dict:
"""Cache OIDC discovery doc in-process. Refreshed on uvicorn restart."""
if "doc" in _CACHED_DISCOVERY:
return _CACHED_DISCOVERY["doc"]
with httpx.Client(timeout=15) as client:
r = client.get(strategy.discovery_url)
r.raise_for_status()
doc = r.json()
required = {"authorization_endpoint", "token_endpoint", "issuer", "jwks_uri"}
if not required.issubset(doc):
raise HTTPException(500, "OIDC discovery document is incomplete")
_CACHED_DISCOVERY["doc"] = doc
return doc
_CACHED_JWKS: dict = {}
def _fetch_jwks(jwks_uri: str) -> dict:
"""Cache JWKS for the lifetime of the process. Auto-refetched if key not found."""
if jwks_uri in _CACHED_JWKS:
return _CACHED_JWKS[jwks_uri]
with httpx.Client(timeout=15) as client:
r = client.get(jwks_uri)
r.raise_for_status()
jwks = r.json()
_CACHED_JWKS[jwks_uri] = jwks
return jwks
def _validate_id_token(id_token: str, strategy: OidcStrategy, expected_nonce: str) -> dict:
"""
Strict ID-Token validation per OIDC core §3.1.3.7.
Validates: signature (via JWKS), issuer, audience, expiration, nbf, nonce.
"""
from authlib.jose import JsonWebToken, JsonWebKey
from authlib.jose.errors import JoseError
discovery = _fetch_discovery(strategy)
jwks_data = _fetch_jwks(discovery["jwks_uri"])
key = JsonWebKey.import_key_set(jwks_data)
try:
claims = JsonWebToken(["RS256", "RS384", "RS512", "ES256", "ES384"]).decode(
id_token,
key=key,
claims_options={
"iss": {"essential": True, "value": discovery["issuer"]},
"aud": {"essential": True, "value": strategy.client_id},
"exp": {"essential": True},
},
)
claims.validate()
except JoseError as e:
# Refresh JWKS once and retry (key rotation)
_CACHED_JWKS.pop(discovery["jwks_uri"], None)
jwks_data = _fetch_jwks(discovery["jwks_uri"])
key = JsonWebKey.import_key_set(jwks_data)
try:
claims = JsonWebToken(["RS256", "RS384", "RS512", "ES256", "ES384"]).decode(
id_token,
key=key,
claims_options={
"iss": {"essential": True, "value": discovery["issuer"]},
"aud": {"essential": True, "value": strategy.client_id},
"exp": {"essential": True},
},
)
claims.validate()
except JoseError as e2:
raise HTTPException(401, f"ID token validation failed: {e2}") from e2
# Nonce check: prevents replay
if claims.get("nonce") != expected_nonce:
raise HTTPException(401, "ID token nonce mismatch (possible replay)")
return dict(claims)
# ============================================
# Endpoints
# ============================================
@router.get("/login")
async def oidc_login(request: Request):
"""
SP-initiated login. Stash state+nonce+PKCE verifier in a signed cookie,
then 302 to the IdP's authorize endpoint.
"""
strategy = OidcStrategy()
if not strategy.is_enabled:
raise HTTPException(404, "OIDC is not configured")
discovery = _fetch_discovery(strategy)
state = secrets.token_urlsafe(32)
nonce = secrets.token_urlsafe(32)
code_verifier, code_challenge = _generate_pkce()
params = {
"response_type": "code",
"client_id": strategy.client_id,
"redirect_uri": strategy.redirect_uri,
"scope": strategy.scopes,
"state": state,
"nonce": nonce,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
# Force account picker — avoids silently using last-used IdP session.
"prompt": "select_account",
}
authorize_url = discovery["authorization_endpoint"] + "?" + urlencode(params)
response = RedirectResponse(authorize_url, status_code=302)
signed = _serializer().dumps({
"state": state,
"nonce": nonce,
"code_verifier": code_verifier,
})
response.set_cookie(
OIDC_STATE_COOKIE,
signed,
max_age=OIDC_STATE_TTL,
httponly=True,
secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE,
path="/",
)
return response
@router.get("/callback")
async def oidc_callback(
request: Request,
code: Optional[str] = None,
state: Optional[str] = None,
error: Optional[str] = None,
error_description: Optional[str] = None,
db: Session = Depends(get_db),
):
"""IdP redirects here with ?code & ?state. We validate, exchange, validate id_token, then issue our own JWT session cookies."""
strategy = OidcStrategy()
if not strategy.is_enabled:
raise HTTPException(404, "OIDC is not configured")
if error:
logger.warning("OIDC error from IdP: %s%s", error, error_description)
raise HTTPException(401, f"IdP returned error: {error}")
if not code or not state:
raise HTTPException(400, "Missing code/state")
# 1) Recover state from signed cookie + verify
cookie = request.cookies.get(OIDC_STATE_COOKIE)
if not cookie:
raise HTTPException(400, "OIDC state cookie missing or expired")
try:
stash = _serializer().loads(cookie, max_age=OIDC_STATE_TTL)
except BadSignature:
raise HTTPException(400, "OIDC state signature invalid")
if state != stash.get("state"):
raise HTTPException(400, "OIDC state mismatch (possible CSRF)")
code_verifier = stash["code_verifier"]
expected_nonce = stash["nonce"]
# 2) Token exchange
discovery = _fetch_discovery(strategy)
token_resp = None
async with httpx.AsyncClient(timeout=20) as client:
token_resp = await client.post(
discovery["token_endpoint"],
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": strategy.redirect_uri,
"client_id": strategy.client_id,
"client_secret": strategy.client_secret,
"code_verifier": code_verifier,
},
headers={"Accept": "application/json"},
)
if token_resp.status_code != 200:
logger.warning("OIDC token endpoint returned %s: %s", token_resp.status_code, token_resp.text[:300])
raise HTTPException(401, "Token exchange failed")
token_data = token_resp.json()
id_token = token_data.get("id_token")
access_token_oidc = token_data.get("access_token")
if not id_token:
raise HTTPException(401, "Token response missing id_token")
# 3) Strict id_token validation (sig + iss + aud + exp + nonce)
id_claims = _validate_id_token(id_token, strategy, expected_nonce)
# 4) Userinfo (optional but recommended for groups)
userinfo: dict = {}
if discovery.get("userinfo_endpoint") and access_token_oidc:
try:
async with httpx.AsyncClient(timeout=15) as client:
u = await client.get(
discovery["userinfo_endpoint"],
headers={"Authorization": f"Bearer {access_token_oidc}"},
)
if u.status_code == 200:
userinfo = u.json()
except httpx.HTTPError as e:
logger.warning("OIDC userinfo fetch failed: %s", e)
# 5) Parse to ExternalIdentity → orchestrator → JIT
identity = strategy.parse_userinfo(userinfo, id_claims)
orchestrator = AuthOrchestrator(db)
try:
result = orchestrator.complete_sso_login(
identity,
actor_ip=request.client.host if request.client else None,
actor_user_agent=request.headers.get("user-agent"),
)
except AuthError as e:
db.add(
AuditLog(
event_type=AuditEventType.AUTH_PROVIDER_FAILED,
event_description=f"OIDC orchestrator rejected user: {e.detail}",
ip_address=request.client.host if request.client else None,
timestamp=datetime.now(),
)
)
db.commit()
raise HTTPException(status_code=401, detail=e.safe_message)
# 6) Issue our own session cookies + redirect to app root
user = result.user
if not user.is_active:
raise HTTPException(status_code=403, detail="Account is deactivated")
token_payload = {"sub": user.username, "role": user.role.value}
access_jwt = create_access_token(token_payload)
refresh_jwt = create_refresh_token(token_payload)
redirect_to = os.getenv("DASHBOARD_URL", "/").rstrip("/") + "/"
response = RedirectResponse(redirect_to, status_code=302)
response.set_cookie(
"access_token", access_jwt, httponly=True, secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE, max_age=JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60, path="/",
)
response.set_cookie(
"refresh_token", refresh_jwt, httponly=True, secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE, max_age=JWT_REFRESH_TOKEN_EXPIRE_DAYS * 86400, path="/",
)
# Drop the OIDC state cookie now that we're done
response.delete_cookie(OIDC_STATE_COOKIE, path="/")
return response
+225
View File
@@ -0,0 +1,225 @@
"""
SAML 2.0 routes.
Endpoints:
- GET /auth/saml/login — SP-initiated SSO (redirect to IdP)
- POST /auth/saml/acs — Assertion Consumer Service (IdP posts SAMLResponse here)
works for both SP-initiated (with RelayState) and IdP-initiated
- GET /auth/saml/metadata — SP metadata XML (for IdP configuration)
XSW (XML Signature Wrapping) mitigation is handled by python3-saml's
`strict` mode + xmlsec1: signature must be on the *correct* element
(Response or Assertion) and additional Assertion children are rejected.
"""
from __future__ import annotations
import logging
import os
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse, Response
from sqlalchemy.orm import Session
from app.auth.jwt_handler import (
JWT_ACCESS_TOKEN_EXPIRE_MINUTES,
JWT_REFRESH_TOKEN_EXPIRE_DAYS,
create_access_token,
create_refresh_token,
)
from app.auth.orchestrator import AuthOrchestrator
from app.auth.strategies.base import AuthError
from app.auth.strategies.saml_strategy import SamlStrategy
from app.database import get_db
from app.models.audit_log import AuditEventType, AuditLog
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth/saml", tags=["Authentication"])
COOKIE_SAMESITE = os.getenv("AUTH_COOKIE_SAMESITE", "lax")
COOKIE_SECURE = os.getenv("AUTH_COOKIE_SECURE", "true").lower() == "true"
def _saml_settings() -> dict:
"""
Build python3-saml settings dict. Loads IdP metadata at startup or
from local file. Strict mode is non-negotiable.
"""
sp_entity = os.getenv("SAML_SP_ENTITY_ID", "").strip()
sp_acs = os.getenv("SAML_SP_ACS_URL", "").strip()
sp_slo = os.getenv("SAML_SP_SLO_URL", "").strip()
sp_cert_path = os.getenv("SAML_SP_CERT_PATH", "").strip()
sp_key_path = os.getenv("SAML_SP_PRIVATE_KEY_PATH", "").strip()
idp_metadata_url = os.getenv("SAML_IDP_METADATA_URL", "").strip()
idp_metadata_path = os.getenv("SAML_IDP_METADATA_PATH", "").strip()
if not (sp_entity and sp_acs and sp_cert_path and sp_key_path):
raise HTTPException(404, "SAML SP is not fully configured")
def _read(path: str) -> str:
with open(path, "r") as f:
return f.read().strip()
sp_cert = _read(sp_cert_path) if sp_cert_path else ""
sp_key = _read(sp_key_path) if sp_key_path else ""
# Load IdP descriptor
from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser
if idp_metadata_url:
idp_data = OneLogin_Saml2_IdPMetadataParser.parse_remote(idp_metadata_url, timeout=15)
elif idp_metadata_path:
idp_data = OneLogin_Saml2_IdPMetadataParser.parse(_read(idp_metadata_path))
else:
raise HTTPException(500, "No IdP metadata source configured")
return {
"strict": True, # XSW + signature/audience/replay enforcement
"debug": False,
"sp": {
"entityId": sp_entity,
"assertionConsumerService": {
"url": sp_acs,
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
},
"singleLogoutService": {
"url": sp_slo or sp_acs,
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
},
"NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
"x509cert": sp_cert,
"privateKey": sp_key,
},
"idp": idp_data.get("idp", {}),
"security": {
"authnRequestsSigned": True,
"wantMessagesSigned": True,
"wantAssertionsSigned": True,
"signMetadata": True,
"wantAssertionsEncrypted": False,
"wantNameId": True,
"wantNameIdEncrypted": False,
"requestedAuthnContext": False,
"signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
"digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256",
},
}
def _prepare_request(request: Request, form: dict) -> dict:
"""Map FastAPI Request → python3-saml's expected dict."""
return {
"https": "on" if request.url.scheme == "https" else "off",
"http_host": request.url.hostname or "",
"server_port": str(request.url.port or (443 if request.url.scheme == "https" else 80)),
"script_name": request.url.path,
"get_data": dict(request.query_params),
"post_data": form,
}
@router.get("/login")
async def saml_login(request: Request):
"""SP-initiated: build AuthnRequest and redirect to IdP."""
from onelogin.saml2.auth import OneLogin_Saml2_Auth
settings = _saml_settings()
req = _prepare_request(request, {})
auth = OneLogin_Saml2_Auth(req, settings)
redirect_url = auth.login(return_to="/", set_nameid_policy=True)
return RedirectResponse(redirect_url, status_code=302)
@router.post("/acs")
async def saml_acs(request: Request, db: Session = Depends(get_db)):
"""
Assertion Consumer Service. python3-saml validates:
- Signature (on Response, Assertion, or both — per `wantAssertionsSigned`)
- InResponseTo (when SP-initiated)
- Conditions (NotBefore / NotOnOrAfter, AudienceRestriction)
- Recipient (= our ACS URL)
- Issuer
- XSW (strict mode rejects unsigned/duplicated assertion siblings)
"""
from onelogin.saml2.auth import OneLogin_Saml2_Auth
settings = _saml_settings()
form = await request.form()
form_dict = {k: v for k, v in form.items()}
req = _prepare_request(request, form_dict)
auth = OneLogin_Saml2_Auth(req, settings)
auth.process_response()
errors = auth.get_errors()
if errors:
reason = auth.get_last_error_reason() or "validation failed"
logger.warning("SAML response rejected: %s (%s)", errors, reason)
db.add(AuditLog(
event_type=AuditEventType.AUTH_PROVIDER_FAILED,
event_description=f"SAML rejected: {','.join(errors)}{reason}",
ip_address=request.client.host if request.client else None,
timestamp=datetime.now(),
))
db.commit()
raise HTTPException(401, "SAML response invalid")
if not auth.is_authenticated():
raise HTTPException(401, "SAML response not authenticated")
# Extract identity
strategy = SamlStrategy()
identity = strategy.parse_attributes(
attributes=auth.get_attributes() or {},
name_id=auth.get_nameid() or "",
)
# Hand off to orchestrator (JIT + role mapping + audit)
orchestrator = AuthOrchestrator(db)
try:
result = orchestrator.complete_sso_login(
identity,
actor_ip=request.client.host if request.client else None,
actor_user_agent=request.headers.get("user-agent"),
)
except AuthError as e:
raise HTTPException(401, e.safe_message)
user = result.user
if not user.is_active:
raise HTTPException(403, "Account is deactivated")
token_payload = {"sub": user.username, "role": user.role.value}
access_jwt = create_access_token(token_payload)
refresh_jwt = create_refresh_token(token_payload)
relay_state = form_dict.get("RelayState") or "/"
# Defend against open redirect: only allow same-origin paths.
if relay_state.startswith("//") or "://" in relay_state:
relay_state = "/"
response = RedirectResponse(relay_state, status_code=302)
response.set_cookie(
"access_token", access_jwt, httponly=True, secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE, max_age=JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60, path="/",
)
response.set_cookie(
"refresh_token", refresh_jwt, httponly=True, secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE, max_age=JWT_REFRESH_TOKEN_EXPIRE_DAYS * 86400, path="/",
)
return response
@router.get("/metadata")
async def saml_metadata():
"""Returns SP metadata XML for the IdP administrator."""
from onelogin.saml2.settings import OneLogin_Saml2_Settings
settings_dict = _saml_settings()
settings = OneLogin_Saml2_Settings(settings_dict, sp_validation_only=True)
metadata = settings.get_sp_metadata()
errors = settings.validate_metadata(metadata)
if errors:
raise HTTPException(500, f"SP metadata invalid: {errors}")
return Response(content=metadata, media_type="application/xml")
+209 -61
View File
@@ -1,38 +1,90 @@
"use client";
import { useState } from 'react';
import { useEffect, useState } from 'react';
import api from '../../lib/api';
type AuthProvider = {
name: 'local' | 'ldap' | 'oidc' | 'saml';
credentials: boolean; // shows username/password form
redirect: boolean; // shows redirect button
display_name: string;
login_url?: string | null;
};
type Stage = 'credentials' | 'mfa';
export default function LoginPage() {
const [providers, setProviders] = useState<AuthProvider[]>([]);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [mfaCode, setMfaCode] = useState('');
const [mfaToken, setMfaToken] = useState('');
const [stage, setStage] = useState<Stage>('credentials');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
// Fetch enabled providers so we render the right buttons.
useEffect(() => {
api.get('/auth/providers')
.then(res => setProviders(res.data?.providers || []))
.catch(() => setProviders([{ name: 'local', credentials: true, redirect: false, display_name: 'Local account' }]));
}, []);
const credentialProviders = providers.filter(p => p.credentials);
const redirectProviders = providers.filter(p => p.redirect && p.login_url);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const response = await api.post('/auth/login', { username, password });
if (response.data.access_token) {
window.location.href = '/';
} else {
setError('Login failed: No access token received.');
// Backend signals MFA needed — switch to second-factor stage.
if (response.data?.mfa_required) {
setMfaToken(response.data.mfa_token);
setStage('mfa');
setLoading(false);
return;
}
if (response.data?.access_token) {
window.location.href = '/';
return;
}
setError('Login failed: no access token received.');
} catch (err: any) {
const status = err.response?.status;
const detail = err.response?.data?.detail;
if (status === 403) setError(detail || 'Account locked or deactivated.');
else if (status === 429) setError('Too many login attempts. Please wait a moment.');
else if (status === 401) setError('Invalid username or password.');
else setError(detail || 'Login failed. Please try again.');
} finally {
setLoading(false);
}
};
if (status === 403) {
setError(detail || 'Account locked or deactivated.');
} else if (status === 429) {
setError('Too many login attempts. Please wait a moment.');
} else if (status === 401) {
setError('Invalid username or password.');
} else {
setError(detail || 'Login failed. Please try again.');
const handleMfaVerify = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const response = await api.post('/auth/mfa/verify', {
mfa_token: mfaToken,
code: mfaCode,
});
if (response.data?.access_token) {
window.location.href = '/';
return;
}
setError('MFA verification failed.');
} catch (err: any) {
const detail = err.response?.data?.detail;
setError(detail || 'Invalid or expired MFA code.');
} finally {
setLoading(false);
}
};
@@ -43,60 +95,156 @@ export default function LoginPage() {
<h2 className="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900 font-mono">
VulnCheck
</h2>
<p className="text-center text-sm text-gray-500 font-mono mt-2">Sign in to your account</p>
<p className="text-center text-sm text-gray-500 font-mono mt-2">
{stage === 'credentials' ? 'Sign in to your account' : 'Enter your verification code'}
</p>
</div>
<div className="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
<form className="space-y-6" onSubmit={handleLogin}>
<div>
<label htmlFor="username" className="block text-sm font-medium leading-6 text-gray-900 font-mono">
Username
</label>
<div className="mt-2">
<input
id="username"
name="username"
type="text"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue sm:text-sm sm:leading-6"
/>
</div>
</div>
{stage === 'credentials' && (
<>
{/* SSO redirect buttons (OIDC / SAML) */}
{redirectProviders.length > 0 && (
<div className="space-y-2 mb-6">
{redirectProviders.map(p => (
<a
key={p.name}
href={p.login_url || '#'}
className="flex w-full items-center justify-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-semibold text-gray-700 shadow-sm hover:bg-gray-50 font-mono"
>
<span>Sign in with {p.display_name}</span>
</a>
))}
{credentialProviders.length > 0 && (
<div className="relative my-4">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-xs">
<span className="bg-gray-50 px-2 text-gray-500 font-mono uppercase">or</span>
</div>
</div>
)}
</div>
)}
<div>
<label htmlFor="password" className="block text-sm font-medium leading-6 text-gray-900 font-mono">
Password
</label>
<div className="mt-2">
<input
id="password"
name="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue sm:text-sm sm:leading-6"
/>
</div>
</div>
{/* Local / LDAP credentials form */}
{credentialProviders.length > 0 && (
<form className="space-y-6" onSubmit={handleLogin}>
<div>
<label htmlFor="username" className="block text-sm font-medium leading-6 text-gray-900 font-mono">
Username
</label>
<div className="mt-2">
<input
id="username"
name="username"
type="text"
autoComplete="username"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue sm:text-sm sm:leading-6"
/>
</div>
</div>
{error && (
<div className="text-red-600 text-sm font-mono text-center bg-red-50 p-2 rounded">
{error}
</div>
)}
<div>
<label htmlFor="password" className="block text-sm font-medium leading-6 text-gray-900 font-mono">
Password
</label>
<div className="mt-2">
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<button
type="submit"
className="flex w-full justify-center rounded-md bg-vulncheck-blue px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-blue-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 font-mono"
>
Sign in
</button>
</div>
</form>
{error && (
<div className="text-red-600 text-sm font-mono text-center bg-red-50 p-2 rounded">
{error}
</div>
)}
<div>
<button
type="submit"
disabled={loading}
className="flex w-full justify-center rounded-md bg-vulncheck-blue px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-blue-600 disabled:opacity-60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 font-mono"
>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</div>
{credentialProviders.length > 1 && (
<p className="text-center text-[11px] text-gray-400 font-mono">
Tries {credentialProviders.map(p => p.display_name).join(' → ')}
</p>
)}
</form>
)}
</>
)}
{stage === 'mfa' && (
<form className="space-y-6" onSubmit={handleMfaVerify}>
<div>
<label htmlFor="mfa" className="block text-sm font-medium leading-6 text-gray-900 font-mono">
Authenticator code
</label>
<p className="text-xs text-gray-500 font-mono mt-1">
Open your authenticator app and enter the 6-digit code.
</p>
<div className="mt-2">
<input
id="mfa"
name="mfa"
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
autoComplete="one-time-code"
autoFocus
required
value={mfaCode}
onChange={(e) => setMfaCode(e.target.value.replace(/\D/g, ''))}
className="block w-full rounded-md border-0 py-2 text-center text-xl tracking-[0.5em] font-mono text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue"
placeholder="000000"
/>
</div>
</div>
{error && (
<div className="text-red-600 text-sm font-mono text-center bg-red-50 p-2 rounded">
{error}
</div>
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => { setStage('credentials'); setMfaCode(''); setMfaToken(''); setError(''); }}
className="flex-1 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-semibold text-gray-700 hover:bg-gray-50 font-mono"
>
Back
</button>
<button
type="submit"
disabled={loading || mfaCode.length !== 6}
className="flex-[2] rounded-md bg-vulncheck-blue px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 disabled:opacity-60 font-mono"
>
{loading ? 'Verifying…' : 'Verify'}
</button>
</div>
</form>
)}
</div>
</div>
);