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 = """\ + + +
+ + +
+ Passordet ditt er oppdatert. Du kan nå logge inn med din javaBin-konto.
+ Logg inn ++ Klikk p\u00e5 knappen under for \u00e5 sette passordet ditt. Lenken er gyldig i 48 timer. +
+| + + Sett passord + + |
Slik setter du passord:
+{javabin_email}
-Slik setter du passord:
-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