Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
E
emiMobile
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Mac Stephens
emiMobile
Commits
27821f79
Commit
27821f79
authored
Sep 02, 2026
by
Michael Brachmann
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
app links and phone number
parent
45db3678
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
13 changed files
with
433 additions
and
86 deletions
+433
-86
Api.Service.pas
emiMobileServer/Source/Api.Service.pas
+3
-2
Api.ServiceImpl.pas
emiMobileServer/Source/Api.ServiceImpl.pas
+167
-18
Auth.Service.pas
emiMobileServer/Source/Auth.Service.pas
+5
-4
Auth.ServiceImpl.pas
emiMobileServer/Source/Auth.ServiceImpl.pas
+58
-19
Common.Config.pas
emiMobileServer/Source/Common.Config.pas
+7
-0
device_registrations_phone.sql
emiMobileServer/device_registrations_phone.sql
+118
-0
Auth.Service.pas
webEMIMobile/Auth.Service.pas
+6
-6
View.DeviceManager.dfm
webEMIMobile/View.DeviceManager.dfm
+15
-4
View.DeviceManager.html
webEMIMobile/View.DeviceManager.html
+13
-7
View.DeviceManager.pas
webEMIMobile/View.DeviceManager.pas
+0
-0
View.DeviceRegistration.dfm
webEMIMobile/View.DeviceRegistration.dfm
+3
-3
View.DeviceRegistration.html
webEMIMobile/View.DeviceRegistration.html
+6
-6
View.DeviceRegistration.pas
webEMIMobile/View.DeviceRegistration.pas
+32
-17
No files found.
emiMobileServer/Source/Api.Service.pas
View file @
27821f79
...
@@ -34,8 +34,9 @@ type
...
@@ -34,8 +34,9 @@ type
// Device management — requires valid JWT; caller must also have user_admin = true
// Device management — requires valid JWT; caller must also have user_admin = true
[
HttpGet
]
function
GetDeviceList
:
TDeviceList
;
[
HttpGet
]
function
GetDeviceList
:
TDeviceList
;
function
RevokeDevice
(
const
CredentialId
:
string
):
TJSONObject
;
function
RevokeDevice
(
const
CredentialId
:
string
):
TJSONObject
;
function
AddPendingDevice
(
const
DeviceName
:
string
):
TJSONObject
;
function
AddPendingDevice
(
const
DeviceName
,
PhoneNumber
:
string
):
TJSONObject
;
function
DeletePendingDevice
(
const
DeviceName
:
string
):
TJSONObject
;
function
DeletePendingDevice
(
const
PhoneNumber
:
string
):
TJSONObject
;
function
SendAppLink
(
const
PhoneNumber
:
string
):
TJSONObject
;
end
;
end
;
implementation
implementation
...
...
emiMobileServer/Source/Api.ServiceImpl.pas
View file @
27821f79
...
@@ -7,7 +7,8 @@ uses
...
@@ -7,7 +7,8 @@ uses
System
.
SysUtils
,
System
.
Generics
.
Collections
,
XData
.
Sys
.
Exceptions
,
System
.
StrUtils
,
System
.
SysUtils
,
System
.
Generics
.
Collections
,
XData
.
Sys
.
Exceptions
,
System
.
StrUtils
,
System
.
Hash
,
System
.
Classes
,
Common
.
Logging
,
System
.
JSON
,
Api
.
Service
,
VCL
.
Forms
,
System
.
Hash
,
System
.
Classes
,
Common
.
Logging
,
System
.
JSON
,
Api
.
Service
,
VCL
.
Forms
,
Auth
.
Service
,
Uni
,
UniProvider
,
PostgreSQLUniProvider
,
Common
.
Ini
,
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
type
...
@@ -37,8 +38,9 @@ type
...
@@ -37,8 +38,9 @@ type
function
GetComplaintMemos
(
const
CfsId
:
string
):
TJSONObject
;
function
GetComplaintMemos
(
const
CfsId
:
string
):
TJSONObject
;
function
GetDeviceList
:
TDeviceList
;
function
GetDeviceList
:
TDeviceList
;
function
RevokeDevice
(
const
CredentialId
:
string
):
TJSONObject
;
function
RevokeDevice
(
const
CredentialId
:
string
):
TJSONObject
;
function
AddPendingDevice
(
const
DeviceName
:
string
):
TJSONObject
;
function
AddPendingDevice
(
const
DeviceName
,
PhoneNumber
:
string
):
TJSONObject
;
function
DeletePendingDevice
(
const
DeviceName
:
string
):
TJSONObject
;
function
DeletePendingDevice
(
const
PhoneNumber
:
string
):
TJSONObject
;
function
SendAppLink
(
const
PhoneNumber
:
string
):
TJSONObject
;
end
;
end
;
implementation
implementation
...
@@ -1328,7 +1330,7 @@ begin
...
@@ -1328,7 +1330,7 @@ begin
try
try
q
.
Connection
:=
conn
;
q
.
Connection
:=
conn
;
q
.
SQL
.
Text
:=
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 '
+
' registered_at, revoked_at, revoked_by, status '
+
'FROM lems.device_registrations '
+
'FROM lems.device_registrations '
+
'ORDER BY registered_at DESC'
;
'ORDER BY registered_at DESC'
;
...
@@ -1342,6 +1344,7 @@ begin
...
@@ -1342,6 +1344,7 @@ begin
item
.
id
:=
q
.
FieldByName
(
'id'
).
AsInteger
;
item
.
id
:=
q
.
FieldByName
(
'id'
).
AsInteger
;
item
.
credential_id
:=
q
.
FieldByName
(
'credential_id'
).
AsString
;
item
.
credential_id
:=
q
.
FieldByName
(
'credential_id'
).
AsString
;
item
.
device_name
:=
q
.
FieldByName
(
'device_name'
).
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
.
user_agent
:=
q
.
FieldByName
(
'user_agent'
).
AsString
;
item
.
registered_at
:=
q
.
FieldByName
(
'registered_at'
).
AsString
;
item
.
registered_at
:=
q
.
FieldByName
(
'registered_at'
).
AsString
;
if
q
.
FieldByName
(
'revoked_at'
).
IsNull
then
if
q
.
FieldByName
(
'revoked_at'
).
IsNull
then
...
@@ -1457,10 +1460,27 @@ begin
...
@@ -1457,10 +1460,27 @@ begin
end
;
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
var
conn
:
TUniConnection
;
conn
:
TUniConnection
;
q
:
TUniQuery
;
q
:
TUniQuery
;
normalizedPhone
:
string
;
begin
begin
RequireAdmin
;
RequireAdmin
;
...
@@ -1474,34 +1494,46 @@ begin
...
@@ -1474,34 +1494,46 @@ begin
Exit
;
Exit
;
end
;
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
;
conn
:=
OpenLemsConnection
;
try
try
q
:=
TUniQuery
.
Create
(
nil
);
q
:=
TUniQuery
.
Create
(
nil
);
try
try
q
.
Connection
:=
conn
;
q
.
Connection
:=
conn
;
// Reject if a pending entry with this
nam
e already exists
// Reject if a pending entry with this
phon
e already exists
q
.
SQL
.
Text
:=
q
.
SQL
.
Text
:=
'SELECT id FROM lems.device_registrations '
+
'SELECT id FROM lems.device_registrations '
+
'WHERE
LOWER(device_name) = LOWER(:NAME)
AND status = ''pending'''
;
'WHERE
phone_number = :PHONE
AND status = ''pending'''
;
q
.
ParamByName
(
'
NAME'
).
AsString
:=
Trim
(
DeviceName
)
;
q
.
ParamByName
(
'
PHONE'
).
AsString
:=
normalizedPhone
;
q
.
Open
;
q
.
Open
;
if
not
q
.
IsEmpty
then
if
not
q
.
IsEmpty
then
begin
begin
q
.
Close
;
q
.
Close
;
Result
.
AddPair
(
'status'
,
'error'
);
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
;
Exit
;
end
;
end
;
q
.
Close
;
q
.
Close
;
q
.
SQL
.
Text
:=
q
.
SQL
.
Text
:=
'INSERT INTO lems.device_registrations (device_name, status) '
+
'INSERT INTO lems.device_registrations (device_name,
phone_number,
status) '
+
'VALUES (:NAME, ''pending'')'
;
'VALUES (:NAME,
:PHONE,
''pending'')'
;
q
.
ParamByName
(
'NAME'
).
AsString
:=
Trim
(
DeviceName
);
q
.
ParamByName
(
'NAME'
).
AsString
:=
Trim
(
DeviceName
);
q
.
ParamByName
(
'PHONE'
).
AsString
:=
normalizedPhone
;
q
.
ExecSQL
;
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
(
'status'
,
'ok'
);
Result
.
AddPair
(
'message'
,
'Pending device added.'
);
Result
.
AddPair
(
'message'
,
'Pending device added.'
);
finally
finally
...
@@ -1512,22 +1544,27 @@ begin
...
@@ -1512,22 +1544,27 @@ begin
end
;
end
;
end
;
end
;
function
TApiService
.
DeletePendingDevice
(
const
DeviceName
:
string
):
TJSONObject
;
function
TApiService
.
DeletePendingDevice
(
const
PhoneNumber
:
string
):
TJSONObject
;
var
var
conn
:
TUniConnection
;
conn
:
TUniConnection
;
q
:
TUniQuery
;
q
:
TUniQuery
;
normalizedPhone
:
string
;
begin
begin
RequireAdmin
;
RequireAdmin
;
Result
:=
TJSONObject
.
Create
;
Result
:=
TJSONObject
.
Create
;
TXDataOperationContext
.
Current
.
Handler
.
ManagedObjects
.
Add
(
Result
);
TXDataOperationContext
.
Current
.
Handler
.
ManagedObjects
.
Add
(
Result
);
if
Trim
(
DeviceName
)
=
''
then
try
normalizedPhone
:=
NormalizePhoneE164Api
(
PhoneNumber
);
except
on
E
:
Exception
do
begin
begin
Result
.
AddPair
(
'status'
,
'error'
);
Result
.
AddPair
(
'status'
,
'error'
);
Result
.
AddPair
(
'message'
,
'Device name is required.'
);
Result
.
AddPair
(
'message'
,
E
.
Message
);
Exit
;
Exit
;
end
;
end
;
end
;
conn
:=
OpenLemsConnection
;
conn
:=
OpenLemsConnection
;
try
try
...
@@ -1536,13 +1573,13 @@ begin
...
@@ -1536,13 +1573,13 @@ begin
q
.
Connection
:=
conn
;
q
.
Connection
:=
conn
;
q
.
SQL
.
Text
:=
q
.
SQL
.
Text
:=
'DELETE FROM lems.device_registrations '
+
'DELETE FROM lems.device_registrations '
+
'WHERE
LOWER(device_name) = LOWER(:NAME)
AND status = ''pending'''
;
'WHERE
phone_number = :PHONE
AND status = ''pending'''
;
q
.
ParamByName
(
'
NAME'
).
AsString
:=
Trim
(
DeviceName
)
;
q
.
ParamByName
(
'
PHONE'
).
AsString
:=
normalizedPhone
;
q
.
ExecSQL
;
q
.
ExecSQL
;
if
q
.
RowsAffected
>
0
then
if
q
.
RowsAffected
>
0
then
begin
begin
Logger
.
Log
(
2
,
'TApiService.DeletePendingDevice - deleted
"'
+
Trim
(
DeviceName
)
+
'"'
);
Logger
.
Log
(
2
,
'TApiService.DeletePendingDevice - deleted
phone: '
+
normalizedPhone
);
Result
.
AddPair
(
'status'
,
'ok'
);
Result
.
AddPair
(
'status'
,
'ok'
);
Result
.
AddPair
(
'message'
,
'Pending device removed.'
);
Result
.
AddPair
(
'message'
,
'Pending device removed.'
);
end
end
...
@@ -1559,6 +1596,118 @@ begin
...
@@ -1559,6 +1596,118 @@ begin
end
;
end
;
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
initialization
RegisterServiceType
(
TApiService
);
RegisterServiceType
(
TApiService
);
...
...
emiMobileServer/Source/Auth.Service.pas
View file @
27821f79
...
@@ -45,6 +45,7 @@ type
...
@@ -45,6 +45,7 @@ type
id
:
Integer
;
id
:
Integer
;
credential_id
:
string
;
credential_id
:
string
;
device_name
:
string
;
device_name
:
string
;
phone_number
:
string
;
user_agent
:
string
;
user_agent
:
string
;
registered_at
:
string
;
registered_at
:
string
;
revoked_at
:
string
;
revoked_at
:
string
;
...
@@ -71,10 +72,10 @@ type
...
@@ -71,10 +72,10 @@ type
[
HttpGet
]
function
GetAgencyConfigList
:
TAgencyConfigList
;
[
HttpGet
]
function
GetAgencyConfigList
:
TAgencyConfigList
;
function
VerifyVersion
(
ClientVersion
:
string
):
TJSONObject
;
function
VerifyVersion
(
ClientVersion
:
string
):
TJSONObject
;
// WebAuthn registration — step 1: server
returns challenge + rpId/rpNam
e
// WebAuthn registration — step 1: server
checks phone pre-auth, returns challeng
e
function
BeginRegistration
(
const
DeviceName
:
string
):
TJSONObject
;
function
BeginRegistration
(
const
PhoneNumber
:
string
):
TJSONObject
;
// WebAuthn registration — step 2: client submits credential, server verifies +
stores
// WebAuthn registration — step 2: client submits credential, server verifies +
activates pending row
function
CompleteRegistration
(
const
DeviceName
,
CredentialId
,
function
CompleteRegistration
(
const
PhoneNumber
,
CredentialId
,
AttestationObject
,
ClientDataJSON
,
AttestationObject
,
ClientDataJSON
,
ChallengeToken
:
string
):
TJSONObject
;
ChallengeToken
:
string
):
TJSONObject
;
// WebAuthn authentication challenge — called before Login
// WebAuthn authentication challenge — called before Login
...
...
emiMobileServer/Source/Auth.ServiceImpl.pas
View file @
27821f79
...
@@ -36,8 +36,8 @@ type
...
@@ -36,8 +36,8 @@ type
signature
:
string
):
string
;
signature
:
string
):
string
;
function
GetAgencieslist
():
TAgenciesList
;
function
GetAgencieslist
():
TAgenciesList
;
function
GetAgencyConfiglist
:
TAgencyConfigList
;
function
GetAgencyConfiglist
:
TAgencyConfigList
;
function
BeginRegistration
(
const
DeviceName
:
string
):
TJSONObject
;
function
BeginRegistration
(
const
PhoneNumber
:
string
):
TJSONObject
;
function
CompleteRegistration
(
const
DeviceName
,
CredentialId
,
function
CompleteRegistration
(
const
PhoneNumber
,
CredentialId
,
AttestationObject
,
ClientDataJSON
,
AttestationObject
,
ClientDataJSON
,
ChallengeToken
:
string
):
TJSONObject
;
ChallengeToken
:
string
):
TJSONObject
;
function
BeginAuthentication
(
const
CredentialId
:
string
):
TJSONObject
;
function
BeginAuthentication
(
const
CredentialId
:
string
):
TJSONObject
;
...
@@ -155,42 +155,69 @@ begin
...
@@ -155,42 +155,69 @@ begin
Result
:=
LowerCase
(
Trim
(
s
));
Result
:=
LowerCase
(
Trim
(
s
));
end
;
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
// BeginRegistration
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
function
TAuthService
.
BeginRegistration
(
const
DeviceName
:
string
):
TJSONObject
;
function
TAuthService
.
BeginRegistration
(
const
PhoneNumber
:
string
):
TJSONObject
;
var
var
token
,
challengeB64
:
string
;
token
,
challengeB64
,
normalizedPhone
:
string
;
q
:
TUniQuery
;
q
:
TUniQuery
;
begin
begin
Logger
.
Log
(
2
,
'AuthService.BeginRegistration -
deviceName: "'
+
DeviceName
+
'"'
);
Logger
.
Log
(
2
,
'AuthService.BeginRegistration -
phone: "'
+
PhoneNumber
+
'"'
);
Result
:=
TJSONObject
.
Create
;
Result
:=
TJSONObject
.
Create
;
TXDataOperationContext
.
Current
.
Handler
.
ManagedObjects
.
Add
(
Result
);
TXDataOperationContext
.
Current
.
Handler
.
ManagedObjects
.
Add
(
Result
);
if
Trim
(
DeviceName
)
=
''
then
if
Trim
(
PhoneNumber
)
=
''
then
begin
begin
Result
.
AddPair
(
'status'
,
'error'
);
Result
.
AddPair
(
'status'
,
'error'
);
Result
.
AddPair
(
'message'
,
'
Device name
is required.'
);
Result
.
AddPair
(
'message'
,
'
Phone number
is required.'
);
Exit
;
Exit
;
end
;
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
);
q
:=
TUniQuery
.
Create
(
nil
);
try
try
q
.
Connection
:=
authDB
.
ucLemsOCSO
;
q
.
Connection
:=
authDB
.
ucLemsOCSO
;
q
.
SQL
.
Text
:=
q
.
SQL
.
Text
:=
'SELECT id FROM lems.device_registrations '
+
'SELECT id FROM lems.device_registrations '
+
'WHERE
LOWER(device_name) = LOWER(:NAME)
AND status = ''pending'''
;
'WHERE
phone_number = :PHONE
AND status = ''pending'''
;
q
.
ParamByName
(
'
NAME'
).
AsString
:=
Trim
(
DeviceName
)
;
q
.
ParamByName
(
'
PHONE'
).
AsString
:=
normalizedPhone
;
q
.
Open
;
q
.
Open
;
if
q
.
IsEmpty
then
if
q
.
IsEmpty
then
begin
begin
q
.
Close
;
q
.
Close
;
Logger
.
Log
(
2
,
'BeginRegistration -
device name not pre-authorized: "'
+
DeviceNam
e
+
'"'
);
Logger
.
Log
(
2
,
'BeginRegistration -
phone not pre-authorized: "'
+
normalizedPhon
e
+
'"'
);
Result
.
AddPair
(
'status'
,
'error'
);
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
;
Exit
;
end
;
end
;
q
.
Close
;
q
.
Close
;
...
@@ -211,7 +238,7 @@ end;
...
@@ -211,7 +238,7 @@ end;
// CompleteRegistration
// CompleteRegistration
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
function
TAuthService
.
CompleteRegistration
(
const
DeviceName
,
CredentialId
,
function
TAuthService
.
CompleteRegistration
(
const
PhoneNumber
,
CredentialId
,
AttestationObject
,
ClientDataJSON
,
ChallengeToken
:
string
):
TJSONObject
;
AttestationObject
,
ClientDataJSON
,
ChallengeToken
:
string
):
TJSONObject
;
var
var
challengeB64
:
string
;
challengeB64
:
string
;
...
@@ -351,7 +378,19 @@ begin
...
@@ -351,7 +378,19 @@ begin
Exit
;
Exit
;
end
;
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
);
q
:=
TUniQuery
.
Create
(
nil
);
try
try
q
.
Connection
:=
authDB
.
ucLemsOCSO
;
q
.
Connection
:=
authDB
.
ucLemsOCSO
;
...
@@ -367,28 +406,28 @@ begin
...
@@ -367,28 +406,28 @@ begin
' public_key_x = :KEYX, public_key_y = :KEYY, '
+
' public_key_x = :KEYX, public_key_y = :KEYY, '
+
' public_key_alg = :ALG, sign_count = :CNT, '
+
' public_key_alg = :ALG, sign_count = :CNT, '
+
' status = ''active'', registered_at = NOW() '
+
' 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
(
'CID'
).
AsString
:=
Trim
(
CredentialId
);
q
.
ParamByName
(
'AGENT'
).
AsString
:=
userAgent
;
q
.
ParamByName
(
'AGENT'
).
AsString
:=
userAgent
;
q
.
ParamByName
(
'KEYX'
).
AsBytes
:=
pubKeyX
;
q
.
ParamByName
(
'KEYX'
).
AsBytes
:=
pubKeyX
;
q
.
ParamByName
(
'KEYY'
).
AsBytes
:=
pubKeyY
;
q
.
ParamByName
(
'KEYY'
).
AsBytes
:=
pubKeyY
;
q
.
ParamByName
(
'ALG'
).
AsInteger
:=
pubKeyAlg
;
q
.
ParamByName
(
'ALG'
).
AsInteger
:=
pubKeyAlg
;
q
.
ParamByName
(
'CNT'
).
AsInteger
:=
Integer
(
signCount
);
q
.
ParamByName
(
'CNT'
).
AsInteger
:=
Integer
(
signCount
);
q
.
ParamByName
(
'
NAME'
).
AsString
:=
Trim
(
DeviceName
)
;
q
.
ParamByName
(
'
PHONE'
).
AsString
:=
normalizedPhone
;
q
.
ExecSQL
;
q
.
ExecSQL
;
if
q
.
RowsAffected
=
0
then
if
q
.
RowsAffected
=
0
then
begin
begin
Logger
.
Log
(
2
,
'CompleteRegistration - no pending row for
"'
+
DeviceNam
e
+
'"'
);
Logger
.
Log
(
2
,
'CompleteRegistration - no pending row for
phone "'
+
normalizedPhon
e
+
'"'
);
Result
.
AddPair
(
'status'
,
'error'
);
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
;
Exit
;
end
;
end
;
finally
finally
q
.
Free
;
q
.
Free
;
end
;
end
;
Logger
.
Log
(
2
,
'CompleteRegistration - activated credential for
"'
+
DeviceNam
e
+
'"'
);
Logger
.
Log
(
2
,
'CompleteRegistration - activated credential for
phone "'
+
normalizedPhon
e
+
'"'
);
Result
.
AddPair
(
'status'
,
'ok'
);
Result
.
AddPair
(
'status'
,
'ok'
);
Result
.
AddPair
(
'message'
,
'Device registered successfully.'
);
Result
.
AddPair
(
'message'
,
'Device registered successfully.'
);
Result
.
AddPair
(
'credentialId'
,
Trim
(
CredentialId
));
Result
.
AddPair
(
'credentialId'
,
Trim
(
CredentialId
));
...
...
emiMobileServer/Source/Common.Config.pas
View file @
27821f79
...
@@ -18,6 +18,9 @@ type
...
@@ -18,6 +18,9 @@ type
FAuditEnabled
:
Boolean
;
FAuditEnabled
:
Boolean
;
FRpId
:
string
;
FRpId
:
string
;
FRpName
:
string
;
FRpName
:
string
;
FTwilioAccountSid
:
string
;
FTwilioAuthToken
:
string
;
FTwilioFromNumber
:
string
;
public
public
constructor
Create
;
constructor
Create
;
property
url
:
string
read
FUrl
write
FUrl
;
property
url
:
string
read
FUrl
write
FUrl
;
...
@@ -31,6 +34,10 @@ type
...
@@ -31,6 +34,10 @@ type
// WebAuthn Relying Party — must match the domain serving the app (e.g. "localhost")
// WebAuthn Relying Party — must match the domain serving the app (e.g. "localhost")
property
rpId
:
string
read
FRpId
write
FRpId
;
property
rpId
:
string
read
FRpId
write
FRpId
;
property
rpName
:
string
read
FRpName
write
FRpName
;
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
;
end
;
procedure
LoadServerConfig
;
procedure
LoadServerConfig
;
...
...
emiMobileServer/device_registrations_phone.sql
0 → 100644
View file @
27821f79
-- 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
;
webEMIMobile/Auth.Service.pas
View file @
27821f79
...
@@ -42,9 +42,9 @@ type
...
@@ -42,9 +42,9 @@ type
procedure
ClearCredentialId
;
procedure
ClearCredentialId
;
// WebAuthn registration — two-step
// WebAuthn registration — two-step
procedure
BeginRegistration
(
A
DeviceName
:
string
;
procedure
BeginRegistration
(
A
PhoneNumber
:
string
;
ASuccess
:
TOnBeginSuccess
;
AError
:
TOnDeviceError
);
ASuccess
:
TOnBeginSuccess
;
AError
:
TOnDeviceError
);
procedure
CompleteRegistration
(
A
DeviceName
,
ACredentialId
,
procedure
CompleteRegistration
(
A
PhoneNumber
,
ACredentialId
,
AAttestationObject
,
AClientDataJSON
,
AChallengeToken
:
string
;
AAttestationObject
,
AClientDataJSON
,
AChallengeToken
:
string
;
ASuccess
:
TOnDeviceSuccess
;
AError
:
TOnDeviceError
);
ASuccess
:
TOnDeviceSuccess
;
AError
:
TOnDeviceError
);
...
@@ -149,7 +149,7 @@ end;
...
@@ -149,7 +149,7 @@ end;
// ---- WebAuthn registration ----
// ---- WebAuthn registration ----
procedure
TAuthService
.
BeginRegistration
(
A
DeviceName
:
string
;
procedure
TAuthService
.
BeginRegistration
(
A
PhoneNumber
:
string
;
ASuccess
:
TOnBeginSuccess
;
AError
:
TOnDeviceError
);
ASuccess
:
TOnBeginSuccess
;
AError
:
TOnDeviceError
);
procedure
OnLoad
(
Response
:
TXDataClientResponse
);
procedure
OnLoad
(
Response
:
TXDataClientResponse
);
...
@@ -177,12 +177,12 @@ procedure TAuthService.BeginRegistration(ADeviceName: string;
...
@@ -177,12 +177,12 @@ procedure TAuthService.BeginRegistration(ADeviceName: string;
begin
begin
FClient
.
RawInvoke
(
FClient
.
RawInvoke
(
'IAuthService.BeginRegistration'
,
'IAuthService.BeginRegistration'
,
[
A
DeviceName
],
[
A
PhoneNumber
],
@
OnLoad
,
@
OnError
@
OnLoad
,
@
OnError
);
);
end
;
end
;
procedure
TAuthService
.
CompleteRegistration
(
A
DeviceName
,
ACredentialId
,
procedure
TAuthService
.
CompleteRegistration
(
A
PhoneNumber
,
ACredentialId
,
AAttestationObject
,
AClientDataJSON
,
AChallengeToken
:
string
;
AAttestationObject
,
AClientDataJSON
,
AChallengeToken
:
string
;
ASuccess
:
TOnDeviceSuccess
;
AError
:
TOnDeviceError
);
ASuccess
:
TOnDeviceSuccess
;
AError
:
TOnDeviceError
);
...
@@ -215,7 +215,7 @@ procedure TAuthService.CompleteRegistration(ADeviceName, ACredentialId,
...
@@ -215,7 +215,7 @@ procedure TAuthService.CompleteRegistration(ADeviceName, ACredentialId,
begin
begin
FClient
.
RawInvoke
(
FClient
.
RawInvoke
(
'IAuthService.CompleteRegistration'
,
'IAuthService.CompleteRegistration'
,
[
A
DeviceName
,
ACredentialId
,
AAttestationObject
,
AClientDataJSON
,
AChallengeToken
],
[
A
PhoneNumber
,
ACredentialId
,
AAttestationObject
,
AClientDataJSON
,
AChallengeToken
],
@
OnLoad
,
@
OnError
@
OnLoad
,
@
OnError
);
);
end
;
end
;
...
...
webEMIMobile/View.DeviceManager.dfm
View file @
27821f79
...
@@ -39,23 +39,34 @@ object FViewDeviceManager: TFViewDeviceManager
...
@@ -39,23 +39,34 @@ object FViewDeviceManager: TFViewDeviceManager
object edtNewDeviceName: TWebEdit
object edtNewDeviceName: TWebEdit
Left = 8
Left = 8
Top = 50
Top = 50
Width =
200
Width =
175
Height = 25
Height = 25
ElementID = 'view.devmgr.newname'
ElementID = 'view.devmgr.newname'
HeightPercent = 100.000000000000000000
HeightPercent = 100.000000000000000000
WidthPercent = 100.000000000000000000
WidthPercent = 100.000000000000000000
TabOrder = 1
TabOrder = 1
end
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
object btnAddDevice: TWebButton
Left =
220
Left =
356
Top = 50
Top = 50
Width =
75
Width =
60
Height = 25
Height = 25
Caption = 'Add'
Caption = 'Add'
ElementID = 'view.devmgr.btnadd'
ElementID = 'view.devmgr.btnadd'
HeightPercent = 100.000000000000000000
HeightPercent = 100.000000000000000000
WidthPercent = 100.000000000000000000
WidthPercent = 100.000000000000000000
TabOrder =
2
TabOrder =
3
OnClick = btnAddDeviceClick
OnClick = btnAddDeviceClick
end
end
object XDataWebClient: TXDataWebClient
object XDataWebClient: TXDataWebClient
...
...
webEMIMobile/View.DeviceManager.html
View file @
27821f79
<div
class=
"container-fluid p-3 h-100 d-flex flex-column"
>
<div
class=
"container-fluid p-3 h-100 d-flex flex-column"
>
<div
class=
"d-flex align-items-center mb-3"
>
<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"
<button
id=
"view.devmgr.btnrefresh"
class=
"btn btn-outline-secondary btn-sm"
class=
"btn btn-outline-secondary btn-sm"
onclick=
"document.dispatchEvent(new CustomEvent('devmgr-refresh'))"
>
onclick=
"document.dispatchEvent(new CustomEvent('devmgr-refresh'))"
>
...
@@ -23,13 +23,18 @@
...
@@ -23,13 +23,18 @@
<!-- Add pending device -->
<!-- Add pending device -->
<div
class=
"card mb-3"
>
<div
class=
"card mb-3"
>
<div
class=
"card-body py-2"
>
<div
class=
"card-body py-2"
>
<div
class=
"d-flex align-items-center gap-2"
>
<div
class=
"d-flex align-items-center gap-2
flex-wrap
"
>
<
label
class=
"form-label mb-0 me-1 fw-semibold text-nowrap"
>
Add Device:
</label
>
<
span
class=
"fw-semibold text-nowrap small"
>
Add Device:
</span
>
<input
type=
"text"
<input
type=
"text"
id=
"view.devmgr.newname"
id=
"view.devmgr.newname"
class=
"form-control form-control-sm"
class=
"form-control form-control-sm"
placeholder=
"Device name"
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"
<button
id=
"view.devmgr.btnadd"
class=
"btn btn-primary btn-sm"
>
Add
</button>
class=
"btn btn-primary btn-sm"
>
Add
</button>
</div>
</div>
...
@@ -41,18 +46,19 @@
...
@@ -41,18 +46,19 @@
<table
class=
"table table-sm table-hover align-middle"
id=
"view.devmgr.table"
>
<table
class=
"table table-sm table-hover align-middle"
id=
"view.devmgr.table"
>
<thead
class=
"table-light sticky-top"
>
<thead
class=
"table-light sticky-top"
>
<tr>
<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>
Browser / User Agent
</th>
<th
style=
"min-width:135px;"
>
Registered
</th>
<th
style=
"min-width:135px;"
>
Registered
</th>
<th
style=
"min-width:80px;"
>
Status
</th>
<th
style=
"min-width:80px;"
>
Status
</th>
<th
style=
"min-width:
80px;"
>
Action
</th>
<th
style=
"min-width:
160px;"
>
Actions
</th>
</tr>
</tr>
</thead>
</thead>
<tbody
id=
"view.devmgr.tbody"
>
<tbody
id=
"view.devmgr.tbody"
>
</tbody>
</tbody>
</table>
</table>
<p
id=
"view.devmgr.empty"
class=
"text-muted d-none text-center py-4"
>
<p
id=
"view.devmgr.empty"
class=
"text-muted d-none text-center py-4"
>
No devices
registere
d.
No devices
foun
d.
</p>
</p>
</div>
</div>
...
...
webEMIMobile/View.DeviceManager.pas
View file @
27821f79
This diff is collapsed.
Click to expand it.
webEMIMobile/View.DeviceRegistration.dfm
View file @
27821f79
...
@@ -8,14 +8,14 @@ object FViewDeviceRegistration: TFViewDeviceRegistration
...
@@ -8,14 +8,14 @@ object FViewDeviceRegistration: TFViewDeviceRegistration
Font.Style = []
Font.Style = []
ParentFont = False
ParentFont = False
OnCreate = WebFormCreate
OnCreate = WebFormCreate
object edt
DeviceName
: TWebEdit
object edt
PhoneNumber
: TWebEdit
Left = 240
Left = 240
Top = 136
Top = 136
Width = 121
Width = 121
Height = 21
Height = 21
ElementID = 'view.devicereg.edt
devicename
'
ElementID = 'view.devicereg.edt
phonenumber
'
HeightPercent = 100.000000000000000000
HeightPercent = 100.000000000000000000
TextHint = '
Device name (optional)
'
TextHint = '
(303) 555-1234
'
WidthPercent = 100.000000000000000000
WidthPercent = 100.000000000000000000
end
end
object btnRegister: TWebButton
object btnRegister: TWebButton
...
...
webEMIMobile/View.DeviceRegistration.html
View file @
27821f79
...
@@ -33,17 +33,17 @@
...
@@ -33,17 +33,17 @@
<p
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.
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.
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>
</p>
<div
class=
"mb-3"
>
<div
class=
"mb-3"
>
<label
class=
"form-label small text-muted"
>
Device name
</label>
<label
class=
"form-label small text-muted"
>
Phone number
</label>
<input
id=
"view.devicereg.edt
devicename
"
<input
id=
"view.devicereg.edt
phonenumber
"
class=
"form-control"
class=
"form-control"
type=
"te
xt
"
type=
"te
l
"
placeholder=
"
e.g. Dispatch Console, Patrol Laptop
"
placeholder=
"
(303) 555-1234
"
autofocus
>
autofocus
>
</div>
</div>
...
...
webEMIMobile/View.DeviceRegistration.pas
View file @
27821f79
...
@@ -10,7 +10,7 @@ uses
...
@@ -10,7 +10,7 @@ uses
type
type
TFViewDeviceRegistration
=
class
(
TWebForm
)
TFViewDeviceRegistration
=
class
(
TWebForm
)
edt
DeviceName
:
TWebEdit
;
edt
PhoneNumber
:
TWebEdit
;
btnRegister
:
TWebButton
;
btnRegister
:
TWebButton
;
pnlMessage
:
TWebPanel
;
pnlMessage
:
TWebPanel
;
lblMessage
:
TWebLabel
;
lblMessage
:
TWebLabel
;
...
@@ -24,7 +24,7 @@ type
...
@@ -24,7 +24,7 @@ type
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
DoWebAuthnCreate
(
A
DeviceName
,
AChallenge
,
AChallengeToken
:
string
);
procedure
DoWebAuthnCreate
(
A
PhoneNumber
,
AChallenge
,
AChallengeToken
:
string
);
public
public
class
procedure
Display
(
ARegistrationProc
:
TSuccessProc
);
class
procedure
Display
(
ARegistrationProc
:
TSuccessProc
);
end
;
end
;
...
@@ -60,6 +60,23 @@ begin
...
@@ -60,6 +60,23 @@ begin
asm
asm
var
el
=
document
.
getElementById
(
'view.devicereg.useragent'
);
var
el
=
document
.
getElementById
(
'view.devicereg.useragent'
);
if
(
el
)
el
.
textContent
=
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
;
end
;
end
;
...
@@ -76,11 +93,11 @@ end;
...
@@ -76,11 +93,11 @@ end;
procedure
TFViewDeviceRegistration
.
btnRegisterClick
(
Sender
:
TObject
);
procedure
TFViewDeviceRegistration
.
btnRegisterClick
(
Sender
:
TObject
);
var
var
deviceName
:
string
;
phoneNumber
:
string
;
procedure
OnBeginOK
(
AChallenge
,
AChallengeToken
:
string
);
procedure
OnBeginOK
(
AChallenge
,
AChallengeToken
:
string
);
begin
begin
DoWebAuthnCreate
(
deviceName
,
AChallenge
,
AChallengeToken
);
DoWebAuthnCreate
(
phoneNumber
,
AChallenge
,
AChallengeToken
);
end
;
end
;
procedure
OnBeginError
(
AMsg
:
string
);
procedure
OnBeginError
(
AMsg
:
string
);
...
@@ -90,24 +107,26 @@ var
...
@@ -90,24 +107,26 @@ var
end
;
end
;
begin
begin
deviceName
:=
Trim
(
edtDeviceName
.
Text
);
phoneNumber
:=
Trim
(
edtPhoneNumber
.
Text
);
if
deviceName
=
''
then
if
phoneNumber
=
''
then
deviceName
:=
'Unnamed Device'
;
begin
ShowNotification
(
'Please enter your phone number.'
);
Exit
;
end
;
SetBusy
(
True
);
SetBusy
(
True
);
HideNotification
;
HideNotification
;
AuthService
.
BeginRegistration
(
deviceName
,
@
OnBeginOK
,
@
OnBeginError
);
AuthService
.
BeginRegistration
(
phoneNumber
,
@
OnBeginOK
,
@
OnBeginError
);
end
;
end
;
procedure
TFViewDeviceRegistration
.
DoWebAuthnCreate
(
procedure
TFViewDeviceRegistration
.
DoWebAuthnCreate
(
A
DeviceName
,
AChallenge
,
AChallengeToken
:
string
);
A
PhoneNumber
,
AChallenge
,
AChallengeToken
:
string
);
var
var
deviceName
,
challenge
,
challengeToken
:
string
;
phoneNumber
,
challenge
,
challengeToken
:
string
;
procedure
OnCompleteOK
;
procedure
OnCompleteOK
;
begin
begin
// Registration complete — proceed to login
FRegistrationProc
;
FRegistrationProc
;
end
;
end
;
...
@@ -120,7 +139,7 @@ var
...
@@ -120,7 +139,7 @@ var
procedure
OnCredential
(
ACredentialId
,
AAttestationObject
,
AClientDataJSON
:
string
);
procedure
OnCredential
(
ACredentialId
,
AAttestationObject
,
AClientDataJSON
:
string
);
begin
begin
AuthService
.
CompleteRegistration
(
AuthService
.
CompleteRegistration
(
deviceName
,
ACredentialId
,
AAttestationObject
,
AClientDataJSON
,
challengeToken
,
phoneNumber
,
ACredentialId
,
AAttestationObject
,
AClientDataJSON
,
challengeToken
,
@
OnCompleteOK
,
@
OnCompleteError
@
OnCompleteOK
,
@
OnCompleteError
);
);
end
;
end
;
...
@@ -132,14 +151,12 @@ var
...
@@ -132,14 +151,12 @@ var
end
;
end
;
begin
begin
deviceName
:=
ADeviceName
;
phoneNumber
:=
APhoneNumber
;
challenge
:=
AChallenge
;
challenge
:=
AChallenge
;
challengeToken
:=
AChallengeToken
;
challengeToken
:=
AChallengeToken
;
// Call navigator.credentials.create() via WebAuthn API
asm
asm
(
function
()
{
(
function
()
{
// Decode base64url challenge to Uint8Array
function b64urlToArr(b64) {
function b64urlToArr(b64) {
b64 = b64.replace(/-/g, '+').replace(/_/g, '/');
b64 = b64.replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4) b64 += '=';
while (b64.length % 4) b64 += '=';
...
@@ -148,13 +165,11 @@ begin
...
@@ -148,13 +165,11 @@ begin
for (var i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
for (var i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return arr;
return arr;
}
}
// Encode ArrayBuffer to base64url
function
arrToB64url
(
buf
)
{
function
arrToB64url
(
buf
)
{
var bin = String.fromCharCode.apply(null, new Uint8Array(buf));
var bin = String.fromCharCode.apply(null, new Uint8Array(buf));
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
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
);
var
userId
=
new
Uint8Array
(
16
);
crypto
.
getRandomValues
(
userId
);
crypto
.
getRandomValues
(
userId
);
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment