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:
+109
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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")},
|
||||
)
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user