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:
2026-10-27 00:12:32 -07:00
parent 80af9b1fd0
commit 6768d1c762
+34
View File
@@ -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)