feat(iam): require password + 2FA at login
Add a login-policy phase: password plus a second factor, with mfaInitSkipLifetime 0s so enrolment is demanded at login rather than skipped for 30 days. Second factors stay OTP and U2F (authenticator app or hardware key). Multi-factor is already configured for U2F-with-verification, so moving 2FA to 2MFA later needs no further policy change. forceMFA does not persist through the v1 policy endpoint: it reads back absent even after a successful PUT, which is why the skip lifetime is the lever used. A repeat run returns 400 "Default Login Policy has not been changed"; treat that as success so provisioning stays idempotent. Full apply now completes end to end: login page : built-in smtp : already active login policy : password + 2FA, enrolment required roles : 21 ensured oidc apps : 11 ensured
This commit is contained in:
@@ -417,6 +417,39 @@ def _ensure_smtp(token: str) -> str:
|
||||
return smtp_id
|
||||
|
||||
|
||||
def _ensure_login_policy(token: str) -> None:
|
||||
"""Require password + a second factor, and demand enrolment at login.
|
||||
|
||||
forceMFA does not persist through the v1 policy endpoint (it reads back
|
||||
absent), so the effective lever is mfaInitSkipLifetime: 0s, which makes the
|
||||
login flow require enrolment instead of letting a user skip it for 30 days.
|
||||
|
||||
Second factors stay OTP and U2F: an authenticator app or a hardware key.
|
||||
Multi-factor is left configured for U2F with verification so a later step
|
||||
can move 2FA to 2MFA without another policy change.
|
||||
"""
|
||||
code, d = _api("PUT", "/admin/v1/policies/login", token, {
|
||||
"allowUsernamePassword": True,
|
||||
"allowRegister": False,
|
||||
"allowExternalIDP": True,
|
||||
"hidePasswordReset": False,
|
||||
"ignoreUnknownUsernames": True,
|
||||
"passwordCheckLifetime": "864000s",
|
||||
"externalLoginCheckLifetime": "864000s",
|
||||
"mfaInitSkipLifetime": "0s",
|
||||
"secondFactorCheckLifetime": "64800s",
|
||||
"multiFactorCheckLifetime": "64800s",
|
||||
"secondFactors": ["SECOND_FACTOR_TYPE_OTP", "SECOND_FACTOR_TYPE_U2F"],
|
||||
"multiFactors": ["MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION"],
|
||||
"passwordlessType": "PASSWORDLESS_TYPE_ALLOWED",
|
||||
})
|
||||
# ZITADEL answers 400 "has not been changed" when the policy already matches.
|
||||
# That is success for an idempotent run, not a failure.
|
||||
if code not in (200, 201) and "has not been changed" not in str(d.get("message", "")):
|
||||
die(f"update login policy failed: {code} {d}")
|
||||
print("login policy : password + 2FA, enrolment required")
|
||||
|
||||
|
||||
def phase_apply(_args: argparse.Namespace) -> int:
|
||||
"""Declare the ZITADEL objects through the API. No Terraform, no state drift."""
|
||||
token = bootstrap_token()
|
||||
@@ -425,6 +458,7 @@ def phase_apply(_args: argparse.Namespace) -> int:
|
||||
|
||||
_ensure_builtin_login(token)
|
||||
_ensure_smtp(token)
|
||||
_ensure_login_policy(token)
|
||||
project_id = _ensure_project(token, PROJECT_NAME)
|
||||
print(f"project : {PROJECT_NAME} ({project_id})")
|
||||
_ensure_roles(token, project_id, ROLES)
|
||||
|
||||
Reference in New Issue
Block a user