|
| 1 | +-- Step-up authentication handler for Envoy |
| 2 | +-- This script intercepts step-up responses and manages the MFA redirect flow |
| 3 | + |
| 4 | +function envoy_on_request(request_handle) |
| 5 | + -- Check if this is an MFA verification callback |
| 6 | + local path = request_handle:headers():get(":path") |
| 7 | + if path and string.match(path, "^/auth/mfa/callback") then |
| 8 | + return handle_mfa_callback(request_handle) |
| 9 | + end |
| 10 | +end |
| 11 | + |
| 12 | +function envoy_on_response(response_handle) |
| 13 | + local status = response_handle:headers():get(":status") |
| 14 | + local content_type = response_handle:headers():get("content-type") |
| 15 | + |
| 16 | + -- Intercept 403 responses with JSON content (step-up required) |
| 17 | + if status == "403" and content_type and string.match(content_type, "application/json") then |
| 18 | + return handle_stepup_response(response_handle) |
| 19 | + end |
| 20 | +end |
| 21 | + |
| 22 | +function handle_stepup_response(response_handle) |
| 23 | + -- Read the response body |
| 24 | + local body = response_handle:body() |
| 25 | + if not body then |
| 26 | + return |
| 27 | + end |
| 28 | + |
| 29 | + -- Try to parse JSON response |
| 30 | + local json_response = parse_json(body:getBytes(0, body:length())) |
| 31 | + if not json_response or json_response.error ~= "mfa_required" then |
| 32 | + return -- Not a step-up response, continue normally |
| 33 | + end |
| 34 | + |
| 35 | + -- Extract MFA session information |
| 36 | + local session_id = json_response.session_id or "" |
| 37 | + local device_id = json_response.device_id or "" |
| 38 | + local mfa_url = json_response.mfa_url or "http://mfa:8445/mfa/challenge" |
| 39 | + |
| 40 | + -- Create MFA challenge request |
| 41 | + local challenge_data = { |
| 42 | + session_id = session_id, |
| 43 | + device_id = device_id, |
| 44 | + user_email = extract_user_email_from_request() |
| 45 | + } |
| 46 | + |
| 47 | + -- Initiate MFA challenge |
| 48 | + local mfa_response = call_mfa_service(mfa_url, challenge_data) |
| 49 | + if not mfa_response then |
| 50 | + response_handle:logErr("Failed to create MFA challenge") |
| 51 | + return |
| 52 | + end |
| 53 | + |
| 54 | + -- Create redirect response to MFA interface |
| 55 | + local redirect_url = string.format("/auth/mfa?session=%s&challenge=%s&code=%s", |
| 56 | + session_id, |
| 57 | + url_encode(mfa_response.challenge or ""), |
| 58 | + mfa_response.code or "") -- Only for PoC |
| 59 | + |
| 60 | + response_handle:headers():remove("content-length") |
| 61 | + response_handle:headers():add("location", redirect_url) |
| 62 | + response_handle:headers():add("content-type", "text/html") |
| 63 | + response_handle:headers():replace(":status", "302") |
| 64 | + |
| 65 | + -- Create redirect page with MFA form |
| 66 | + local redirect_html = create_mfa_form(session_id, mfa_response.challenge, mfa_response.code) |
| 67 | + response_handle:body():setBytes(redirect_html) |
| 68 | +end |
| 69 | + |
| 70 | +function handle_mfa_callback(request_handle) |
| 71 | + local query_params = parse_query_string(request_handle:headers():get(":path")) |
| 72 | + local session_id = query_params.session or "" |
| 73 | + local code = query_params.code or "" |
| 74 | + |
| 75 | + if session_id == "" or code == "" then |
| 76 | + send_error_response(request_handle, "Invalid MFA parameters") |
| 77 | + return |
| 78 | + end |
| 79 | + |
| 80 | + -- Verify MFA code |
| 81 | + local verify_data = { |
| 82 | + session_id = session_id, |
| 83 | + code = code |
| 84 | + } |
| 85 | + |
| 86 | + local verify_response = call_mfa_verify("http://mfa:8445/mfa/verify", verify_data) |
| 87 | + if not verify_response or verify_response.status ~= "verified" then |
| 88 | + send_error_response(request_handle, "MFA verification failed") |
| 89 | + return |
| 90 | + end |
| 91 | + |
| 92 | + -- MFA successful - redirect back to original request with MFA token |
| 93 | + local original_path = query_params.original_path or "/" |
| 94 | + local mfa_token = verify_response.token or "" |
| 95 | + |
| 96 | + request_handle:headers():add("x-mfa-token", mfa_token) |
| 97 | + request_handle:headers():add("x-mfa-verified", "true") |
| 98 | + request_handle:headers():replace(":path", original_path) |
| 99 | + |
| 100 | + -- Continue with original request |
| 101 | +end |
| 102 | + |
| 103 | +function call_mfa_service(url, data) |
| 104 | + local headers, body = request_handle:httpCall( |
| 105 | + "mfa_cluster", |
| 106 | + { |
| 107 | + [":method"] = "POST", |
| 108 | + [":path"] = "/mfa/challenge", |
| 109 | + [":authority"] = "mfa:8445", |
| 110 | + ["content-type"] = "application/json" |
| 111 | + }, |
| 112 | + json_encode(data), |
| 113 | + 5000 |
| 114 | + ) |
| 115 | + |
| 116 | + if not body then |
| 117 | + return nil |
| 118 | + end |
| 119 | + |
| 120 | + return parse_json(body) |
| 121 | +end |
| 122 | + |
| 123 | +function call_mfa_verify(url, data) |
| 124 | + local headers, body = request_handle:httpCall( |
| 125 | + "mfa_cluster", |
| 126 | + { |
| 127 | + [":method"] = "POST", |
| 128 | + [":path"] = "/mfa/verify", |
| 129 | + [":authority"] = "mfa:8445", |
| 130 | + ["content-type"] = "application/json" |
| 131 | + }, |
| 132 | + json_encode(data), |
| 133 | + 5000 |
| 134 | + ) |
| 135 | + |
| 136 | + if not body then |
| 137 | + return nil |
| 138 | + end |
| 139 | + |
| 140 | + return parse_json(body) |
| 141 | +end |
| 142 | + |
| 143 | +function create_mfa_form(session_id, challenge, code) |
| 144 | + return string.format([[ |
| 145 | +<!DOCTYPE html> |
| 146 | +<html> |
| 147 | +<head> |
| 148 | + <title>Additional Authentication Required</title> |
| 149 | + <style> |
| 150 | + body { font-family: Arial, sans-serif; max-width: 400px; margin: 100px auto; padding: 20px; } |
| 151 | + .form-group { margin: 15px 0; } |
| 152 | + input[type="text"] { width: 100%%; padding: 10px; font-size: 18px; text-align: center; } |
| 153 | + button { width: 100%%; padding: 15px; background: #007cba; color: white; border: none; font-size: 16px; } |
| 154 | + .challenge { background: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0; } |
| 155 | + .code { font-weight: bold; color: #007cba; font-size: 24px; text-align: center; margin: 20px 0; } |
| 156 | + </style> |
| 157 | +</head> |
| 158 | +<body> |
| 159 | + <h2>Additional Authentication Required</h2> |
| 160 | + <div class="challenge">%s</div> |
| 161 | + <div class="code">Code: %s</div> |
| 162 | + <form id="mfaForm" method="GET" action="/auth/mfa/callback"> |
| 163 | + <input type="hidden" name="session" value="%s"> |
| 164 | + <input type="hidden" name="original_path" value="%s"> |
| 165 | + <div class="form-group"> |
| 166 | + <input type="text" name="code" placeholder="Enter 6-digit code" maxlength="6" required> |
| 167 | + </div> |
| 168 | + <button type="submit">Verify</button> |
| 169 | + </form> |
| 170 | +</body> |
| 171 | +</html>]], challenge, code, session_id, get_original_path()) |
| 172 | +end |
| 173 | + |
| 174 | +-- Utility functions |
| 175 | +function parse_json(str) |
| 176 | + -- Basic JSON parsing (would use proper library in production) |
| 177 | + local json = {} |
| 178 | + for key, value in string.gmatch(str, '"([^"]+)"%s*:%s*"([^"]*)"') do |
| 179 | + json[key] = value |
| 180 | + end |
| 181 | + for key, value in string.gmatch(str, '"([^"]+)"%s*:%s*([%d%.]+)') do |
| 182 | + json[key] = tonumber(value) |
| 183 | + end |
| 184 | + return json |
| 185 | +end |
| 186 | + |
| 187 | +function json_encode(data) |
| 188 | + local result = "{" |
| 189 | + local first = true |
| 190 | + for key, value in pairs(data) do |
| 191 | + if not first then |
| 192 | + result = result .. "," |
| 193 | + end |
| 194 | + if type(value) == "string" then |
| 195 | + result = result .. string.format('"%s":"%s"', key, value) |
| 196 | + else |
| 197 | + result = result .. string.format('"%s":%s', key, tostring(value)) |
| 198 | + end |
| 199 | + first = false |
| 200 | + end |
| 201 | + result = result .. "}" |
| 202 | + return result |
| 203 | +end |
| 204 | + |
| 205 | +function parse_query_string(path) |
| 206 | + local params = {} |
| 207 | + if path then |
| 208 | + local query = string.match(path, "^[^%?]*%?(.*)$") |
| 209 | + if query then |
| 210 | + for key, value in string.gmatch(query, "([^&=]+)=([^&]*)") do |
| 211 | + params[key] = url_decode(value) |
| 212 | + end |
| 213 | + end |
| 214 | + end |
| 215 | + return params |
| 216 | +end |
| 217 | + |
| 218 | +function url_encode(str) |
| 219 | + if str then |
| 220 | + str = string.gsub(str, "([^%w%-%.%_%~ ])", function(c) |
| 221 | + return string.format("%%%02X", string.byte(c)) |
| 222 | + end) |
| 223 | + str = string.gsub(str, " ", "+") |
| 224 | + end |
| 225 | + return str |
| 226 | +end |
| 227 | + |
| 228 | +function url_decode(str) |
| 229 | + if str then |
| 230 | + str = string.gsub(str, "+", " ") |
| 231 | + str = string.gsub(str, "%%(%x%x)", function(h) |
| 232 | + return string.char(tonumber(h, 16)) |
| 233 | + end) |
| 234 | + end |
| 235 | + return str |
| 236 | +end |
| 237 | + |
| 238 | +function extract_user_email_from_request() |
| 239 | + -- Extract from JWT or other source |
| 240 | + return "user@example.com" -- Placeholder |
| 241 | +end |
| 242 | + |
| 243 | +function get_original_path() |
| 244 | + -- Get the original request path |
| 245 | + return "/" -- Placeholder |
| 246 | +end |
| 247 | + |
| 248 | +function send_error_response(request_handle, message) |
| 249 | + request_handle:respond( |
| 250 | + {[":status"] = "400", ["content-type"] = "text/html"}, |
| 251 | + string.format("<html><body><h1>Error</h1><p>%s</p></body></html>", message) |
| 252 | + ) |
| 253 | +end |
0 commit comments