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 <noreply@anthropic.com>
116 lines
2.8 KiB
Text
116 lines
2.8 KiB
Text
<cfsetting showdebugoutput="false">
|
|
<cfsetting enablecfoutputonly="true">
|
|
<cfcontent type="application/json; charset=utf-8" reset="true">
|
|
<cfheader name="Cache-Control" value="no-store">
|
|
|
|
<cfscript>
|
|
/*
|
|
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
|
|
});
|
|
}
|
|
</cfscript>
|