Commit 27821f79 by Michael Brachmann

app links and phone number

parent 45db3678
......@@ -34,8 +34,9 @@ type
// Device management — requires valid JWT; caller must also have user_admin = true
[HttpGet] function GetDeviceList: TDeviceList;
function RevokeDevice(const CredentialId: string): TJSONObject;
function AddPendingDevice(const DeviceName: string): TJSONObject;
function DeletePendingDevice(const DeviceName: string): TJSONObject;
function AddPendingDevice(const DeviceName, PhoneNumber: string): TJSONObject;
function DeletePendingDevice(const PhoneNumber: string): TJSONObject;
function SendAppLink(const PhoneNumber: string): TJSONObject;
end;
implementation
......
......@@ -7,7 +7,8 @@ uses
System.SysUtils, System.Generics.Collections, XData.Sys.Exceptions, System.StrUtils,
System.Hash, System.Classes, Common.Logging, System.JSON, Api.Service, VCL.Forms,
Auth.Service, Uni, UniProvider, PostgreSQLUniProvider, Common.Ini,
Sparkle.HttpServer.Context, System.NetEncoding;
Sparkle.HttpServer.Context, System.NetEncoding,
System.Net.HttpClient, System.Net.URLClient, System.Classes;
type
......@@ -37,8 +38,9 @@ type
function GetComplaintMemos(const CfsId: string): TJSONObject;
function GetDeviceList: TDeviceList;
function RevokeDevice(const CredentialId: string): TJSONObject;
function AddPendingDevice(const DeviceName: string): TJSONObject;
function DeletePendingDevice(const DeviceName: string): TJSONObject;
function AddPendingDevice(const DeviceName, PhoneNumber: string): TJSONObject;
function DeletePendingDevice(const PhoneNumber: string): TJSONObject;
function SendAppLink(const PhoneNumber: string): TJSONObject;
end;
implementation
......@@ -1328,7 +1330,7 @@ begin
try
q.Connection := conn;
q.SQL.Text :=
'SELECT id, credential_id, device_name, user_agent, ' +
'SELECT id, credential_id, device_name, phone_number, user_agent, ' +
' registered_at, revoked_at, revoked_by, status ' +
'FROM lems.device_registrations ' +
'ORDER BY registered_at DESC';
......@@ -1342,6 +1344,7 @@ begin
item.id := q.FieldByName('id').AsInteger;
item.credential_id := q.FieldByName('credential_id').AsString;
item.device_name := q.FieldByName('device_name').AsString;
item.phone_number := q.FieldByName('phone_number').AsString;
item.user_agent := q.FieldByName('user_agent').AsString;
item.registered_at := q.FieldByName('registered_at').AsString;
if q.FieldByName('revoked_at').IsNull then
......@@ -1457,10 +1460,27 @@ begin
end;
function TApiService.AddPendingDevice(const DeviceName: string): TJSONObject;
function NormalizePhoneE164Api(const APhone: string): string;
var
digits: string;
i: Integer;
begin
digits := '';
for i := 1 to Length(APhone) do
if CharInSet(APhone[i], ['0'..'9']) then
digits := digits + APhone[i];
if (Length(digits) = 11) and (digits[1] = '1') then
Delete(digits, 1, 1);
if Length(digits) <> 10 then
raise Exception.Create('Invalid phone number. Enter a 10-digit US number.');
Result := '+1' + digits;
end;
function TApiService.AddPendingDevice(const DeviceName, PhoneNumber: string): TJSONObject;
var
conn: TUniConnection;
q: TUniQuery;
normalizedPhone: string;
begin
RequireAdmin;
......@@ -1474,34 +1494,46 @@ begin
Exit;
end;
try
normalizedPhone := NormalizePhoneE164Api(PhoneNumber);
except
on E: Exception do
begin
Result.AddPair('status', 'error');
Result.AddPair('message', E.Message);
Exit;
end;
end;
conn := OpenLemsConnection;
try
q := TUniQuery.Create(nil);
try
q.Connection := conn;
// Reject if a pending entry with this name already exists
// Reject if a pending entry with this phone already exists
q.SQL.Text :=
'SELECT id FROM lems.device_registrations ' +
'WHERE LOWER(device_name) = LOWER(:NAME) AND status = ''pending''';
q.ParamByName('NAME').AsString := Trim(DeviceName);
'WHERE phone_number = :PHONE AND status = ''pending''';
q.ParamByName('PHONE').AsString := normalizedPhone;
q.Open;
if not q.IsEmpty then
begin
q.Close;
Result.AddPair('status', 'error');
Result.AddPair('message', 'A pending entry for that device name already exists.');
Result.AddPair('message', 'A pending entry for that phone number already exists.');
Exit;
end;
q.Close;
q.SQL.Text :=
'INSERT INTO lems.device_registrations (device_name, status) ' +
'VALUES (:NAME, ''pending'')';
q.ParamByName('NAME').AsString := Trim(DeviceName);
'INSERT INTO lems.device_registrations (device_name, phone_number, status) ' +
'VALUES (:NAME, :PHONE, ''pending'')';
q.ParamByName('NAME').AsString := Trim(DeviceName);
q.ParamByName('PHONE').AsString := normalizedPhone;
q.ExecSQL;
Logger.Log(2, 'TApiService.AddPendingDevice - added "' + Trim(DeviceName) + '"');
Logger.Log(2, 'TApiService.AddPendingDevice - added "' + Trim(DeviceName) + '" phone: ' + normalizedPhone);
Result.AddPair('status', 'ok');
Result.AddPair('message', 'Pending device added.');
finally
......@@ -1512,21 +1544,26 @@ begin
end;
end;
function TApiService.DeletePendingDevice(const DeviceName: string): TJSONObject;
function TApiService.DeletePendingDevice(const PhoneNumber: string): TJSONObject;
var
conn: TUniConnection;
q: TUniQuery;
normalizedPhone: string;
begin
RequireAdmin;
Result := TJSONObject.Create;
TXDataOperationContext.Current.Handler.ManagedObjects.Add(Result);
if Trim(DeviceName) = '' then
begin
Result.AddPair('status', 'error');
Result.AddPair('message', 'Device name is required.');
Exit;
try
normalizedPhone := NormalizePhoneE164Api(PhoneNumber);
except
on E: Exception do
begin
Result.AddPair('status', 'error');
Result.AddPair('message', E.Message);
Exit;
end;
end;
conn := OpenLemsConnection;
......@@ -1536,13 +1573,13 @@ begin
q.Connection := conn;
q.SQL.Text :=
'DELETE FROM lems.device_registrations ' +
'WHERE LOWER(device_name) = LOWER(:NAME) AND status = ''pending''';
q.ParamByName('NAME').AsString := Trim(DeviceName);
'WHERE phone_number = :PHONE AND status = ''pending''';
q.ParamByName('PHONE').AsString := normalizedPhone;
q.ExecSQL;
if q.RowsAffected > 0 then
begin
Logger.Log(2, 'TApiService.DeletePendingDevice - deleted "' + Trim(DeviceName) + '"');
Logger.Log(2, 'TApiService.DeletePendingDevice - deleted phone: ' + normalizedPhone);
Result.AddPair('status', 'ok');
Result.AddPair('message', 'Pending device removed.');
end
......@@ -1559,6 +1596,118 @@ begin
end;
end;
function TApiService.SendAppLink(const PhoneNumber: string): TJSONObject;
var
conn: TUniConnection;
q: TUniQuery;
normalizedPhone, redeemCode, linkUrl, msgBody: string;
httpClient: THTTPClient;
bodyStream: TStringStream;
formData, authStr: string;
response: IHTTPResponse;
begin
RequireAdmin;
Result := TJSONObject.Create;
TXDataOperationContext.Current.Handler.ManagedObjects.Add(Result);
try
normalizedPhone := NormalizePhoneE164Api(PhoneNumber);
except
on E: Exception do
begin
Result.AddPair('status', 'error');
Result.AddPair('message', E.Message);
Exit;
end;
end;
if (ServerConfig.twilioAccountSid = '') or (ServerConfig.twilioAuthToken = '') or
(ServerConfig.twilioFromNumber = '') then
begin
Result.AddPair('status', 'error');
Result.AddPair('message', 'Twilio is not configured on the server.');
Exit;
end;
// Pick an unused redeem code (SKIP LOCKED for concurrent safety)
redeemCode := '';
conn := OpenLemsConnection;
try
q := TUniQuery.Create(nil);
try
q.Connection := conn;
conn.StartTransaction;
try
q.SQL.Text :=
'SELECT code FROM lems.redeem_codes ' +
'WHERE used_at IS NULL ' +
'ORDER BY id ' +
'LIMIT 1 FOR UPDATE SKIP LOCKED';
q.Open;
if q.IsEmpty then
begin
q.Close;
conn.Rollback;
Result.AddPair('status', 'error');
Result.AddPair('message', 'No unused redeem codes available.');
Exit;
end;
redeemCode := q.FieldByName('code').AsString;
q.Close;
q.SQL.Text :=
'UPDATE lems.redeem_codes SET used_at = NOW(), used_for = :PHONE ' +
'WHERE code = :CODE';
q.ParamByName('PHONE').AsString := normalizedPhone;
q.ParamByName('CODE').AsString := redeemCode;
q.ExecSQL;
conn.Commit;
except
conn.Rollback;
raise;
end;
finally
q.Free;
end;
finally
conn.Free;
end;
// Build and send Twilio SMS
linkUrl := 'https://apps.apple.com/redeem?code=' + redeemCode + '&ctx=apps';
msgBody := 'Your emiMobile app download link: ' + linkUrl;
authStr := TNetEncoding.Base64.Encode(ServerConfig.twilioAccountSid + ':' + ServerConfig.twilioAuthToken);
formData := 'From=' + TNetEncoding.URL.Encode(ServerConfig.twilioFromNumber) +
'&To=' + TNetEncoding.URL.Encode(normalizedPhone) +
'&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;
Logger.Log(2, Format('TApiService.SendAppLink - sent to %s code %s', [normalizedPhone, redeemCode]));
Result.AddPair('status', 'ok');
Result.AddPair('message', 'App link sent via SMS.');
Result.AddPair('code', redeemCode);
end;
initialization
RegisterServiceType(TApiService);
......
......@@ -45,6 +45,7 @@ type
id: Integer;
credential_id: string;
device_name: string;
phone_number: string;
user_agent: string;
registered_at: string;
revoked_at: string;
......@@ -71,10 +72,10 @@ type
[HttpGet] function GetAgencyConfigList: TAgencyConfigList;
function VerifyVersion(ClientVersion: string): TJSONObject;
// WebAuthn registration — step 1: server returns challenge + rpId/rpName
function BeginRegistration(const DeviceName: string): TJSONObject;
// WebAuthn registration — step 2: client submits credential, server verifies + stores
function CompleteRegistration(const DeviceName, CredentialId,
// WebAuthn registration — step 1: server checks phone pre-auth, returns challenge
function BeginRegistration(const PhoneNumber: string): TJSONObject;
// WebAuthn registration — step 2: client submits credential, server verifies + activates pending row
function CompleteRegistration(const PhoneNumber, CredentialId,
AttestationObject, ClientDataJSON,
ChallengeToken: string): TJSONObject;
// WebAuthn authentication challenge — called before Login
......
......@@ -36,8 +36,8 @@ type
signature: string): string;
function GetAgencieslist(): TAgenciesList;
function GetAgencyConfiglist: TAgencyConfigList;
function BeginRegistration(const DeviceName: string): TJSONObject;
function CompleteRegistration(const DeviceName, CredentialId,
function BeginRegistration(const PhoneNumber: string): TJSONObject;
function CompleteRegistration(const PhoneNumber, CredentialId,
AttestationObject, ClientDataJSON,
ChallengeToken: string): TJSONObject;
function BeginAuthentication(const CredentialId: string): TJSONObject;
......@@ -155,42 +155,69 @@ begin
Result := LowerCase(Trim(s));
end;
function NormalizePhoneE164(const APhone: string): string;
var
digits: string;
i: Integer;
begin
digits := '';
for i := 1 to Length(APhone) do
if CharInSet(APhone[i], ['0'..'9']) then
digits := digits + APhone[i];
if (Length(digits) = 11) and (digits[1] = '1') then
Delete(digits, 1, 1);
if Length(digits) <> 10 then
raise Exception.Create('Invalid phone number. Enter a 10-digit US number.');
Result := '+1' + digits;
end;
// ---------------------------------------------------------------------------
// BeginRegistration
// ---------------------------------------------------------------------------
function TAuthService.BeginRegistration(const DeviceName: string): TJSONObject;
function TAuthService.BeginRegistration(const PhoneNumber: string): TJSONObject;
var
token, challengeB64: string;
token, challengeB64, normalizedPhone: string;
q: TUniQuery;
begin
Logger.Log(2, 'AuthService.BeginRegistration - deviceName: "' + DeviceName + '"');
Logger.Log(2, 'AuthService.BeginRegistration - phone: "' + PhoneNumber + '"');
Result := TJSONObject.Create;
TXDataOperationContext.Current.Handler.ManagedObjects.Add(Result);
if Trim(DeviceName) = '' then
if Trim(PhoneNumber) = '' then
begin
Result.AddPair('status', 'error');
Result.AddPair('message', 'Device name is required.');
Result.AddPair('message', 'Phone number is required.');
Exit;
end;
// Verify device name was pre-authorized by admin
try
normalizedPhone := NormalizePhoneE164(PhoneNumber);
except
on E: Exception do
begin
Result.AddPair('status', 'error');
Result.AddPair('message', E.Message);
Exit;
end;
end;
// Verify phone number was pre-authorized by admin
q := TUniQuery.Create(nil);
try
q.Connection := authDB.ucLemsOCSO;
q.SQL.Text :=
'SELECT id FROM lems.device_registrations ' +
'WHERE LOWER(device_name) = LOWER(:NAME) AND status = ''pending''';
q.ParamByName('NAME').AsString := Trim(DeviceName);
'WHERE phone_number = :PHONE AND status = ''pending''';
q.ParamByName('PHONE').AsString := normalizedPhone;
q.Open;
if q.IsEmpty then
begin
q.Close;
Logger.Log(2, 'BeginRegistration - device name not pre-authorized: "' + DeviceName + '"');
Logger.Log(2, 'BeginRegistration - phone not pre-authorized: "' + normalizedPhone + '"');
Result.AddPair('status', 'error');
Result.AddPair('message', 'Device name not recognized. Contact your administrator.');
Result.AddPair('message', 'Phone number not recognized. Contact your administrator.');
Exit;
end;
q.Close;
......@@ -211,7 +238,7 @@ end;
// CompleteRegistration
// ---------------------------------------------------------------------------
function TAuthService.CompleteRegistration(const DeviceName, CredentialId,
function TAuthService.CompleteRegistration(const PhoneNumber, CredentialId,
AttestationObject, ClientDataJSON, ChallengeToken: string): TJSONObject;
var
challengeB64: string;
......@@ -351,7 +378,19 @@ begin
Exit;
end;
// 9. Activate the pending row — UPDATE instead of INSERT
// 9. Normalize phone and 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);
try
q.Connection := authDB.ucLemsOCSO;
......@@ -367,28 +406,28 @@ begin
' public_key_x = :KEYX, public_key_y = :KEYY, ' +
' public_key_alg = :ALG, sign_count = :CNT, ' +
' status = ''active'', registered_at = NOW() ' +
'WHERE LOWER(device_name) = LOWER(:NAME) AND status = ''pending''';
'WHERE phone_number = :PHONE AND status = ''pending''';
q.ParamByName('CID').AsString := Trim(CredentialId);
q.ParamByName('AGENT').AsString := userAgent;
q.ParamByName('KEYX').AsBytes := pubKeyX;
q.ParamByName('KEYY').AsBytes := pubKeyY;
q.ParamByName('ALG').AsInteger := pubKeyAlg;
q.ParamByName('CNT').AsInteger := Integer(signCount);
q.ParamByName('NAME').AsString := Trim(DeviceName);
q.ParamByName('PHONE').AsString := normalizedPhone;
q.ExecSQL;
if q.RowsAffected = 0 then
begin
Logger.Log(2, 'CompleteRegistration - no pending row for "' + DeviceName + '"');
Logger.Log(2, 'CompleteRegistration - no pending row for phone "' + normalizedPhone + '"');
Result.AddPair('status', 'error');
Result.AddPair('message', 'Device name is not pending registration. Contact your administrator.');
Result.AddPair('message', 'Phone number is not pending registration. Contact your administrator.');
Exit;
end;
finally
q.Free;
end;
Logger.Log(2, 'CompleteRegistration - activated credential for "' + DeviceName + '"');
Logger.Log(2, 'CompleteRegistration - activated credential for phone "' + normalizedPhone + '"');
Result.AddPair('status', 'ok');
Result.AddPair('message', 'Device registered successfully.');
Result.AddPair('credentialId', Trim(CredentialId));
......
......@@ -18,6 +18,9 @@ type
FAuditEnabled: Boolean;
FRpId: string;
FRpName: string;
FTwilioAccountSid: string;
FTwilioAuthToken: string;
FTwilioFromNumber: string;
public
constructor Create;
property url: string read FUrl write FUrl;
......@@ -31,6 +34,10 @@ type
// WebAuthn Relying Party — must match the domain serving the app (e.g. "localhost")
property rpId: string read FRpId write FRpId;
property rpName: string read FRpName write FRpName;
// Twilio SMS (for sending App Store redemption links)
property twilioAccountSid: string read FTwilioAccountSid write FTwilioAccountSid;
property twilioAuthToken: string read FTwilioAuthToken write FTwilioAuthToken;
property twilioFromNumber: string read FTwilioFromNumber write FTwilioFromNumber;
end;
procedure LoadServerConfig;
......
-- Migration: add phone_number to device_registrations + create redeem_codes table
-- Run once against the lems database (after device_registrations_pending.sql).
-- 1. Add phone_number column (nullable; unique among non-revoked rows via app logic)
ALTER TABLE lems.device_registrations
ADD COLUMN IF NOT EXISTS phone_number VARCHAR(20);
-- 2. Create redeem_codes table for App Store redemption links
CREATE TABLE IF NOT EXISTS lems.redeem_codes (
id SERIAL PRIMARY KEY,
code VARCHAR(20) NOT NULL UNIQUE,
used_at TIMESTAMPTZ,
used_for VARCHAR(20) -- E.164 phone number this code was sent to
);
-- 3. Seed redeem codes from wyoming_redeem_codes_03_2023.csv
INSERT INTO lems.redeem_codes (code) VALUES
('6KMHNH7A6LN9'),
('6MXT9NTX6L9E'),
('YAJWXLFYHL64'),
('PPYK7L6YKEER'),
('A37L4E733ENH'),
('33JAW639P3YX'),
('HHAKYY9T36PJ'),
('T9FJ7KRML43Y'),
('E73AARXT946K'),
('AEAFJT7XRJWN'),
('PXFA7E69LKNT'),
('PHKAP6M6N76T'),
('67EWNTXFKKHN'),
('3HR7479KMKJ7'),
('EYYR7Y79T3PL'),
('TYJYHY6FKX7E'),
('9N6E9X4KYHHJ'),
('FTMFLJ43ANN6'),
('R6HKEHMM3MRL'),
('7M3JNAKYTJ6H'),
('TRWMRNE6TA33'),
('3NJ7YX3NFAFX'),
('NRJTARKHMEHH'),
('EP4APXP46RWN'),
('TAMPH6EK3XA6'),
('6K9AAT7WA3PF'),
('AJXYJTL6A37W'),
('YKPA6LR3LFP6'),
('XPXRPFHH7EXY'),
('9NN693L766JN'),
('4F9EPN9YWFTW'),
('FJ9RXNNL6M3R'),
('A4HMWTR4Y6YR'),
('JL9RMJYWH3RW'),
('NYXEELXHEEWR'),
('TPTYRPWEFHLL'),
('JWLEXF3T3HPX'),
('WPYFWTJHMNEA'),
('JLYHHAR9LAJ7'),
('KW9YX4AY4F7F'),
('LMJAJHE7HH69'),
('LAWPM9WJYMLH'),
('NLFMEA744NRJ'),
('ETN39J3KYA3N'),
('6Y7R3NX6K497'),
('N3RN7WRRTEH6'),
('LJ7REJWYPHWN'),
('RWEMWAFWN4EP'),
('M4XKLLAN7YNF'),
('6NHRYRM4RTL7'),
('WR3TA9KE47JN'),
('H7TWKXYPNJEK'),
('X4XHH6AHXMET'),
('MPWKKNMKWML7'),
('J3HEW4JWRKN3'),
('E766HNR7AWAN'),
('P6PTAFJJF6WY'),
('XM7MR44FYPT4'),
('TNM64T3FAHML'),
('MEJ4YP7F9NJT'),
('LLWHYLEAXJRX'),
('TT7JPF7LRFNY'),
('FXN6EFHXLHAY'),
('M934TNKH7N6K'),
('X94WXYT9PML7'),
('L9AKMW376JTP'),
('LTLKEWWA47JT'),
('KRRNJYXPKHWH'),
('NMTNKTKRXRPR'),
('XMLFRRYA669X'),
('EM96HJHJ64YA'),
('XTF9TM94EKXH'),
('P944369MR7N4'),
('LJTFEAJFJ9F9'),
('AX749RNJNFXP'),
('XNY3W6JKHALK'),
('XLK79WPMRP7R'),
('T49KNHXKJ7L3'),
('LRHAP7LNRLNL'),
('NNJWPN69YATJ'),
('LAALW6EH3L3A'),
('FH3A7NYMX9YY'),
('F369PH7W4HYL'),
('FEAMK994PEL7'),
('9AE7A4TYKPFM'),
('4TYJHWMA99KX'),
('MPR643T4E47R'),
('3JEMLFRNTLP4'),
('K7EWYHFJ96AW'),
('P9YKYAPYHXWE'),
('3R96N4FMHTEA'),
('XJPPTEJT7YAM'),
('396PF9KKMK4P'),
('J9WJRKYNXMAR'),
('RYTTTP44RTMJ'),
('R6JHY44LKE7N'),
('L7L7K3KR9TLP'),
('AX7YFHTN63KP'),
('WTKPYRF9FMJN'),
('4YL3MN7FWFP7')
ON CONFLICT (code) DO NOTHING;
......@@ -42,9 +42,9 @@ type
procedure ClearCredentialId;
// WebAuthn registration — two-step
procedure BeginRegistration(ADeviceName: string;
procedure BeginRegistration(APhoneNumber: string;
ASuccess: TOnBeginSuccess; AError: TOnDeviceError);
procedure CompleteRegistration(ADeviceName, ACredentialId,
procedure CompleteRegistration(APhoneNumber, ACredentialId,
AAttestationObject, AClientDataJSON, AChallengeToken: string;
ASuccess: TOnDeviceSuccess; AError: TOnDeviceError);
......@@ -149,7 +149,7 @@ end;
// ---- WebAuthn registration ----
procedure TAuthService.BeginRegistration(ADeviceName: string;
procedure TAuthService.BeginRegistration(APhoneNumber: string;
ASuccess: TOnBeginSuccess; AError: TOnDeviceError);
procedure OnLoad(Response: TXDataClientResponse);
......@@ -177,12 +177,12 @@ procedure TAuthService.BeginRegistration(ADeviceName: string;
begin
FClient.RawInvoke(
'IAuthService.BeginRegistration',
[ADeviceName],
[APhoneNumber],
@OnLoad, @OnError
);
end;
procedure TAuthService.CompleteRegistration(ADeviceName, ACredentialId,
procedure TAuthService.CompleteRegistration(APhoneNumber, ACredentialId,
AAttestationObject, AClientDataJSON, AChallengeToken: string;
ASuccess: TOnDeviceSuccess; AError: TOnDeviceError);
......@@ -215,7 +215,7 @@ procedure TAuthService.CompleteRegistration(ADeviceName, ACredentialId,
begin
FClient.RawInvoke(
'IAuthService.CompleteRegistration',
[ADeviceName, ACredentialId, AAttestationObject, AClientDataJSON, AChallengeToken],
[APhoneNumber, ACredentialId, AAttestationObject, AClientDataJSON, AChallengeToken],
@OnLoad, @OnError
);
end;
......
......@@ -39,23 +39,34 @@ object FViewDeviceManager: TFViewDeviceManager
object edtNewDeviceName: TWebEdit
Left = 8
Top = 50
Width = 200
Width = 175
Height = 25
ElementID = 'view.devmgr.newname'
HeightPercent = 100.000000000000000000
WidthPercent = 100.000000000000000000
TabOrder = 1
end
object edtNewPhoneNumber: TWebEdit
Left = 192
Top = 50
Width = 155
Height = 25
ElementID = 'view.devmgr.newphone'
HeightPercent = 100.000000000000000000
TextHint = '(303) 555-1234'
WidthPercent = 100.000000000000000000
TabOrder = 2
end
object btnAddDevice: TWebButton
Left = 220
Left = 356
Top = 50
Width = 75
Width = 60
Height = 25
Caption = 'Add'
ElementID = 'view.devmgr.btnadd'
HeightPercent = 100.000000000000000000
WidthPercent = 100.000000000000000000
TabOrder = 2
TabOrder = 3
OnClick = btnAddDeviceClick
end
object XDataWebClient: TXDataWebClient
......
<div class="container-fluid p-3 h-100 d-flex flex-column">
<div class="d-flex align-items-center mb-3">
<h5 class="mb-0 me-auto">Registered Devices</h5>
<h5 class="mb-0 me-auto">Device Management</h5>
<button id="view.devmgr.btnrefresh"
class="btn btn-outline-secondary btn-sm"
onclick="document.dispatchEvent(new CustomEvent('devmgr-refresh'))">
......@@ -23,13 +23,18 @@
<!-- Add pending device -->
<div class="card mb-3">
<div class="card-body py-2">
<div class="d-flex align-items-center gap-2">
<label class="form-label mb-0 me-1 fw-semibold text-nowrap">Add Device:</label>
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="fw-semibold text-nowrap small">Add Device:</span>
<input type="text"
id="view.devmgr.newname"
class="form-control form-control-sm"
placeholder="Device name"
style="max-width: 220px;">
style="max-width:180px;">
<input type="tel"
id="view.devmgr.newphone"
class="form-control form-control-sm"
placeholder="(303) 555-1234"
style="max-width:160px;">
<button id="view.devmgr.btnadd"
class="btn btn-primary btn-sm">Add</button>
</div>
......@@ -41,18 +46,19 @@
<table class="table table-sm table-hover align-middle" id="view.devmgr.table">
<thead class="table-light sticky-top">
<tr>
<th style="min-width:140px;">Device Name</th>
<th style="min-width:130px;">Device Name</th>
<th style="min-width:120px;">Phone</th>
<th>Browser / User Agent</th>
<th style="min-width:135px;">Registered</th>
<th style="min-width:80px;">Status</th>
<th style="min-width:80px;">Action</th>
<th style="min-width:160px;">Actions</th>
</tr>
</thead>
<tbody id="view.devmgr.tbody">
</tbody>
</table>
<p id="view.devmgr.empty" class="text-muted d-none text-center py-4">
No devices registered.
No devices found.
</p>
</div>
......
......@@ -8,14 +8,14 @@ object FViewDeviceRegistration: TFViewDeviceRegistration
Font.Style = []
ParentFont = False
OnCreate = WebFormCreate
object edtDeviceName: TWebEdit
object edtPhoneNumber: TWebEdit
Left = 240
Top = 136
Width = 121
Height = 21
ElementID = 'view.devicereg.edtdevicename'
ElementID = 'view.devicereg.edtphonenumber'
HeightPercent = 100.000000000000000000
TextHint = 'Device name (optional)'
TextHint = '(303) 555-1234'
WidthPercent = 100.000000000000000000
end
object btnRegister: TWebButton
......
......@@ -33,17 +33,17 @@
<p class="text-muted small mb-3">
This browser has not been registered for emiMobile access.
Give the device a recognisable name, then click <strong>Register</strong>.
Enter the phone number your administrator registered for this device,
then click <strong>Register</strong>.
Your browser will prompt you to verify with a PIN, fingerprint, or security key.
An administrator can revoke access for any registered device at any time.
</p>
<div class="mb-3">
<label class="form-label small text-muted">Device name</label>
<input id="view.devicereg.edtdevicename"
<label class="form-label small text-muted">Phone number</label>
<input id="view.devicereg.edtphonenumber"
class="form-control"
type="text"
placeholder="e.g. Dispatch Console, Patrol Laptop"
type="tel"
placeholder="(303) 555-1234"
autofocus>
</div>
......
......@@ -10,7 +10,7 @@ uses
type
TFViewDeviceRegistration = class(TWebForm)
edtDeviceName: TWebEdit;
edtPhoneNumber: TWebEdit;
btnRegister: TWebButton;
pnlMessage: TWebPanel;
lblMessage: TWebLabel;
......@@ -24,7 +24,7 @@ type
procedure ShowNotification(const AMsg: string; AIsError: Boolean = True);
procedure HideNotification;
procedure SetBusy(ABusy: Boolean);
procedure DoWebAuthnCreate(ADeviceName, AChallenge, AChallengeToken: string);
procedure DoWebAuthnCreate(APhoneNumber, AChallenge, AChallengeToken: string);
public
class procedure Display(ARegistrationProc: TSuccessProc);
end;
......@@ -60,6 +60,23 @@ begin
asm
var el = document.getElementById('view.devicereg.useragent');
if (el) el.textContent = userAgent;
// Format phone number on-the-fly as the user types
var inp = document.getElementById('view.devicereg.edtphonenumber');
if (inp) {
inp.addEventListener('input', function() {
var digits = inp.value.replace(/\D/g, '');
if (digits.length > 10) digits = digits.slice(0, 10);
var formatted = '';
if (digits.length > 6)
formatted = '(' + digits.slice(0,3) + ') ' + digits.slice(3,6) + '-' + digits.slice(6);
else if (digits.length > 3)
formatted = '(' + digits.slice(0,3) + ') ' + digits.slice(3);
else if (digits.length > 0)
formatted = '(' + digits;
inp.value = formatted;
});
}
end;
end;
......@@ -76,11 +93,11 @@ end;
procedure TFViewDeviceRegistration.btnRegisterClick(Sender: TObject);
var
deviceName: string;
phoneNumber: string;
procedure OnBeginOK(AChallenge, AChallengeToken: string);
begin
DoWebAuthnCreate(deviceName, AChallenge, AChallengeToken);
DoWebAuthnCreate(phoneNumber, AChallenge, AChallengeToken);
end;
procedure OnBeginError(AMsg: string);
......@@ -90,24 +107,26 @@ var
end;
begin
deviceName := Trim(edtDeviceName.Text);
if deviceName = '' then
deviceName := 'Unnamed Device';
phoneNumber := Trim(edtPhoneNumber.Text);
if phoneNumber = '' then
begin
ShowNotification('Please enter your phone number.');
Exit;
end;
SetBusy(True);
HideNotification;
AuthService.BeginRegistration(deviceName, @OnBeginOK, @OnBeginError);
AuthService.BeginRegistration(phoneNumber, @OnBeginOK, @OnBeginError);
end;
procedure TFViewDeviceRegistration.DoWebAuthnCreate(
ADeviceName, AChallenge, AChallengeToken: string);
APhoneNumber, AChallenge, AChallengeToken: string);
var
deviceName, challenge, challengeToken: string;
phoneNumber, challenge, challengeToken: string;
procedure OnCompleteOK;
begin
// Registration complete — proceed to login
FRegistrationProc;
end;
......@@ -120,7 +139,7 @@ var
procedure OnCredential(ACredentialId, AAttestationObject, AClientDataJSON: string);
begin
AuthService.CompleteRegistration(
deviceName, ACredentialId, AAttestationObject, AClientDataJSON, challengeToken,
phoneNumber, ACredentialId, AAttestationObject, AClientDataJSON, challengeToken,
@OnCompleteOK, @OnCompleteError
);
end;
......@@ -132,14 +151,12 @@ var
end;
begin
deviceName := ADeviceName;
phoneNumber := APhoneNumber;
challenge := AChallenge;
challengeToken := AChallengeToken;
// Call navigator.credentials.create() via WebAuthn API
asm
(function() {
// Decode base64url challenge to Uint8Array
function b64urlToArr(b64) {
b64 = b64.replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4) b64 += '=';
......@@ -148,13 +165,11 @@ begin
for (var i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return arr;
}
// Encode ArrayBuffer to base64url
function arrToB64url(buf) {
var bin = String.fromCharCode.apply(null, new Uint8Array(buf));
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
// Random 16-byte user handle (not linked to user identity for platform keys)
var userId = new Uint8Array(16);
crypto.getRandomValues(userId);
......
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