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;
......
...@@ -28,6 +28,11 @@ type ...@@ -28,6 +28,11 @@ type
function CheckUser(const User, Password, Agency: string): Integer; function CheckUser(const User, Password, Agency: string): Integer;
function LoadUserByName(const User, Agency: string): Boolean; function LoadUserByName(const User, Agency: string): Boolean;
function Decrypt(inStr, keyStr: AnsiString): AnsiString; function Decrypt(inStr, keyStr: AnsiString): AnsiString;
function GenerateVerificationCode: string;
function CreateVerificationToken(const PhoneNumber, Code: string): string;
function VerifyVerificationToken(const PhoneNumber, Code,
VerificationToken: string): Boolean;
procedure SendVerificationText(const PhoneNumber, Code: string);
// Returns True and sets AChallengeB64 if token is valid and of the expected type // Returns True and sets AChallengeB64 if token is valid and of the expected type
function VerifyChallengeToken(const ChallengeToken, ExpectedType: string; function VerifyChallengeToken(const ChallengeToken, ExpectedType: string;
out AChallengeB64: string): Boolean; out AChallengeB64: string): Boolean;
...@@ -41,6 +46,8 @@ type ...@@ -41,6 +46,8 @@ type
function GetAgencieslist(): TAgenciesList; function GetAgencieslist(): TAgenciesList;
function GetAgencyConfiglist: TAgencyConfigList; function GetAgencyConfiglist: TAgencyConfigList;
function BeginRegistration(const PhoneNumber: string): TJSONObject; function BeginRegistration(const PhoneNumber: string): TJSONObject;
function VerifyRegistrationCode(const PhoneNumber, VerificationCode,
VerificationToken: string): TJSONObject;
function CompleteRegistration(const PhoneNumber, CredentialId, function CompleteRegistration(const PhoneNumber, CredentialId,
AttestationObject, ClientDataJSON, AttestationObject, ClientDataJSON,
ChallengeToken: string): TJSONObject; ChallengeToken: string): TJSONObject;
...@@ -60,8 +67,11 @@ implementation ...@@ -60,8 +67,11 @@ implementation
uses uses
System.DateUtils, System.DateUtils,
System.Classes,
System.Generics.Collections, System.Generics.Collections,
System.NetEncoding, System.NetEncoding,
System.Net.HttpClient,
System.Net.URLClient,
Bcl.JOSE.Core.Builder, Bcl.JOSE.Core.Builder,
Bcl.JOSE.Core.JWT, Bcl.JOSE.Core.JWT,
Aurelius.Global.Utils, Aurelius.Global.Utils,
...@@ -194,16 +204,119 @@ begin ...@@ -194,16 +204,119 @@ begin
Result := '+1' + digits; Result := '+1' + digits;
end; end;
function TAuthService.GenerateVerificationCode: string;
var
bytes: TBytes;
value: Cardinal;
begin
bytes := RandomBytes(4);
value := (Cardinal(bytes[0]) shl 24) or
(Cardinal(bytes[1]) shl 16) or
(Cardinal(bytes[2]) shl 8) or
Cardinal(bytes[3]);
Result := FormatFloat('000000', value mod 1000000);
// ADD THIS DELETE BEFORE DEPLOY
Logger.Log(3, '---TAuthService.GenerateVerificationCode initiated with Verification Code: ' + Result);
Logger.Log(3, '---TAuthService.GenerateVerificationCode End');
end;
function TAuthService.CreateVerificationToken(const PhoneNumber,
Code: string): string;
var
payload, expiry: string;
keyBytes, hmacBytes: TBytes;
begin
expiry := IntToStr(DateTimeToUnix(TTimeZone.Local.ToUniversalTime(IncMinute(Now, 5))));
payload := PhoneNumber + '|' + expiry + '|' + Base64UrlEncode(RandomBytes(16));
keyBytes := TEncoding.UTF8.GetBytes(ServerConfig.jwtTokenSecret);
hmacBytes := HMACSHA256Bytes(keyBytes, TEncoding.UTF8.GetBytes(payload + '|' + Code));
Result := Base64UrlEncode(TEncoding.UTF8.GetBytes(payload)) + '.' + Base64UrlEncode(hmacBytes);
end;
function TAuthService.VerifyVerificationToken(const PhoneNumber, Code,
VerificationToken: string): Boolean;
var
tokenParts, payloadParts: TArray<string>;
payload, expectedHmac: string;
expiry: Int64;
keyBytes, hmacBytes: TBytes;
begin
Result := False;
tokenParts := VerificationToken.Split(['.']);
if Length(tokenParts) <> 2 then Exit;
try
payload := TEncoding.UTF8.GetString(Base64UrlDecode(tokenParts[0]));
except
Exit;
end;
payloadParts := payload.Split(['|']);
if Length(payloadParts) <> 3 then Exit;
if payloadParts[0] <> PhoneNumber then Exit;
expiry := StrToInt64Def(payloadParts[1], 0);
if (expiry = 0) or
(DateTimeToUnix(TTimeZone.Local.ToUniversalTime(Now)) > expiry) then Exit;
keyBytes := TEncoding.UTF8.GetBytes(ServerConfig.jwtTokenSecret);
hmacBytes := HMACSHA256Bytes(keyBytes, TEncoding.UTF8.GetBytes(payload + '|' + Code));
expectedHmac := Base64UrlEncode(hmacBytes);
Result := expectedHmac = tokenParts[1];
end;
procedure TAuthService.SendVerificationText(const PhoneNumber, Code: string);
var
httpClient: THTTPClient;
bodyStream: TStringStream;
formData, authStr, msgBody: string;
response: IHTTPResponse;
begin
if (ServerConfig.twilioAccountSid = '') or
(ServerConfig.twilioAuthToken = '') or
(ServerConfig.twilioFromNumber = '') then
raise Exception.Create('Twilio is not configured on the server.');
msgBody := 'Your emiMobile verification code is: ' + Code + sLineBreak +
'Enter this code in the registration form to continue.';
authStr := TNetEncoding.Base64.Encode(
ServerConfig.twilioAccountSid + ':' + ServerConfig.twilioAuthToken)
.Replace(#13, '').Replace(#10, '');
formData := 'From=' + TNetEncoding.URL.Encode(ServerConfig.twilioFromNumber) +
'&To=' + TNetEncoding.URL.Encode(PhoneNumber) +
'&Body=' + TNetEncoding.URL.Encode(msgBody);
httpClient := THTTPClient.Create;
try
httpClient.ContentType := 'application/x-www-form-urlencoded';
httpClient.CustomHeaders['Authorization'] := 'Basic ' + authStr;
bodyStream := TStringStream.Create(formData, TEncoding.UTF8);
try
response := httpClient.Post(
'https://api.twilio.com/2010-04-01/Accounts/' +
ServerConfig.twilioAccountSid + '/Messages.json', bodyStream);
if response.StatusCode >= 300 then
raise Exception.CreateFmt('Twilio error %d: %s',
[response.StatusCode, response.ContentAsString]);
finally
bodyStream.Free;
end;
finally
httpClient.Free;
end;
end;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// BeginRegistration // BeginRegistration
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function TAuthService.BeginRegistration(const PhoneNumber: string): TJSONObject; function TAuthService.BeginRegistration(const PhoneNumber: string): TJSONObject;
var var
token, challengeB64, normalizedPhone: string; code, normalizedPhone, verificationToken, deviceName: string;
deviceId: Integer;
q: TUniQuery; q: TUniQuery;
begin begin
Logger.Log(2, 'AuthService.BeginRegistration - phone: "' + PhoneNumber + '"'); Logger.Log(3, '---TAuthService.BeginRegistration initiated with phone number: ' + PhoneNumber);
Result := TJSONObject.Create; Result := TJSONObject.Create;
TXDataOperationContext.Current.Handler.ManagedObjects.Add(Result); TXDataOperationContext.Current.Handler.ManagedObjects.Add(Result);
...@@ -231,7 +344,7 @@ begin ...@@ -231,7 +344,7 @@ begin
try try
q.Connection := authDB.ucLemsOCSO; q.Connection := authDB.ucLemsOCSO;
q.SQL.Text := q.SQL.Text :=
'SELECT id FROM lems.device_registrations ' + 'SELECT id, device_name FROM lems.device_registrations ' +
'WHERE phone_number = :PHONE AND status = ''pending'''; 'WHERE phone_number = :PHONE AND status = ''pending''';
q.ParamByName('PHONE').AsString := normalizedPhone; q.ParamByName('PHONE').AsString := normalizedPhone;
q.Open; q.Open;
...@@ -243,18 +356,103 @@ begin ...@@ -243,18 +356,103 @@ begin
Result.AddPair('message', 'Phone number not recognized. Contact your administrator.'); Result.AddPair('message', 'Phone number not recognized. Contact your administrator.');
Exit; Exit;
end; end;
deviceId := q.FieldByName('id').AsInteger;
deviceName := q.FieldByName('device_name').AsString;
q.Close; q.Close;
finally finally
q.Free; q.Free;
end; end;
token := MakeChallengeToken('reg'); code := GenerateVerificationCode;
verificationToken := CreateVerificationToken(normalizedPhone, code);
try
SendVerificationText(normalizedPhone, code);
except
on E: Exception do
begin
Logger.Log(2, '--Error sending registration verification code: ' + E.Message);
Result.AddPair('status', 'error');
Result.AddPair('message', 'The verification code could not be sent. Please try again.');
Exit;
end;
end;
// ADD THIS DELETE BEFORE DEPLOY
Logger.Log(3, Format(
'Verification code set to %s for phone number: %s with device id %d and device name: %s',
[code, normalizedPhone, deviceId, deviceName]));
Result.AddPair('status', 'ok');
Result.AddPair('message', 'Verification code sent.');
Result.AddPair('verificationToken', verificationToken);
Logger.Log(3, '---TAuthService.BeginRegistration End');
end;
function TAuthService.VerifyRegistrationCode(const PhoneNumber,
VerificationCode, VerificationToken: string): TJSONObject;
var
normalizedPhone, token, challengeB64: string;
q: TUniQuery;
begin
// ADD THIS DELETE BEFORE DEPLOY
Logger.Log(3, Format(
'---TAuthService.VerifyRegistrationCode initiated with phone number: %s and verification code: %s',
[PhoneNumber, VerificationCode]));
Result := TJSONObject.Create;
TXDataOperationContext.Current.Handler.ManagedObjects.Add(Result);
try
normalizedPhone := NormalizePhoneE164(PhoneNumber);
except
on E: Exception do
begin
Result.AddPair('status', 'error');
Result.AddPair('message', E.Message);
Exit;
end;
end;
q := TUniQuery.Create(nil);
try
q.Connection := authDB.ucLemsOCSO;
q.SQL.Text :=
'SELECT id FROM lems.device_registrations ' +
'WHERE phone_number = :PHONE AND status = ''pending''';
q.ParamByName('PHONE').AsString := normalizedPhone;
q.Open;
if q.IsEmpty then
begin
q.Close;
Result.AddPair('status', 'error');
Result.AddPair('message', 'Phone number not recognized. Contact your administrator.');
Exit;
end;
q.Close;
finally
q.Free;
end;
if (Length(Trim(VerificationCode)) <> 6) or
not VerifyVerificationToken(normalizedPhone, Trim(VerificationCode),
VerificationToken) then
begin
Logger.Log(2, '--Verification failed for phone number: ' + normalizedPhone);
Result.AddPair('status', 'error');
Result.AddPair('message', 'Invalid or expired verification code.');
Exit;
end;
token := MakeChallengeToken('reg-' + normalizedPhone);
challengeB64 := token.Split([':'], 4)[0]; challengeB64 := token.Split([':'], 4)[0];
Result.AddPair('challenge', challengeB64); Result.AddPair('status', 'ok');
Result.AddPair('challenge', challengeB64);
Result.AddPair('challengeToken', token); Result.AddPair('challengeToken', token);
Result.AddPair('rpId', ServerConfig.rpId); Result.AddPair('rpId', ServerConfig.rpId);
Result.AddPair('rpName', ServerConfig.rpName); Result.AddPair('rpName', ServerConfig.rpName);
Logger.Log(3, '--Verification successful for phone number: ' + normalizedPhone);
Logger.Log(3, '---TAuthService.VerifyRegistrationCode End');
end; end;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
...@@ -268,7 +466,7 @@ var ...@@ -268,7 +466,7 @@ var
cdJsonBytes, attObjBytes, credIdBytes: TBytes; cdJsonBytes, attObjBytes, credIdBytes: TBytes;
cdJsonText: string; cdJsonText: string;
cdJson: TJSONObject; cdJson: TJSONObject;
typeVal, challengeVal, originVal, effectiveRpId: string; typeVal, challengeVal, originVal, effectiveRpId, normalizedPhone: string;
authData: TBytes; authData: TBytes;
rpIdHash, credId, pubKeyX, pubKeyY: TBytes; rpIdHash, credId, pubKeyX, pubKeyY: TBytes;
flags: Byte; flags: Byte;
...@@ -283,8 +481,20 @@ begin ...@@ -283,8 +481,20 @@ begin
Logger.Log(2, 'AuthService.CompleteRegistration - credId: ' + Copy(CredentialId, 1, 20) + '...'); Logger.Log(2, 'AuthService.CompleteRegistration - credId: ' + Copy(CredentialId, 1, 20) + '...');
try
normalizedPhone := NormalizePhoneE164(PhoneNumber);
except
on E: Exception do
begin
Result.AddPair('status', 'error');
Result.AddPair('message', E.Message);
Exit;
end;
end;
// 1. Verify challenge token // 1. Verify challenge token
if not VerifyChallengeToken(ChallengeToken, 'reg', challengeB64) then if not VerifyChallengeToken(ChallengeToken,
'reg-' + normalizedPhone, challengeB64) then
begin begin
Result.AddPair('status', 'error'); Result.AddPair('status', 'error');
Result.AddPair('message', 'Invalid or expired registration challenge.'); Result.AddPair('message', 'Invalid or expired registration challenge.');
...@@ -401,19 +611,7 @@ begin ...@@ -401,19 +611,7 @@ begin
Exit; Exit;
end; end;
// 9. Normalize phone and activate the pending row // 9. Activate the pending row
var normalizedPhone: string := '';
try
normalizedPhone := NormalizePhoneE164(PhoneNumber);
except
on E: Exception do
begin
Result.AddPair('status', 'error');
Result.AddPair('message', E.Message);
Exit;
end;
end;
q := TUniQuery.Create(nil); q := TUniQuery.Create(nil);
try try
q.Connection := authDB.ucLemsOCSO; q.Connection := authDB.ucLemsOCSO;
...@@ -985,7 +1183,19 @@ begin ...@@ -985,7 +1183,19 @@ begin
Logger.Log(2, 'AuthService.CompleteRegistrationSimple - phone: "' + PhoneNumber + '"'); Logger.Log(2, 'AuthService.CompleteRegistrationSimple - phone: "' + PhoneNumber + '"');
if not VerifyChallengeToken(ChallengeToken, 'reg', challengeB64) then try
normalizedPhone := NormalizePhoneE164(PhoneNumber);
except
on E: Exception do
begin
Result.AddPair('status', 'error');
Result.AddPair('message', E.Message);
Exit;
end;
end;
if not VerifyChallengeToken(ChallengeToken,
'reg-' + normalizedPhone, challengeB64) then
begin begin
Result.AddPair('status', 'error'); Result.AddPair('status', 'error');
Result.AddPair('message', 'Invalid or expired registration challenge.'); Result.AddPair('message', 'Invalid or expired registration challenge.');
...@@ -999,17 +1209,6 @@ begin ...@@ -999,17 +1209,6 @@ begin
Exit; Exit;
end; end;
try
normalizedPhone := NormalizePhoneE164(PhoneNumber);
except
on E: Exception do
begin
Result.AddPair('status', 'error');
Result.AddPair('message', E.Message);
Exit;
end;
end;
q := TUniQuery.Create(nil); q := TUniQuery.Create(nil);
try try
q.Connection := authDB.ucLemsOCSO; q.Connection := authDB.ucLemsOCSO;
......
...@@ -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