From d76b9f914728317b4716ae43f25b91afaf1fe371 Mon Sep 17 00:00:00 2001 From: Alexander Amiri Date: Fri, 13 Mar 2026 23:14:13 +0100 Subject: [PATCH] Add self-service password-set flow for new hero accounts (#56) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google Workspace 2FA enforcement blocks forgot-password for new accounts. New flow: team provisioner generates HMAC-signed link → hero clicks → sets password via Lambda Function URL → team provisioner updates Google Admin SDK. - New Lambda `javabin-password-set` with Function URL (GET/POST /set-password) - HMAC-SHA256 tokens with 48h expiry and single-use enforcement via DynamoDB - Welcome email updated with "Sett passord" button instead of forgot-password - `resend_password_link` action for expired links - `set_password` action delegated from password-set Lambda to team provisioner - Security Hub alerts: DynamoDB dedup retained, button changed to console link --- terraform/lambda-src/password_set/handler.py | 433 ++++++++++++++++++ terraform/lambda-src/slack_alert/handler.py | 4 +- .../lambda-src/team_provisioner/handler.py | 135 +++++- terraform/platform/lambdas/main.tf | 98 ++++ terraform/platform/lambdas/outputs.tf | 10 + 5 files changed, 667 insertions(+), 13 deletions(-) create mode 100644 terraform/lambda-src/password_set/handler.py diff --git a/terraform/lambda-src/password_set/handler.py b/terraform/lambda-src/password_set/handler.py new file mode 100644 index 0000000..a8de633 --- /dev/null +++ b/terraform/lambda-src/password_set/handler.py @@ -0,0 +1,433 @@ +"""Self-service password-set flow for new hero Google Workspace accounts. + +Routes (via Lambda Function URL): +- GET /set-password?token=... → Serve password-set HTML form +- POST /set-password → Set password via team-provisioner Lambda invoke + +Password-set tokens are HMAC-SHA256 signed with single-use enforcement via DynamoDB. +""" + +import base64 +import hashlib +import hmac +import json +import logging +import os +import time +import urllib.parse +import uuid + +import boto3 + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +ssm = boto3.client("ssm") +dynamodb = boto3.resource("dynamodb") +lambda_client = boto3.client("lambda") + +SIGNING_KEY_PARAM = os.environ.get( + "SIGNING_KEY_PARAM", "/javabin/platform/password-token-signing-key" +) +DEDUP_TABLE_NAME = os.environ.get("DEDUP_TABLE_NAME", "javabin-alert-dedup") +TEAM_PROVISIONER_FUNCTION = os.environ.get( + "TEAM_PROVISIONER_FUNCTION", "javabin-team-provisioner" +) +PASSWORD_TOKEN_TTL = 48 * 3600 # 48 hours + +_secret_cache = {} + + +def _get_secret(param_name): + if param_name not in _secret_cache: + resp = ssm.get_parameter(Name=param_name, WithDecryption=True) + _secret_cache[param_name] = resp["Parameter"]["Value"] + return _secret_cache[param_name] + + +def _dedup_table(): + return dynamodb.Table(DEDUP_TABLE_NAME) + + +# --------------------------------------------------------------------------- +# Password-set token helpers +# --------------------------------------------------------------------------- +def _b64url_encode(data): + if isinstance(data, str): + data = data.encode("utf-8") + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _b64url_decode(s): + padding = 4 - len(s) % 4 + if padding != 4: + s += "=" * padding + return base64.urlsafe_b64decode(s) + + +def generate_password_token(email, signing_key): + """Generate an HMAC-signed password-set token. + + Token format: base64url(json_payload).hmac_hex + Payload: {"email": "...", "exp": unix_ts, "jti": "uuid"} + """ + payload = json.dumps({ + "email": email, + "exp": int(time.time()) + PASSWORD_TOKEN_TTL, + "jti": str(uuid.uuid4()), + }) + payload_b64 = _b64url_encode(payload) + sig = hmac.new( + signing_key.encode("utf-8"), payload_b64.encode("utf-8"), hashlib.sha256 + ).hexdigest() + return f"{payload_b64}.{sig}" + + +def _validate_token(token): + """Validate a password-set token. Returns (payload_dict, error_string).""" + signing_key = _get_secret(SIGNING_KEY_PARAM) + + parts = token.split(".") + if len(parts) != 2: + return None, "Invalid token format" + + payload_b64, sig = parts + + # Verify signature + expected_sig = hmac.new( + signing_key.encode("utf-8"), payload_b64.encode("utf-8"), hashlib.sha256 + ).hexdigest() + if not hmac.compare_digest(expected_sig, sig): + return None, "Invalid token signature" + + # Decode payload + try: + payload = json.loads(_b64url_decode(payload_b64)) + except Exception: + return None, "Invalid token payload" + + # Check expiry + if time.time() > payload.get("exp", 0): + return None, "Token has expired" + + # Check single-use via DynamoDB + jti = payload.get("jti", "") + dedup_key = f"pwset:{jti}" + try: + resp = _dedup_table().get_item(Key={"finding_key": dedup_key}) + if "Item" in resp: + return None, "This link has already been used" + except Exception as e: + logger.warning("DynamoDB single-use check failed: %s", e) + + return payload, None + + +def _mark_token_used(jti): + """Mark a password-set token as used in DynamoDB.""" + try: + _dedup_table().put_item(Item={ + "finding_key": f"pwset:{jti}", + "used_at": int(time.time()), + "expires_at": int(time.time()) + (30 * 86400), + }) + except Exception as e: + logger.warning("Failed to mark token used: %s", e) + + +# --------------------------------------------------------------------------- +# Password-set form HTML +# --------------------------------------------------------------------------- +_PASSWORD_FORM_HTML = """\ + + + + + +Sett passord — javaBin + + + +
+
+ javaBin +
+
+

Sett passord

+ +
+ + + + + +
+ ✗ Minst 12 tegn + ✗ Stor bokstav + ✗ Liten bokstav + ✗ Tall +
+
+ +
+
+
javaBin — java.no · javazone.no
+
+ + +""" + +_PASSWORD_SUCCESS_HTML = """\ + + + + + +Passord satt — javaBin + + + +
+
+ javaBin +
+
+

✓ Passord satt!

+

Passordet ditt er oppdatert. Du kan nå logge inn med din javaBin-konto.

+ Logg inn +
+
javaBin — java.no · javazone.no
+
+ +""" + +_PASSWORD_ERROR_HTML = """\ + + + + + +Feil — javaBin + + + +
+
+ javaBin +
+
+

✗ {{TITLE}}

+

{{MESSAGE}}

+
+
javaBin — java.no · javazone.no
+
+ +""" + + +# --------------------------------------------------------------------------- +# Password-set handlers +# --------------------------------------------------------------------------- +def _handle_get_password_form(query_params): + """Serve the password-set HTML form after validating the token.""" + token = query_params.get("token", "") + if not token: + return _html_error(400, "Ugyldig lenke", "Ingen token funnet i lenken.") + + payload, error = _validate_token(token) + if error: + return _html_error(400, "Lenken er ugyldig", error) + + email = payload.get("email", "") + html = _PASSWORD_FORM_HTML.replace("{{EMAIL}}", email).replace("{{TOKEN}}", token) + return _html_response(200, html) + + +def _handle_post_password(body): + """Process password-set form submission.""" + parsed = urllib.parse.parse_qs(body) + token = parsed.get("token", [""])[0] + password = parsed.get("password", [""])[0] + password_confirm = parsed.get("password_confirm", [""])[0] + + if not token or not password: + return _html_error(400, "Mangler data", "Token og passord er p\u00e5krevd.") + + if password != password_confirm: + return _html_error( + 400, "Passordene er ikke like", "Vennligst g\u00e5 tilbake og pr\u00f8v igjen." + ) + + if ( + len(password) < 12 + or not any(c.isupper() for c in password) + or not any(c.islower() for c in password) + or not any(c.isdigit() for c in password) + ): + return _html_error( + 400, + "Passordet er for svakt", + "Minst 12 tegn, stor bokstav, liten bokstav og tall.", + ) + + payload, error = _validate_token(token) + if error: + return _html_error(400, "Lenken er ugyldig", error) + + email = payload.get("email", "") + jti = payload.get("jti", "") + + # Set password via team-provisioner Lambda + try: + resp = lambda_client.invoke( + FunctionName=TEAM_PROVISIONER_FUNCTION, + InvocationType="RequestResponse", + Payload=json.dumps({ + "action": "set_password", + "email": email, + "password": password, + }), + ) + result = json.loads(resp["Payload"].read()) + if result.get("statusCode") != 200: + raise RuntimeError(result.get("body", "Unknown error")) + except Exception as e: + logger.error("Password set failed for %s: %s", email, e) + return _html_error( + 500, + "Noe gikk galt", + "Kunne ikke sette passord. Kontakt en administrator.", + ) + + _mark_token_used(jti) + logger.info("Password set for %s via self-service link", email) + return _html_response(200, _PASSWORD_SUCCESS_HTML) + + +# --------------------------------------------------------------------------- +# Response helpers +# --------------------------------------------------------------------------- +def _json_response(status_code, body): + return { + "statusCode": status_code, + "headers": {"Content-Type": "application/json"}, + "body": json.dumps(body), + } + + +def _html_response(status_code, html): + return { + "statusCode": status_code, + "headers": {"Content-Type": "text/html; charset=utf-8"}, + "body": html, + } + + +def _html_error(status_code, title, message): + html = _PASSWORD_ERROR_HTML.replace("{{TITLE}}", title).replace( + "{{MESSAGE}}", message + ) + return _html_response(status_code, html) + + +# --------------------------------------------------------------------------- +# Lambda entry point (Function URL) +# --------------------------------------------------------------------------- +def handler(event, context): + """Route by HTTP method + path.""" + http = event.get("requestContext", {}).get("http", {}) + method = http.get("method", "GET") + path = event.get("rawPath", "/") + query_params = event.get("queryStringParameters") or {} + + # Decode body + body = event.get("body", "") + if event.get("isBase64Encoded"): + body = base64.b64decode(body).decode("utf-8") + + # GET /set-password → password form + if method == "GET" and path == "/set-password": + return _handle_get_password_form(query_params) + + # POST /set-password → set password + if method == "POST" and path == "/set-password": + return _handle_post_password(body) + + return _json_response(404, {"error": "not found"}) diff --git a/terraform/lambda-src/slack_alert/handler.py b/terraform/lambda-src/slack_alert/handler.py index 7f80220..ad9e0ed 100644 --- a/terraform/lambda-src/slack_alert/handler.py +++ b/terraform/lambda-src/slack_alert/handler.py @@ -878,7 +878,7 @@ def format_securityhub_finding(parsed): "text": {"type": "mrkdwn", "text": f"_{description[:500]}_"} }) - # Link button to view & suppress in Security Hub console + # Link to view & suppress in Security Hub console encoded_id = finding_id.replace("/", "%2F").replace(":", "%3A") sh_finding_url = ( f"https://{region}.console.aws.amazon.com/securityhub/home" @@ -888,7 +888,7 @@ def format_securityhub_finding(parsed): "type": "actions", "elements": [{ "type": "button", - "text": {"type": "plain_text", "text": "View & Suppress in Security Hub", "emoji": True}, + "text": {"type": "plain_text", "text": "View in Security Hub", "emoji": True}, "url": sh_finding_url, }], }) diff --git a/terraform/lambda-src/team_provisioner/handler.py b/terraform/lambda-src/team_provisioner/handler.py index 4908b0c..5a30c13 100644 --- a/terraform/lambda-src/team_provisioner/handler.py +++ b/terraform/lambda-src/team_provisioner/handler.py @@ -57,6 +57,12 @@ IDENTITY_STORE_ID = os.environ.get("IDENTITY_STORE_ID", "") SSO_INSTANCE_ARN = os.environ.get("SSO_INSTANCE_ARN", "") PROJECT = os.environ.get("PROJECT", "javabin") +SIGNING_KEY_PARAM = os.environ.get( + "SIGNING_KEY_PARAM", "/javabin/platform/password-token-signing-key" +) +PASSWORD_SET_URL_PARAM = os.environ.get( + "PASSWORD_SET_URL_PARAM", "/javabin/platform/password-set-function-url" +) sso_client = boto3.client("sso-admin") @@ -196,15 +202,39 @@ def _google_api(method, path, access_token, body=None): raise -def _send_welcome_email(access_token, javabin_email, personal_email, firstname): +def _send_welcome_email(access_token, javabin_email, personal_email, firstname, password_set_url=None): """Send a welcome email to the hero's personal address via Gmail API. - Uses domain-wide delegation to send as the admin email. The email instructs - the hero to set up their @java.no account via the forgot-password flow. + Uses domain-wide delegation to send as the admin email. If password_set_url + is provided, the email includes a "Set your password" button. Otherwise falls + back to forgot-password instructions. """ admin_email = _get_ssm_param(GOOGLE_ADMIN_EMAIL_PARAM) subject = "Velkommen som JavaBin-Helt!" + + if password_set_url: + password_section = f"""\ +

+ Klikk p\u00e5 knappen under for \u00e5 sette passordet ditt. Lenken er gyldig i 48 timer. +

+ + +
+ + Sett passord + +
""" + else: + password_section = f"""\ +

Slik setter du passord:

+
    +
  1. G\u00e5 til accounts.google.com
  2. +
  3. Skriv inn {javabin_email}
  4. +
  5. Trykk p\u00e5 \u00abGlemt passord\u00bb
  6. +
  7. Du f\u00e5r en lenke til {personal_email}
  8. +
""" + body_html = f"""\ @@ -230,13 +260,7 @@ def _send_welcome_email(access_token, javabin_email, personal_email, firstname):

{javabin_email}

-

Slik setter du passord:

-
    -
  1. G\u00e5 til accounts.google.com
  2. -
  3. Skriv inn {javabin_email}
  4. -
  5. Trykk p\u00e5 \u00abGlemt passord\u00bb
  6. -
  7. Du f\u00e5r en lenke til {personal_email}
  8. -
+{password_section}

N\u00e5r du er logget inn har du tilgang til javaBin sine tjenester, e-post og delte dokumenter.

@@ -1223,7 +1247,11 @@ def handle_sync_groups_and_heros(event): # Send welcome email to personal address via Gmail API if personal_email: try: - _send_welcome_email(access_token, email, personal_email, hero.get("firstname", "")) + pw_url = _generate_password_set_url(email) + _send_welcome_email( + access_token, email, personal_email, + hero.get("firstname", ""), password_set_url=pw_url, + ) except Exception as we: logger.warning("Could not send welcome email to %s: %s", personal_email, we) else: @@ -1384,6 +1412,87 @@ def handle_sync_groups_and_heros(event): } +def _generate_password_set_url(email): + """Generate a signed password-set URL for a new hero account. + + Uses the same HMAC-SHA256 token format as the password-set Lambda. + Reads the function URL from SSM (avoids circular TF dependency). + """ + import hashlib + import hmac + import uuid + + try: + base_url = _get_ssm_param(PASSWORD_SET_URL_PARAM).rstrip("/") + except Exception as e: + logger.warning("Could not read interactive function URL from SSM: %s", e) + return None + + signing_key = _get_ssm_param(SIGNING_KEY_PARAM) + payload = json.dumps({ + "email": email, + "exp": int(time.time()) + 48 * 3600, # 48 hours + "jti": str(uuid.uuid4()), + }) + payload_b64 = _b64url(payload) + sig = hmac.new( + signing_key.encode("utf-8"), payload_b64.encode("utf-8"), hashlib.sha256 + ).hexdigest() + token = f"{payload_b64}.{sig}" + + return f"{base_url}/set-password?token={token}" + + +def handle_resend_password_link(event): + """Handle the resend_password_link action — generate a new password-set link and email it. + + Use when the original 48h link has expired. Can be triggered by direct Lambda + invocation with: {"action": "resend_password_link", "email": "user@java.no", + "personal_email": "user@gmail.com", "firstname": "Name"} + """ + email = event.get("email", "") + personal_email = event.get("personal_email", "") + firstname = event.get("firstname", email.split("@")[0]) + + if not email or not personal_email: + return {"statusCode": 400, "body": "Missing email or personal_email"} + + access_token = _get_google_access_token() + pw_url = _generate_password_set_url(email) + if not pw_url: + return {"statusCode": 500, "body": "Could not generate password-set URL"} + + _send_welcome_email(access_token, email, personal_email, firstname, password_set_url=pw_url) + logger.info("Resent password-set link for %s to %s", email, personal_email) + return {"statusCode": 200, "body": f"Password-set link sent to {personal_email}"} + + +def handle_set_password(event): + """Handle the set_password action — set a user's Google Workspace password. + + Called by the password-set Lambda after validating the password-set token. + """ + email = event.get("email", "") + password = event.get("password", "") + + if not email or not password: + return {"statusCode": 400, "body": "Missing email or password"} + + access_token = _get_google_access_token() + user_key = urllib.parse.quote(email, safe="") + + result = _google_api("PATCH", f"/users/{user_key}", access_token, { + "password": password, + "changePasswordAtNextLogin": False, + }) + + if result is None: + return {"statusCode": 404, "body": f"User not found: {email}"} + + logger.info("Password set for %s via self-service flow", email) + return {"statusCode": 200, "body": f"Password set for {email}"} + + def handle_sync_groups(event): """Handle the sync_groups action — process groups.yaml entries.""" groups = event.get("groups", []) @@ -1659,6 +1768,10 @@ def handler(event, context): # Dispatch by action payload = _extract_payload(event) + if payload.get("action") == "set_password": + return handle_set_password(payload) + if payload.get("action") == "resend_password_link": + return handle_resend_password_link(payload) if payload.get("action") == "sync_groups_and_heros": return handle_sync_groups_and_heros(payload) if payload.get("action") == "sync_groups": diff --git a/terraform/platform/lambdas/main.tf b/terraform/platform/lambdas/main.tf index 716c61c..e1a0b4d 100644 --- a/terraform/platform/lambdas/main.tf +++ b/terraform/platform/lambdas/main.tf @@ -130,6 +130,17 @@ data "archive_file" "team_provisioner" { } } +data "archive_file" "password_set" { + type = "zip" + output_path = "${path.module}/builds/password_set.zip" + output_file_mode = "0644" + + source { + content = file("${local.lambda_src_path}/password_set/handler.py") + filename = "handler.py" + } +} + ################################################################################ # IAM Roles ################################################################################ @@ -520,6 +531,59 @@ resource "aws_iam_role_policy_attachment" "team_provisioner_logs" { policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" } +# --- password-set role --- +resource "aws_iam_role" "password_set" { + name = "${var.project}-password-set" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "lambda.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) +} + +resource "aws_iam_role_policy" "password_set" { + name = "${var.project}-password-set" + role = aws_iam_role.password_set.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "SSMRead" + Effect = "Allow" + Action = "ssm:GetParameter" + Resource = [ + "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/javabin/platform/*", + ] + }, + { + Sid = "DynamoDBDedup" + Effect = "Allow" + Action = [ + "dynamodb:GetItem", + "dynamodb:PutItem", + ] + Resource = var.alert_dedup_table_arn + }, + { + Sid = "InvokeTeamProvisioner" + Effect = "Allow" + Action = "lambda:InvokeFunction" + Resource = aws_lambda_function.team_provisioner.arn + }, + ] + }) +} + +resource "aws_iam_role_policy_attachment" "password_set_logs" { + role = aws_iam_role.password_set.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + ################################################################################ # Lambda Functions ################################################################################ @@ -643,10 +707,44 @@ resource "aws_lambda_function" "team_provisioner" { IDENTITY_STORE_ID = var.identity_store_id SSO_INSTANCE_ARN = var.sso_instance_arn PROJECT = var.project + SIGNING_KEY_PARAM = "/javabin/platform/password-token-signing-key" + PASSWORD_SET_URL_PARAM = "/javabin/platform/password-set-function-url" + } + } +} + +resource "aws_lambda_function" "password_set" { + function_name = "${var.project}-password-set" + role = aws_iam_role.password_set.arn + handler = "handler.handler" + runtime = "python3.12" + timeout = 30 + memory_size = 128 + filename = data.archive_file.password_set.output_path + source_code_hash = data.archive_file.password_set.output_base64sha256 + + environment { + variables = { + SIGNING_KEY_PARAM = "/javabin/platform/password-token-signing-key" + DEDUP_TABLE_NAME = var.alert_dedup_table_name + TEAM_PROVISIONER_FUNCTION = aws_lambda_function.team_provisioner.function_name } } } +resource "aws_lambda_function_url" "password_set" { + function_name = aws_lambda_function.password_set.function_name + authorization_type = "NONE" +} + +# Store function URL in SSM so team-provisioner can read it at runtime +# (avoids circular dependency between team-provisioner and password-set) +resource "aws_ssm_parameter" "password_set_function_url" { + name = "/javabin/platform/password-set-function-url" + type = "String" + value = aws_lambda_function_url.password_set.function_url +} + ################################################################################ # SNS Subscriptions — slack-alert subscribes to both topics ################################################################################ diff --git a/terraform/platform/lambdas/outputs.tf b/terraform/platform/lambdas/outputs.tf index a69dddc..45aa9be 100644 --- a/terraform/platform/lambdas/outputs.tf +++ b/terraform/platform/lambdas/outputs.tf @@ -28,6 +28,16 @@ output "team_provisioner_function_arn" { value = aws_lambda_function.team_provisioner.arn } +output "password_set_function_arn" { + description = "ARN of the password-set Lambda function" + value = aws_lambda_function.password_set.arn +} + +output "password_set_function_url" { + description = "Function URL for the password-set Lambda" + value = aws_lambda_function_url.password_set.function_url +} + output "apply_gate_role_arn" { description = "ARN of the apply-gate Lambda IAM role" value = aws_iam_role.apply_gate.arn