Commit 7fed875b by Mac Stephens

Add Twilio SMS verification, six-digit code validation, and simple-key device…

Add Twilio SMS verification, six-digit code validation, and simple-key device registration flow (removed twilio details from json for git)
parent 689bd1bf
...@@ -75,9 +75,12 @@ type ...@@ -75,9 +75,12 @@ type
[HttpGet] function GetAgencyConfigList: TAgencyConfigList; [HttpGet] function GetAgencyConfigList: TAgencyConfigList;
function VerifyVersion(ClientVersion: string): TJSONObject; function VerifyVersion(ClientVersion: string): TJSONObject;
// WebAuthn registration — step 1: server checks phone pre-auth, returns challenge // Section: Device registration step 1 checks phone pre-auth and sends an SMS code
function BeginRegistration(const PhoneNumber: string): TJSONObject; function BeginRegistration(const PhoneNumber: string): TJSONObject;
// WebAuthn registration — step 2: client submits credential, server verifies + activates pending row // Section: Device registration step 2 verifies the SMS code and returns a challenge
function VerifyRegistrationCode(const PhoneNumber, VerificationCode,
VerificationToken: string): TJSONObject;
// Section: WebAuthn registration verifies the credential and activates the pending row
function CompleteRegistration(const PhoneNumber, CredentialId, function CompleteRegistration(const PhoneNumber, CredentialId,
AttestationObject, ClientDataJSON, AttestationObject, ClientDataJSON,
ChallengeToken: string): TJSONObject; ChallengeToken: string): TJSONObject;
......
...@@ -2,5 +2,8 @@ ...@@ -2,5 +2,8 @@
"url": "http://localhost:2001/emsys/emiMobile/", "url": "http://localhost:2001/emsys/emiMobile/",
"jwtTokenSecret": "super_secret0123super_secret4567", "jwtTokenSecret": "super_secret0123super_secret4567",
"adminPassword": "whatisthisusedfor?", "adminPassword": "whatisthisusedfor?",
"webAppFolder": "static" "webAppFolder": "static",
"twilioAccountSid": "",
"twilioAuthToken": "",
"twilioFromNumber": ""
} }
...@@ -16,6 +16,7 @@ type ...@@ -16,6 +16,7 @@ type
TOnLoginSuccess = reference to procedure; TOnLoginSuccess = reference to procedure;
TOnLoginError = reference to procedure(AMsg: string); TOnLoginError = reference to procedure(AMsg: string);
TOnCodeSentSuccess = reference to procedure(AVerificationToken: string);
TOnBeginSuccess = reference to procedure(AChallenge, AChallengeToken: string); TOnBeginSuccess = reference to procedure(AChallenge, AChallengeToken: string);
TOnDeviceSuccess = reference to procedure; TOnDeviceSuccess = reference to procedure;
TOnDeviceError = reference to procedure(AMsg: string); TOnDeviceError = reference to procedure(AMsg: string);
...@@ -49,9 +50,13 @@ type ...@@ -49,9 +50,13 @@ type
function IsSimpleKey: Boolean; function IsSimpleKey: Boolean;
procedure ClearCredentialId; procedure ClearCredentialId;
// WebAuthn registration — two-step // Section: Device registration verification
procedure BeginRegistration(APhoneNumber: string; procedure BeginRegistration(APhoneNumber: string;
ASuccess: TOnBeginSuccess; AError: TOnDeviceError); ASuccess: TOnCodeSentSuccess; AError: TOnDeviceError);
procedure VerifyRegistrationCode(APhoneNumber, AVerificationCode,
AVerificationToken: string; ASuccess: TOnBeginSuccess;
AError: TOnDeviceError);
// Section: WebAuthn registration
procedure CompleteRegistration(APhoneNumber, ACredentialId, procedure CompleteRegistration(APhoneNumber, ACredentialId,
AAttestationObject, AClientDataJSON, AChallengeToken: string; AAttestationObject, AClientDataJSON, AChallengeToken: string;
ASuccess: TOnDeviceSuccess; AError: TOnDeviceError); ASuccess: TOnDeviceSuccess; AError: TOnDeviceError);
...@@ -214,7 +219,40 @@ end; ...@@ -214,7 +219,40 @@ end;
// ---- WebAuthn registration ---- // ---- WebAuthn registration ----
procedure TAuthService.BeginRegistration(APhoneNumber: string; procedure TAuthService.BeginRegistration(APhoneNumber: string;
ASuccess: TOnBeginSuccess; AError: TOnDeviceError); ASuccess: TOnCodeSentSuccess; AError: TOnDeviceError);
procedure OnLoad(Response: TXDataClientResponse);
var
resp: JS.TJSObject;
verificationToken, status: string;
begin
resp := JS.TJSObject(Response.Result);
status := JS.toString(resp.Properties['status']);
if status = 'error' then
begin
AError(JS.toString(resp.Properties['message']));
Exit;
end;
verificationToken := JS.toString(resp.Properties['verificationToken']);
ASuccess(verificationToken);
end;
procedure OnError(Error: TXDataClientError);
begin
AError(Format('%s: %s', [Error.ErrorCode, Error.ErrorMessage]));
end;
begin
FClient.RawInvoke(
'IAuthService.BeginRegistration',
[APhoneNumber],
@OnLoad, @OnError
);
end;
procedure TAuthService.VerifyRegistrationCode(APhoneNumber,
AVerificationCode, AVerificationToken: string; ASuccess: TOnBeginSuccess;
AError: TOnDeviceError);
procedure OnLoad(Response: TXDataClientResponse); procedure OnLoad(Response: TXDataClientResponse);
var var
...@@ -240,8 +278,8 @@ procedure TAuthService.BeginRegistration(APhoneNumber: string; ...@@ -240,8 +278,8 @@ procedure TAuthService.BeginRegistration(APhoneNumber: string;
begin begin
FClient.RawInvoke( FClient.RawInvoke(
'IAuthService.BeginRegistration', 'IAuthService.VerifyRegistrationCode',
[APhoneNumber], [APhoneNumber, AVerificationCode, AVerificationToken],
@OnLoad, @OnError @OnLoad, @OnError
); );
end; end;
......
...@@ -18,36 +18,56 @@ object FViewDeviceRegistration: TFViewDeviceRegistration ...@@ -18,36 +18,56 @@ object FViewDeviceRegistration: TFViewDeviceRegistration
TextHint = '(303) 555-1234' TextHint = '(303) 555-1234'
WidthPercent = 100.000000000000000000 WidthPercent = 100.000000000000000000
end end
object chkUseWebAuthn: TWebCheckBox
Left = 240
Top = 163
Width = 160
Height = 21
Caption = 'Use Passkey (WebAuthn)'
ElementID = 'view.devicereg.chkwebauthn'
HeightPercent = 100.000000000000000000
TabOrder = 1
WidthPercent = 100.000000000000000000
end
object btnRegister: TWebButton object btnRegister: TWebButton
Left = 240 Left = 240
Top = 190 Top = 163
Width = 121 Width = 121
Height = 25 Height = 25
Caption = 'Register This Device' Caption = 'Send Code'
ElementID = 'view.devicereg.btnregister' ElementID = 'view.devicereg.btnregister'
HeightPercent = 100.000000000000000000 HeightPercent = 100.000000000000000000
TabOrder = 2 TabOrder = 1
WidthPercent = 100.000000000000000000 WidthPercent = 100.000000000000000000
OnClick = btnRegisterClick OnClick = btnRegisterClick
end end
object pnlVerification: TWebPanel
Left = 240
Top = 194
Width = 177
Height = 58
ElementID = 'view.devicereg.verification'
TabOrder = 2
object edtVerificationCode: TWebEdit
Left = 0
Top = 0
Width = 121
Height = 21
ElementID = 'view.devicereg.edtverificationcode'
HeightPercent = 100.000000000000000000
TabOrder = 0
TextHint = 'Six-digit code'
WidthPercent = 100.000000000000000000
end
object btnVerifyCode: TWebButton
Left = 0
Top = 27
Width = 121
Height = 25
Caption = 'Verify and Register'
ElementID = 'view.devicereg.btnverify'
HeightPercent = 100.000000000000000000
TabOrder = 1
WidthPercent = 100.000000000000000000
OnClick = btnVerifyCodeClick
end
end
object pnlMessage: TWebPanel object pnlMessage: TWebPanel
Left = 240 Left = 240
Top = 65 Top = 65
Width = 121 Width = 121
Height = 33 Height = 33
ElementID = 'view.devicereg.message' ElementID = 'view.devicereg.message'
TabOrder = 2 TabOrder = 3
object lblMessage: TWebLabel object lblMessage: TWebLabel
Left = 16 Left = 16
Top = 11 Top = 11
......
...@@ -31,17 +31,10 @@ ...@@ -31,17 +31,10 @@
aria-label="Close"></button> aria-label="Close"></button>
</div> </div>
<p id="view.devicereg.desc-webauthn" class="text-muted small mb-3"> <p class="text-muted small mb-3">
This browser has not been registered for emiMobile access. This browser has not been registered for emiMobile access.
Enter the phone number your administrator registered for this device, Enter the phone number your administrator registered for this device,
then click <strong>Register</strong>. then click <strong>Send Code</strong>.
Your browser will prompt you to verify with a PIN, fingerprint, or security key.
</p>
<p id="view.devicereg.desc-simplekey" class="text-muted small mb-3 d-none">
This browser has not been registered for emiMobile access.
Enter the phone number your administrator registered for this device,
then click <strong>Register</strong>.
A secure key will be generated and stored in your browser.
</p> </p>
<div class="mb-3"> <div class="mb-3">
...@@ -53,6 +46,21 @@ ...@@ -53,6 +46,21 @@
autofocus> autofocus>
</div> </div>
<div id="view.devicereg.verification" class="mb-3 d-none">
<label class="form-label small text-muted">Verification code</label>
<input id="view.devicereg.edtverificationcode"
class="form-control mb-2"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
placeholder="Six-digit code">
<button id="view.devicereg.btnverify"
class="btn btn-primary w-100">
Verify and Register
</button>
</div>
<div class="mb-3"> <div class="mb-3">
<p class="text-muted small mb-1">Browser</p> <p class="text-muted small mb-1">Browser</p>
<p id="view.devicereg.useragent" <p id="view.devicereg.useragent"
...@@ -60,28 +68,9 @@ ...@@ -60,28 +68,9 @@
style="max-width:100%; overflow:hidden; white-space:nowrap;"></p> style="max-width:100%; overflow:hidden; white-space:nowrap;"></p>
</div> </div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="view.devicereg.chkwebauthn" checked>
<label class="form-check-label small text-muted"
for="view.devicereg.chkwebauthn">
Use Passkey (WebAuthn)
</label>
</div>
<p id="view.devicereg.chk-webauthn-help"
class="text-muted mb-0" style="font-size:0.75rem; padding-left:1.5rem;">
Biometrics, PIN, or security key — hardware-backed.
</p>
<p id="view.devicereg.chk-simplekey-help"
class="text-muted mb-0 d-none" style="font-size:0.75rem; padding-left:1.5rem;">
Browser-stored key — no hardware required.
</p>
</div>
<button id="view.devicereg.btnregister" <button id="view.devicereg.btnregister"
class="btn btn-primary w-100"> class="btn btn-outline-primary w-100">
Register This Device Send Code
</button> </button>
</div> </div>
......
...@@ -11,20 +11,25 @@ uses ...@@ -11,20 +11,25 @@ uses
type type
TFViewDeviceRegistration = class(TWebForm) TFViewDeviceRegistration = class(TWebForm)
edtPhoneNumber: TWebEdit; edtPhoneNumber: TWebEdit;
chkUseWebAuthn: TWebCheckBox;
btnRegister: TWebButton; btnRegister: TWebButton;
pnlVerification: TWebPanel;
edtVerificationCode: TWebEdit;
btnVerifyCode: TWebButton;
pnlMessage: TWebPanel; pnlMessage: TWebPanel;
lblMessage: TWebLabel; lblMessage: TWebLabel;
btnCloseNotification: TWebButton; btnCloseNotification: TWebButton;
XDataWebClient: TXDataWebClient; XDataWebClient: TXDataWebClient;
procedure btnRegisterClick(Sender: TObject); procedure btnRegisterClick(Sender: TObject);
procedure btnVerifyCodeClick(Sender: TObject);
procedure btnCloseNotificationClick(Sender: TObject); procedure btnCloseNotificationClick(Sender: TObject);
procedure WebFormCreate(Sender: TObject); procedure WebFormCreate(Sender: TObject);
private private
FRegistrationProc: TSuccessProc; FRegistrationProc: TSuccessProc;
FVerificationToken: string;
procedure ShowNotification(const AMsg: string; AIsError: Boolean = True); procedure ShowNotification(const AMsg: string; AIsError: Boolean = True);
procedure HideNotification; procedure HideNotification;
procedure SetBusy(ABusy: Boolean); procedure SetBusy(ABusy: Boolean);
procedure SetVerifyBusy(ABusy: Boolean);
procedure DoWebAuthnCreate(APhoneNumber, AChallenge, AChallengeToken: string); procedure DoWebAuthnCreate(APhoneNumber, AChallenge, AChallengeToken: string);
procedure DoSimpleKeyCreate(APhoneNumber, AChallengeToken: string); procedure DoSimpleKeyCreate(APhoneNumber, AChallengeToken: string);
public public
...@@ -55,6 +60,8 @@ var ...@@ -55,6 +60,8 @@ var
userAgent: string; userAgent: string;
begin begin
HideNotification; HideNotification;
FVerificationToken := '';
pnlVerification.ElementHandle.classList.add('d-none');
userAgent := ''; userAgent := '';
asm asm
userAgent = navigator.userAgent; userAgent = navigator.userAgent;
...@@ -80,27 +87,6 @@ begin ...@@ -80,27 +87,6 @@ begin
}); });
} }
// Toggle description text when checkbox changes
var chk = document.getElementById('view.devicereg.chkwebauthn');
if (chk) {
chk.addEventListener('change', function() {
var waDesc = document.getElementById('view.devicereg.desc-webauthn');
var skDesc = document.getElementById('view.devicereg.desc-simplekey');
var waHelp = document.getElementById('view.devicereg.chk-webauthn-help');
var skHelp = document.getElementById('view.devicereg.chk-simplekey-help');
if (chk.checked) {
if (waDesc) waDesc.classList.remove('d-none');
if (skDesc) skDesc.classList.add('d-none');
if (waHelp) waHelp.classList.remove('d-none');
if (skHelp) skHelp.classList.add('d-none');
} else {
if (waDesc) waDesc.classList.add('d-none');
if (skDesc) skDesc.classList.remove('d-none');
if (waHelp) waHelp.classList.add('d-none');
if (skHelp) skHelp.classList.remove('d-none');
}
});
}
end; end;
end; end;
...@@ -108,14 +94,20 @@ procedure TFViewDeviceRegistration.SetBusy(ABusy: Boolean); ...@@ -108,14 +94,20 @@ procedure TFViewDeviceRegistration.SetBusy(ABusy: Boolean);
begin begin
asm asm
var btn = document.getElementById('view.devicereg.btnregister'); var btn = document.getElementById('view.devicereg.btnregister');
var chk = document.getElementById('view.devicereg.chkwebauthn');
if (btn) { if (btn) {
btn.disabled = ABusy; btn.disabled = ABusy;
if (ABusy) { btn.textContent = ABusy ? 'Sending...' : 'Send Code';
btn.textContent = (chk && chk.checked) ? 'Waiting for authenticator...' : 'Activating device...'; }
} else { end;
btn.textContent = 'Register This Device'; end;
}
procedure TFViewDeviceRegistration.SetVerifyBusy(ABusy: Boolean);
begin
asm
var btn = document.getElementById('view.devicereg.btnverify');
if (btn) {
btn.disabled = ABusy;
btn.textContent = ABusy ? 'Verifying...' : 'Verify and Register';
} }
end; end;
end; end;
...@@ -123,17 +115,18 @@ end; ...@@ -123,17 +115,18 @@ end;
procedure TFViewDeviceRegistration.btnRegisterClick(Sender: TObject); procedure TFViewDeviceRegistration.btnRegisterClick(Sender: TObject);
var var
phoneNumber: string; phoneNumber: string;
useWebAuthn: Boolean;
procedure OnBeginOK(AChallenge, AChallengeToken: string); procedure OnCodeSent(AVerificationToken: string);
begin begin
if useWebAuthn then SetBusy(False);
DoWebAuthnCreate(phoneNumber, AChallenge, AChallengeToken) FVerificationToken := AVerificationToken;
else edtPhoneNumber.Enabled := False;
DoSimpleKeyCreate(phoneNumber, AChallengeToken); pnlVerification.ElementHandle.classList.remove('d-none');
edtVerificationCode.SetFocus;
ShowNotification('Verification code sent. Enter the six-digit code to continue.', False);
end; end;
procedure OnBeginError(AMsg: string); procedure OnCodeError(AMsg: string);
begin begin
SetBusy(False); SetBusy(False);
ShowNotification(AMsg); ShowNotification(AMsg);
...@@ -147,11 +140,49 @@ begin ...@@ -147,11 +140,49 @@ begin
Exit; Exit;
end; end;
useWebAuthn := chkUseWebAuthn.Checked;
SetBusy(True); SetBusy(True);
HideNotification; HideNotification;
AuthService.BeginRegistration(phoneNumber, @OnBeginOK, @OnBeginError); AuthService.BeginRegistration(phoneNumber, @OnCodeSent, @OnCodeError);
end;
procedure TFViewDeviceRegistration.btnVerifyCodeClick(Sender: TObject);
var
phoneNumber, verificationCode: string;
i: Integer;
procedure OnVerified(AChallenge, AChallengeToken: string);
begin
DoSimpleKeyCreate(phoneNumber, AChallengeToken);
end;
procedure OnVerifyError(AMsg: string);
begin
SetVerifyBusy(False);
ShowNotification(AMsg);
end;
begin
phoneNumber := Trim(edtPhoneNumber.Text);
verificationCode := Trim(edtVerificationCode.Text);
if Length(verificationCode) <> 6 then
begin
ShowNotification('Please enter the six-digit verification code.');
Exit;
end;
for i := 1 to Length(verificationCode) do
if not CharInSet(verificationCode[i], ['0'..'9']) then
begin
ShowNotification('Please enter the six-digit verification code.');
Exit;
end;
SetVerifyBusy(True);
HideNotification;
AuthService.VerifyRegistrationCode(phoneNumber, verificationCode,
FVerificationToken, @OnVerified, @OnVerifyError);
end; end;
procedure TFViewDeviceRegistration.DoWebAuthnCreate( procedure TFViewDeviceRegistration.DoWebAuthnCreate(
...@@ -166,7 +197,7 @@ var ...@@ -166,7 +197,7 @@ var
procedure OnCompleteError(AMsg: string); procedure OnCompleteError(AMsg: string);
begin begin
SetBusy(False); SetVerifyBusy(False);
ShowNotification(AMsg); ShowNotification(AMsg);
end; end;
...@@ -180,7 +211,7 @@ var ...@@ -180,7 +211,7 @@ var
procedure OnWebAuthnError(AMsg: string); procedure OnWebAuthnError(AMsg: string);
begin begin
SetBusy(False); SetVerifyBusy(False);
ShowNotification(AMsg); ShowNotification(AMsg);
end; end;
...@@ -243,7 +274,7 @@ var ...@@ -243,7 +274,7 @@ var
procedure OnCompleteError(AMsg: string); procedure OnCompleteError(AMsg: string);
begin begin
SetBusy(False); SetVerifyBusy(False);
ShowNotification(AMsg); ShowNotification(AMsg);
end; end;
...@@ -261,7 +292,7 @@ begin ...@@ -261,7 +292,7 @@ begin
if deviceKey = '' then if deviceKey = '' then
begin begin
SetBusy(False); SetVerifyBusy(False);
ShowNotification('Failed to generate device key.'); ShowNotification('Failed to generate device key.');
Exit; Exit;
end; end;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment