From db90d9911a5eefa01c22d569de6d26f8b9ad563f Mon Sep 17 00:00:00 2001 From: John Mizerek Date: Sun, 1 Feb 2026 09:03:40 -0800 Subject: [PATCH] Add OTP email login to business portal Replace default password login with email-based OTP flow. User enters email, receives 6-digit code, enters it to log in. Password login retained as fallback via link. On dev, magic OTP code is shown directly for easy testing. Co-Authored-By: Claude Opus 4.5 --- api/Application.cfm | 2 + api/auth/sendLoginOTP.cfm | 129 +++++++++++++ api/auth/verifyEmailOTP.cfm | 116 ++++++++++++ portal/login.html | 368 ++++++++++++++++++++++++++++++++---- 4 files changed, 577 insertions(+), 38 deletions(-) create mode 100644 api/auth/sendLoginOTP.cfm create mode 100644 api/auth/verifyEmailOTP.cfm diff --git a/api/Application.cfm b/api/Application.cfm index 358f9c1..bf2cbe7 100644 --- a/api/Application.cfm +++ b/api/Application.cfm @@ -103,6 +103,8 @@ if (len(request._api_path)) { if (findNoCase("/api/auth/verifyOTP.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/auth/loginOTP.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/auth/verifyLoginOTP.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/auth/sendLoginOTP.cfm", request._api_path)) request._api_isPublic = true; + if (findNoCase("/api/auth/verifyEmailOTP.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/auth/completeProfile.cfm", request._api_path)) request._api_isPublic = true; if (findNoCase("/api/businesses/list.cfm", request._api_path)) request._api_isPublic = true; diff --git a/api/auth/sendLoginOTP.cfm b/api/auth/sendLoginOTP.cfm new file mode 100644 index 0000000..d34d0cc --- /dev/null +++ b/api/auth/sendLoginOTP.cfm @@ -0,0 +1,129 @@ + + + + + + +/* + Send OTP Code for Portal Login + POST: { "Email": "user@example.com" } + Returns: { OK: true } always (don't reveal if email exists) +*/ + +function apiAbort(required struct payload) { + writeOutput(serializeJSON(payload)); + abort; +} + +function readJsonBody() { + var raw = getHttpRequestData().content; + if (isNull(raw)) raw = ""; + if (!len(trim(raw))) return {}; + try { + var data = deserializeJSON(raw); + if (isStruct(data)) return data; + } catch (any e) {} + return {}; +} + +data = readJsonBody(); +email = trim(data.Email ?: ""); + +if (!len(email)) { + apiAbort({ "OK": false, "ERROR": "missing_email", "MESSAGE": "Email is required" }); +} + +genericResponse = { "OK": true, "MESSAGE": "If an account exists, a code has been sent." }; + +try { + // Look up user by email + qUser = queryExecute(" + SELECT ID, FirstName + FROM Users + WHERE EmailAddress = :email + AND IsActive = 1 + LIMIT 1 + ", { + email: { value: email, cfsqltype: "cf_sql_varchar" } + }, { datasource: "payfrit" }); + + // Always return OK to not reveal if email exists + if (qUser.recordCount == 0) { + writeOutput(serializeJSON(genericResponse)); + abort; + } + + userId = qUser.ID; + + // Rate limit: max 3 codes per user in last 10 minutes + qRateCheck = queryExecute(" + SELECT COUNT(*) AS cnt + FROM OTPCodes + WHERE UserID = :userId + AND CreatedAt > DATE_SUB(NOW(), INTERVAL 10 MINUTE) + ", { + userId: { value: userId, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + if (qRateCheck.cnt >= 3) { + writeOutput(serializeJSON(genericResponse)); + abort; + } + + // Generate 6-digit code (or use magic code on dev) + code = randRange(100000, 999999); + isDev = findNoCase("dev.payfrit.com", cgi.SERVER_NAME) > 0 + || findNoCase("localhost", cgi.SERVER_NAME) > 0; + + if (isDev && structKeyExists(application, "MAGIC_OTP_ENABLED") && application.MAGIC_OTP_ENABLED + && structKeyExists(application, "MAGIC_OTP_CODE") && len(application.MAGIC_OTP_CODE)) { + code = application.MAGIC_OTP_CODE; + } + + // Store in DB with 10-minute expiry + queryExecute(" + INSERT INTO OTPCodes (UserID, Code, ExpiresAt) + VALUES (:userId, :code, DATE_ADD(NOW(), INTERVAL 10 MINUTE)) + ", { + userId: { value: userId, cfsqltype: "cf_sql_integer" }, + code: { value: code, cfsqltype: "cf_sql_varchar" } + }, { datasource: "payfrit" }); + + // Send email (skip on dev if magic OTP is enabled) + if (!isDev) { + try { + mailService = new mail(); + mailService.setTo(email); + mailService.setFrom("admin@payfrit.com"); + mailService.setSubject("Your Payfrit login code"); + mailService.setType("html"); + + emailBody = " +
+

Your login code

+

Hi #encodeForHTML(qUser.FirstName)#, use this code to sign in to Payfrit:

+
+ #code# +
+

This code expires in 10 minutes. If you didn't request this, you can safely ignore it.

+
+ "; + + mailService.send(body=emailBody); + } catch (any mailErr) { + writeLog(file="otp_errors", text="Email send failed for user #userId#: #mailErr.message#"); + } + } + + // On dev, include the code in response for easy testing + resp = duplicate(genericResponse); + if (isDev) { + resp["DEV_OTP"] = code; + } + writeOutput(serializeJSON(resp)); + +} catch (any e) { + writeLog(file="otp_errors", text="sendLoginOTP error for #email#: #e.message#"); + writeOutput(serializeJSON(genericResponse)); +} +
diff --git a/api/auth/verifyEmailOTP.cfm b/api/auth/verifyEmailOTP.cfm new file mode 100644 index 0000000..b1b7f1f --- /dev/null +++ b/api/auth/verifyEmailOTP.cfm @@ -0,0 +1,116 @@ + + + + + + +/* + Verify Email OTP Code for Portal Login + POST: { "Email": "user@example.com", "Code": "123456" } + Returns: { OK: true, UserID, FirstName, Token } or { OK: false, ERROR: "invalid_code" } +*/ + +function apiAbort(required struct payload) { + writeOutput(serializeJSON(payload)); + abort; +} + +function readJsonBody() { + var raw = getHttpRequestData().content; + if (isNull(raw)) raw = ""; + if (!len(trim(raw))) return {}; + try { + var data = deserializeJSON(raw); + if (isStruct(data)) return data; + } catch (any e) {} + return {}; +} + +data = readJsonBody(); +email = trim(data.Email ?: ""); +code = trim(data.Code ?: ""); + +if (!len(email) || !len(code)) { + apiAbort({ "OK": false, "ERROR": "missing_fields", "MESSAGE": "Email and code are required" }); +} + +try { + // Look up user by email + qUser = queryExecute(" + SELECT ID, FirstName + FROM Users + WHERE EmailAddress = :email + AND IsActive = 1 + LIMIT 1 + ", { + email: { value: email, cfsqltype: "cf_sql_varchar" } + }, { datasource: "payfrit" }); + + if (qUser.recordCount == 0) { + apiAbort({ "OK": false, "ERROR": "invalid_code", "MESSAGE": "Invalid or expired code" }); + } + + userId = qUser.ID; + + // Check for valid OTP in OTPCodes table + qOTP = queryExecute(" + SELECT ID + FROM OTPCodes + WHERE UserID = :userId + AND Code = :code + AND ExpiresAt > NOW() + AND UsedAt IS NULL + ORDER BY CreatedAt DESC + LIMIT 1 + ", { + userId: { value: userId, cfsqltype: "cf_sql_integer" }, + code: { value: code, cfsqltype: "cf_sql_varchar" } + }, { datasource: "payfrit" }); + + if (qOTP.recordCount == 0) { + apiAbort({ "OK": false, "ERROR": "invalid_code", "MESSAGE": "Invalid or expired code" }); + } + + // Mark OTP as used + queryExecute(" + UPDATE OTPCodes + SET UsedAt = NOW() + WHERE ID = :otpId + ", { + otpId: { value: qOTP.ID, cfsqltype: "cf_sql_integer" } + }, { datasource: "payfrit" }); + + // Create auth token (same as login.cfm) + token = replace(createUUID(), "-", "", "all"); + + queryExecute( + "INSERT INTO UserTokens (UserID, Token) VALUES (?, ?)", + [ + { value: userId, cfsqltype: "cf_sql_integer" }, + { value: token, cfsqltype: "cf_sql_varchar" } + ], + { datasource: "payfrit" } + ); + + // Set session + lock timeout="15" throwontimeout="yes" type="exclusive" scope="session" { + session.UserID = userId; + } + request.UserID = userId; + + writeOutput(serializeJSON({ + "OK": true, + "ERROR": "", + "UserID": userId, + "FirstName": qUser.FirstName, + "Token": token + })); + +} catch (any e) { + apiAbort({ + "OK": false, + "ERROR": "server_error", + "MESSAGE": e.message + }); +} + diff --git a/portal/login.html b/portal/login.html index a257a46..9f785a0 100644 --- a/portal/login.html +++ b/portal/login.html @@ -134,6 +134,20 @@ display: block; } + .login-success { + background: rgba(0, 255, 136, 0.1); + border: 1px solid rgba(0, 255, 136, 0.3); + color: var(--primary); + padding: 12px 16px; + border-radius: 8px; + font-size: 14px; + display: none; + } + + .login-success.show { + display: block; + } + .login-footer { text-align: center; margin-top: 24px; @@ -182,6 +196,46 @@ .step.active { display: block; } + + .otp-input { + text-align: center; + font-size: 24px; + font-weight: 600; + letter-spacing: 8px; + padding: 14px 16px; + } + + .switch-link { + color: var(--text-muted); + text-decoration: none; + font-size: 13px; + cursor: pointer; + } + + .switch-link:hover { + color: var(--primary); + text-decoration: underline; + } + + .resend-link { + color: var(--text-muted); + font-size: 13px; + cursor: pointer; + background: none; + border: none; + padding: 0; + text-decoration: none; + } + + .resend-link:hover { + color: var(--primary); + text-decoration: underline; + } + + .resend-link:disabled { + opacity: 0.5; + cursor: not-allowed; + } @@ -190,11 +244,49 @@ - -
+ +
+ + + +
+ + +
+ + + +
+ + + - +