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
886d9a67
Commit
886d9a67
authored
Sep 10, 2026
by
Michael Brachmann
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
updates for auto login after passkey
parent
cf24b2b6
Hide whitespace changes
Inline
Side-by-side
Showing
10 changed files
with
637 additions
and
33 deletions
+637
-33
Api.Service.pas
emiMobileServer/Source/Api.Service.pas
+1
-0
Api.ServiceImpl.pas
emiMobileServer/Source/Api.ServiceImpl.pas
+52
-2
Auth.Service.pas
emiMobileServer/Source/Auth.Service.pas
+8
-0
Auth.ServiceImpl.pas
emiMobileServer/Source/Auth.ServiceImpl.pas
+277
-2
device_registrations_username.sql
emiMobileServer/device_registrations_username.sql
+8
-0
Auth.Service.pas
webEMIMobile/Auth.Service.pas
+54
-3
View.DeviceManager.html
webEMIMobile/View.DeviceManager.html
+2
-1
View.DeviceManager.pas
webEMIMobile/View.DeviceManager.pas
+53
-4
View.Login.html
webEMIMobile/View.Login.html
+38
-20
View.Login.pas
webEMIMobile/View.Login.pas
+144
-1
No files found.
emiMobileServer/Source/Api.Service.pas
View file @
886d9a67
...
@@ -37,6 +37,7 @@ type
...
@@ -37,6 +37,7 @@ type
function
AddPendingDevice
(
const
DeviceName
,
PhoneNumber
:
string
):
TJSONObject
;
function
AddPendingDevice
(
const
DeviceName
,
PhoneNumber
:
string
):
TJSONObject
;
function
DeletePendingDevice
(
const
PhoneNumber
:
string
):
TJSONObject
;
function
DeletePendingDevice
(
const
PhoneNumber
:
string
):
TJSONObject
;
function
SendAppLink
(
const
PhoneNumber
:
string
):
TJSONObject
;
function
SendAppLink
(
const
PhoneNumber
:
string
):
TJSONObject
;
function
UpdateDeviceUsername
(
const
CredentialId
,
Username
:
string
):
TJSONObject
;
end
;
end
;
implementation
implementation
...
...
emiMobileServer/Source/Api.ServiceImpl.pas
View file @
886d9a67
...
@@ -43,6 +43,7 @@ type
...
@@ -43,6 +43,7 @@ type
function
AddPendingDevice
(
const
DeviceName
,
PhoneNumber
:
string
):
TJSONObject
;
function
AddPendingDevice
(
const
DeviceName
,
PhoneNumber
:
string
):
TJSONObject
;
function
DeletePendingDevice
(
const
PhoneNumber
:
string
):
TJSONObject
;
function
DeletePendingDevice
(
const
PhoneNumber
:
string
):
TJSONObject
;
function
SendAppLink
(
const
PhoneNumber
:
string
):
TJSONObject
;
function
SendAppLink
(
const
PhoneNumber
:
string
):
TJSONObject
;
function
UpdateDeviceUsername
(
const
CredentialId
,
Username
:
string
):
TJSONObject
;
end
;
end
;
implementation
implementation
...
@@ -1346,8 +1347,8 @@ begin
...
@@ -1346,8 +1347,8 @@ begin
try
try
q
.
Connection
:=
conn
;
q
.
Connection
:=
conn
;
q
.
SQL
.
Text
:=
q
.
SQL
.
Text
:=
'SELECT id, credential_id, device_name, phone_number, user
_agent
, '
+
'SELECT id, credential_id, device_name, phone_number, user
name
, '
+
' registered_at, revoked_at, revoked_by, status '
+
'
user_agent,
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'
;
q
.
Open
;
q
.
Open
;
...
@@ -1361,6 +1362,7 @@ begin
...
@@ -1361,6 +1362,7 @@ begin
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
.
phone_number
:=
q
.
FieldByName
(
'phone_number'
).
AsString
;
item
.
username
:=
q
.
FieldByName
(
'username'
).
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
...
@@ -1725,6 +1727,54 @@ begin
...
@@ -1725,6 +1727,54 @@ begin
Result
.
AddPair
(
'code'
,
redeemCode
);
Result
.
AddPair
(
'code'
,
redeemCode
);
end
;
end
;
function
TApiService
.
UpdateDeviceUsername
(
const
CredentialId
,
Username
:
string
):
TJSONObject
;
var
conn
:
TUniConnection
;
q
:
TUniQuery
;
begin
RequireAdmin
;
Result
:=
TJSONObject
.
Create
;
TXDataOperationContext
.
Current
.
Handler
.
ManagedObjects
.
Add
(
Result
);
if
Trim
(
CredentialId
)
=
''
then
begin
Result
.
AddPair
(
'status'
,
'error'
);
Result
.
AddPair
(
'message'
,
'CredentialId is required.'
);
Exit
;
end
;
conn
:=
OpenLemsConnection
;
try
q
:=
TUniQuery
.
Create
(
nil
);
try
q
.
Connection
:=
conn
;
q
.
SQL
.
Text
:=
'UPDATE lems.device_registrations '
+
'SET username = :UNAME '
+
'WHERE credential_id = :CID AND status = ''active'''
;
q
.
ParamByName
(
'UNAME'
).
AsString
:=
Trim
(
Username
);
q
.
ParamByName
(
'CID'
).
AsString
:=
Trim
(
CredentialId
);
q
.
ExecSQL
;
if
q
.
RowsAffected
=
0
then
begin
Result
.
AddPair
(
'status'
,
'error'
);
Result
.
AddPair
(
'message'
,
'Device not found or not active.'
);
Exit
;
end
;
finally
q
.
Free
;
end
;
finally
conn
.
Free
;
end
;
Logger
.
Log
(
2
,
Format
(
'TApiService.UpdateDeviceUsername - set username "%s" on credId %s'
,
[
Trim
(
Username
),
Copy
(
CredentialId
,
1
,
20
)]));
Result
.
AddPair
(
'status'
,
'ok'
);
end
;
initialization
initialization
RegisterServiceType
(
TApiService
);
RegisterServiceType
(
TApiService
);
...
...
emiMobileServer/Source/Auth.Service.pas
View file @
886d9a67
...
@@ -46,6 +46,8 @@ type
...
@@ -46,6 +46,8 @@ type
credential_id
:
string
;
credential_id
:
string
;
device_name
:
string
;
device_name
:
string
;
phone_number
:
string
;
phone_number
:
string
;
username
:
string
;
agency
:
string
;
user_agent
:
string
;
user_agent
:
string
;
registered_at
:
string
;
registered_at
:
string
;
revoked_at
:
string
;
revoked_at
:
string
;
...
@@ -80,6 +82,12 @@ type
...
@@ -80,6 +82,12 @@ type
ChallengeToken
:
string
):
TJSONObject
;
ChallengeToken
:
string
):
TJSONObject
;
// WebAuthn authentication challenge — called before Login
// WebAuthn authentication challenge — called before Login
function
BeginAuthentication
(
const
CredentialId
:
string
):
TJSONObject
;
function
BeginAuthentication
(
const
CredentialId
:
string
):
TJSONObject
;
// Returns stored username/agency for a credential (unauthenticated; used by login form)
function
GetDeviceUser
(
const
CredentialId
:
string
):
TJSONObject
;
// Passkey-only login (no password) — uses stored username+agency from device record
function
LoginAutomatic
(
const
CredentialId
,
ChallengeToken
,
AuthenticatorData
,
ClientDataJSON
,
Signature
:
string
):
string
;
end
;
end
;
implementation
implementation
...
...
emiMobileServer/Source/Auth.ServiceImpl.pas
View file @
886d9a67
...
@@ -26,6 +26,7 @@ type
...
@@ -26,6 +26,7 @@ type
//procedure BeforeDestruction; override;
//procedure BeforeDestruction; override;
function
VerifyVersion
(
ClientVersion
:
string
):
TJSONObject
;
function
VerifyVersion
(
ClientVersion
:
string
):
TJSONObject
;
function
CheckUser
(
const
User
,
Password
,
Agency
:
string
):
Integer
;
function
CheckUser
(
const
User
,
Password
,
Agency
:
string
):
Integer
;
function
LoadUserByName
(
const
User
,
Agency
:
string
):
Boolean
;
function
Decrypt
(
inStr
,
keyStr
:
AnsiString
):
AnsiString
;
function
Decrypt
(
inStr
,
keyStr
:
AnsiString
):
AnsiString
;
// 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
;
...
@@ -36,7 +37,6 @@ type
...
@@ -36,7 +37,6 @@ type
signature
:
string
):
string
;
signature
:
string
):
string
;
constructor
Create
;
constructor
Create
;
destructor
Destroy
;
override
;
destructor
Destroy
;
override
;
//function Login(const User, Password, Agency: string): string;
function
GetAgencieslist
():
TAgenciesList
;
function
GetAgencieslist
():
TAgenciesList
;
function
GetAgencyConfiglist
:
TAgencyConfigList
;
function
GetAgencyConfiglist
:
TAgencyConfigList
;
function
BeginRegistration
(
const
PhoneNumber
:
string
):
TJSONObject
;
function
BeginRegistration
(
const
PhoneNumber
:
string
):
TJSONObject
;
...
@@ -44,6 +44,10 @@ type
...
@@ -44,6 +44,10 @@ type
AttestationObject
,
ClientDataJSON
,
AttestationObject
,
ClientDataJSON
,
ChallengeToken
:
string
):
TJSONObject
;
ChallengeToken
:
string
):
TJSONObject
;
function
BeginAuthentication
(
const
CredentialId
:
string
):
TJSONObject
;
function
BeginAuthentication
(
const
CredentialId
:
string
):
TJSONObject
;
function
GetDeviceUser
(
const
CredentialId
:
string
):
TJSONObject
;
function
LoginAutomatic
(
const
CredentialId
,
ChallengeToken
,
AuthenticatorData
,
ClientDataJSON
,
Signature
:
string
):
string
;
end
;
end
;
implementation
implementation
...
@@ -471,7 +475,7 @@ begin
...
@@ -471,7 +475,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
username, agency
FROM lems.device_registrations '
+
'WHERE credential_id = :CID AND revoked_at IS NULL'
;
'WHERE credential_id = :CID AND revoked_at IS NULL'
;
q
.
ParamByName
(
'CID'
).
AsString
:=
Trim
(
CredentialId
);
q
.
ParamByName
(
'CID'
).
AsString
:=
Trim
(
CredentialId
);
q
.
Open
;
q
.
Open
;
...
@@ -497,6 +501,52 @@ begin
...
@@ -497,6 +501,52 @@ begin
end
;
end
;
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// GetDeviceUser — unauthenticated; returns stored username/agency for the
// credential so the login form can offer passkey-only auto-login
// ---------------------------------------------------------------------------
function
TAuthService
.
GetDeviceUser
(
const
CredentialId
:
string
):
TJSONObject
;
var
q
:
TUniQuery
;
begin
Result
:=
TJSONObject
.
Create
;
TXDataOperationContext
.
Current
.
Handler
.
ManagedObjects
.
Add
(
Result
);
if
Trim
(
CredentialId
)
=
''
then
begin
Result
.
AddPair
(
'username'
,
''
);
Result
.
AddPair
(
'agency'
,
''
);
Exit
;
end
;
q
:=
TUniQuery
.
Create
(
nil
);
try
q
.
Connection
:=
authDB
.
ucLemsOCSO
;
q
.
SQL
.
Text
:=
'SELECT username, agency FROM lems.device_registrations '
+
'WHERE credential_id = :CID AND status = ''active'''
;
q
.
ParamByName
(
'CID'
).
AsString
:=
Trim
(
CredentialId
);
q
.
Open
;
try
if
q
.
IsEmpty
then
begin
Result
.
AddPair
(
'username'
,
''
);
Result
.
AddPair
(
'agency'
,
''
);
end
else
begin
Result
.
AddPair
(
'username'
,
q
.
FieldByName
(
'username'
).
AsString
);
Result
.
AddPair
(
'agency'
,
q
.
FieldByName
(
'agency'
).
AsString
);
end
;
finally
q
.
Close
;
end
;
finally
q
.
Free
;
end
;
end
;
// ---------------------------------------------------------------------------
// Login — verifies WebAuthn assertion then issues JWT
// Login — verifies WebAuthn assertion then issues JWT
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
...
@@ -687,6 +737,231 @@ begin
...
@@ -687,6 +737,231 @@ begin
finally
finally
JWT
.
Free
;
JWT
.
Free
;
end
;
end
;
// 9. Persist username + agency on the device record so future auto-login works
q
:=
TUniQuery
.
Create
(
nil
);
try
q
.
Connection
:=
authDB
.
ucLemsOCSO
;
q
.
SQL
.
Text
:=
'UPDATE lems.device_registrations '
+
'SET username = :UNAME, agency = :AGCY '
+
'WHERE credential_id = :CID'
;
q
.
ParamByName
(
'UNAME'
).
AsString
:=
userName
;
q
.
ParamByName
(
'AGCY'
).
AsString
:=
userAgency
;
q
.
ParamByName
(
'CID'
).
AsString
:=
Trim
(
credentialId
);
q
.
ExecSQL
;
finally
q
.
Free
;
end
;
end
;
// ---------------------------------------------------------------------------
// LoadUserByName — like CheckUser but without password verification
// ---------------------------------------------------------------------------
function
TAuthService
.
LoadUserByName
(
const
User
,
Agency
:
string
):
Boolean
;
begin
authDB
.
uqAuth
.
Close
;
authDB
.
uqAuth
.
SQL
.
Text
:=
'select u.* from lems.users u '
+
'where upper(user_name) = :USER_NAME '
+
'and u.dept = :AGENCY'
;
authDB
.
uqAuth
.
ParamByName
(
'USER_NAME'
).
AsString
:=
UpperCase
(
Trim
(
User
));
authDB
.
uqAuth
.
ParamByName
(
'AGENCY'
).
AsString
:=
UpperCase
(
Trim
(
Agency
));
authDB
.
uqAuth
.
Open
;
try
if
authDB
.
uqAuth
.
IsEmpty
then
Exit
(
False
);
if
authDB
.
uqAuth
.
FieldByName
(
'active'
).
AsString
=
'F'
then
Exit
(
False
);
userName
:=
authDB
.
uqAuth
.
FieldByName
(
'user_name'
).
AsString
;
userFullName
:=
authDB
.
uqAuth
.
FieldByName
(
'firstname'
).
AsString
+
' '
+
authDB
.
uqAuth
.
FieldByName
(
'lastname'
).
AsString
;
userAgency
:=
authDB
.
uqAuth
.
FieldByName
(
'dept'
).
AsString
;
userBadge
:=
authDB
.
uqAuth
.
FieldByName
(
'badgenum'
).
AsString
;
userId
:=
authDB
.
uqAuth
.
FieldByName
(
'userid'
).
AsString
;
userPersonnelId
:=
authDB
.
uqAuth
.
FieldByName
(
'personnelid'
).
AsString
;
userIsAdmin
:=
SameText
(
Trim
(
User
),
'admin'
);
Result
:=
True
;
finally
authDB
.
uqAuth
.
Close
;
end
;
end
;
// ---------------------------------------------------------------------------
// LoginAutomatic — WebAuthn assertion login without username/password
// Uses stored username + agency from device_registrations
// ---------------------------------------------------------------------------
function
TAuthService
.
LoginAutomatic
(
const
CredentialId
,
ChallengeToken
,
AuthenticatorData
,
ClientDataJSON
,
Signature
:
string
):
string
;
var
challengeB64
:
string
;
cdJsonBytes
,
authDataBytes
,
sigBytes
:
TBytes
;
cdJsonText
:
string
;
cdJson
:
TJSONObject
;
typeVal
,
challengeVal
,
loginOriginVal
,
loginEffectiveRpId
:
string
;
rpIdHash
,
expectedRpIdHash
:
TBytes
;
flags
:
Byte
;
signCount
:
Cardinal
;
pubKeyX
,
pubKeyY
:
TBytes
;
message
:
TBytes
;
q
:
TUniQuery
;
storedSignCount
:
Int64
;
storedUsername
,
storedAgency
:
string
;
JWT
:
TJWT
;
begin
Logger
.
Log
(
2
,
'AuthService.LoginAutomatic - credId: '
+
Copy
(
CredentialId
,
1
,
20
));
// 1. Verify challenge token
if
not
VerifyChallengeToken
(
ChallengeToken
,
'auth'
,
challengeB64
)
then
raise
EXDataHttpUnauthorized
.
Create
(
'Invalid or expired authentication challenge.'
);
// 2. Parse and verify clientDataJSON
try
cdJsonBytes
:=
Base64UrlDecode
(
ClientDataJSON
);
cdJsonText
:=
TEncoding
.
UTF8
.
GetString
(
cdJsonBytes
);
cdJson
:=
TJSONObject
.
ParseJSONValue
(
cdJsonText
)
as
TJSONObject
;
except
raise
EXDataHttpUnauthorized
.
Create
(
'Failed to parse clientDataJSON.'
);
end
;
if
not
Assigned
(
cdJson
)
then
raise
EXDataHttpUnauthorized
.
Create
(
'clientDataJSON is not valid JSON.'
);
try
typeVal
:=
cdJson
.
GetValue
<
string
>(
'type'
,
''
);
challengeVal
:=
cdJson
.
GetValue
<
string
>(
'challenge'
,
''
);
loginOriginVal
:=
cdJson
.
GetValue
<
string
>(
'origin'
,
''
);
finally
cdJson
.
Free
;
end
;
if
typeVal
<>
'webauthn.get'
then
raise
EXDataHttpUnauthorized
.
Create
(
'clientDataJSON type mismatch.'
);
if
challengeVal
<>
challengeB64
then
raise
EXDataHttpUnauthorized
.
Create
(
'Challenge mismatch.'
);
loginEffectiveRpId
:=
ExtractOriginHostname
(
loginOriginVal
);
if
loginEffectiveRpId
=
''
then
loginEffectiveRpId
:=
ServerConfig
.
rpId
;
// 3. Load credential + stored username/agency from DB
storedUsername
:=
''
;
storedAgency
:=
''
;
q
:=
TUniQuery
.
Create
(
nil
);
try
q
.
Connection
:=
authDB
.
ucLemsOCSO
;
q
.
SQL
.
Text
:=
'SELECT public_key_x, public_key_y, sign_count, username, agency '
+
'FROM lems.device_registrations '
+
'WHERE credential_id = :CID AND status = ''active'''
;
q
.
ParamByName
(
'CID'
).
AsString
:=
Trim
(
CredentialId
);
q
.
Open
;
try
if
q
.
IsEmpty
then
raise
EXDataHttpUnauthorized
.
Create
(
'Device not registered or access has been revoked.'
);
pubKeyX
:=
q
.
FieldByName
(
'public_key_x'
).
AsBytes
;
pubKeyY
:=
q
.
FieldByName
(
'public_key_y'
).
AsBytes
;
storedSignCount
:=
q
.
FieldByName
(
'sign_count'
).
AsLargeInt
;
storedUsername
:=
q
.
FieldByName
(
'username'
).
AsString
;
storedAgency
:=
q
.
FieldByName
(
'agency'
).
AsString
;
finally
q
.
Close
;
end
;
finally
q
.
Free
;
end
;
if
(
storedUsername
=
''
)
or
(
storedAgency
=
''
)
then
raise
EXDataHttpUnauthorized
.
Create
(
'No username saved for this device. Please sign in with your username and password first.'
);
// 4. Parse authenticatorData
try
authDataBytes
:=
Base64UrlDecode
(
AuthenticatorData
);
except
raise
EXDataHttpUnauthorized
.
Create
(
'Failed to decode authenticatorData.'
);
end
;
if
Length
(
authDataBytes
)
<
37
then
raise
EXDataHttpUnauthorized
.
Create
(
'authenticatorData too short.'
);
SetLength
(
rpIdHash
,
32
);
Move
(
authDataBytes
[
0
],
rpIdHash
[
0
],
32
);
expectedRpIdHash
:=
SHA256Bytes
(
TEncoding
.
UTF8
.
GetBytes
(
loginEffectiveRpId
));
if
not
CompareMem
(@
rpIdHash
[
0
],
@
expectedRpIdHash
[
0
],
32
)
then
raise
EXDataHttpUnauthorized
.
Create
(
Format
(
'rpId mismatch — server used "%s"'
,
[
loginEffectiveRpId
]));
flags
:=
authDataBytes
[
32
];
if
(
flags
and
$
01
)
=
0
then
raise
EXDataHttpUnauthorized
.
Create
(
'User presence flag not set.'
);
// 5. Verify ECDSA signature
SetLength
(
message
,
Length
(
authDataBytes
)
+
32
);
Move
(
authDataBytes
[
0
],
message
[
0
],
Length
(
authDataBytes
));
var
cdHash
:=
SHA256Bytes
(
cdJsonBytes
);
Move
(
cdHash
[
0
],
message
[
Length
(
authDataBytes
)],
32
);
try
sigBytes
:=
Base64UrlDecode
(
Signature
);
except
raise
EXDataHttpUnauthorized
.
Create
(
'Failed to decode signature.'
);
end
;
if
not
VerifyECDSAP256
(
pubKeyX
,
pubKeyY
,
message
,
sigBytes
)
then
raise
EXDataHttpUnauthorized
.
Create
(
'WebAuthn signature verification failed.'
);
// 6. Update sign count
signCount
:=
(
Cardinal
(
authDataBytes
[
33
])
shl
24
)
or
(
Cardinal
(
authDataBytes
[
34
])
shl
16
)
or
(
Cardinal
(
authDataBytes
[
35
])
shl
8
)
or
authDataBytes
[
36
];
if
(
storedSignCount
>
0
)
and
(
Int64
(
signCount
)
<=
storedSignCount
)
then
Logger
.
Log
(
1
,
'AuthService.LoginAutomatic - WARNING: sign count did not increase'
);
q
:=
TUniQuery
.
Create
(
nil
);
try
q
.
Connection
:=
authDB
.
ucLemsOCSO
;
q
.
SQL
.
Text
:=
'UPDATE lems.device_registrations SET sign_count = :CNT WHERE credential_id = :CID'
;
q
.
ParamByName
(
'CNT'
).
AsInteger
:=
Integer
(
signCount
);
q
.
ParamByName
(
'CID'
).
AsString
:=
Trim
(
CredentialId
);
q
.
ExecSQL
;
finally
q
.
Free
;
end
;
// 7. Load user details from CAD (no password check)
if
not
LoadUserByName
(
storedUsername
,
storedAgency
)
then
raise
EXDataHttpUnauthorized
.
Create
(
Format
(
'User "%s" not found or inactive in agency "%s".'
,
[
storedUsername
,
storedAgency
]));
Logger
.
Log
(
2
,
Format
(
'AuthService.LoginAutomatic - success for User: "%s" Agency: "%s"'
,
[
userName
,
userAgency
]));
// 8. Issue JWT
JWT
:=
TJWT
.
Create
;
try
JWT
.
Claims
.
JWTId
:=
LowerCase
(
Copy
(
TUtils
.
GuidToVariant
(
TUtils
.
NewGuid
),
2
,
36
));
JWT
.
Claims
.
IssuedAt
:=
Now
;
JWT
.
Claims
.
Expiration
:=
IncHour
(
Now
,
24
);
JWT
.
Claims
.
SetClaimOfType
<
string
>(
'user_name'
,
userName
);
JWT
.
Claims
.
SetClaimOfType
<
string
>(
'user_fullname'
,
userFullName
);
JWT
.
Claims
.
SetClaimOfType
<
string
>(
'user_agency'
,
userAgency
);
JWT
.
Claims
.
SetClaimOfType
<
string
>(
'user_badge'
,
userBadge
);
JWT
.
Claims
.
SetClaimOfType
<
string
>(
'user_id'
,
userId
);
JWT
.
Claims
.
SetClaimOfType
<
string
>(
'user_personnelid'
,
userPersonnelId
);
JWT
.
Claims
.
SetClaimOfType
<
Boolean
>(
'user_admin'
,
userIsAdmin
);
JWT
.
Claims
.
SetClaimOfType
<
string
>(
'credential_id'
,
Trim
(
CredentialId
));
Result
:=
TJOSE
.
SHA256CompactToken
(
ServerConfig
.
jwtTokenSecret
,
JWT
);
finally
JWT
.
Free
;
end
;
end
;
end
;
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
...
...
emiMobileServer/device_registrations_username.sql
0 → 100644
View file @
886d9a67
-- Migration: add username and agency to device_registrations
-- username: the CAD username of the person who last logged in from this device
-- (saved automatically on first successful login, or set manually by admin)
-- agency: the CAD agency used at login time (saved automatically; required for auto-login)
ALTER
TABLE
lems
.
device_registrations
ADD
COLUMN
IF
NOT
EXISTS
username
VARCHAR
(
50
),
ADD
COLUMN
IF
NOT
EXISTS
agency
VARCHAR
(
20
);
webEMIMobile/Auth.Service.pas
View file @
886d9a67
...
@@ -14,9 +14,10 @@ type
...
@@ -14,9 +14,10 @@ type
TOnLoginSuccess
=
reference
to
procedure
;
TOnLoginSuccess
=
reference
to
procedure
;
TOnLoginError
=
reference
to
procedure
(
AMsg
:
string
);
TOnLoginError
=
reference
to
procedure
(
AMsg
:
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
);
TOnGetDeviceUserOK
=
reference
to
procedure
(
AUsername
,
AAgency
:
string
);
TAuthService
=
class
TAuthService
=
class
private
private
...
@@ -54,6 +55,11 @@ type
...
@@ -54,6 +55,11 @@ type
procedure
LoginWithAssertion
(
AUser
,
APassword
,
AAgency
,
ACredentialId
,
procedure
LoginWithAssertion
(
AUser
,
APassword
,
AAgency
,
ACredentialId
,
AChallengeToken
,
AAuthenticatorData
,
AClientDataJSON
,
ASignature
:
string
;
AChallengeToken
,
AAuthenticatorData
,
AClientDataJSON
,
ASignature
:
string
;
ASuccess
:
TOnLoginSuccess
;
AError
:
TOnLoginError
);
ASuccess
:
TOnLoginSuccess
;
AError
:
TOnLoginError
);
// Passkey-only auto-login (no password) — uses stored username on device record
procedure
GetDeviceUser
(
ACredentialId
:
string
;
ASuccess
:
TOnGetDeviceUserOK
);
procedure
LoginAutomatic
(
ACredentialId
,
AChallengeToken
,
AAuthenticatorData
,
AClientDataJSON
,
ASignature
:
string
;
ASuccess
:
TOnLoginSuccess
;
AError
:
TOnLoginError
);
end
;
end
;
TJwtHelper
=
class
TJwtHelper
=
class
...
@@ -282,6 +288,51 @@ begin
...
@@ -282,6 +288,51 @@ begin
);
);
end
;
end
;
// ---- Passkey auto-login ----
procedure
TAuthService
.
GetDeviceUser
(
ACredentialId
:
string
;
ASuccess
:
TOnGetDeviceUserOK
);
procedure
OnLoad
(
Response
:
TXDataClientResponse
);
var
resp
:
JS
.
TJSObject
;
begin
resp
:=
JS
.
TJSObject
(
Response
.
Result
);
ASuccess
(
JS
.
toString
(
resp
.
Properties
[
'username'
]),
JS
.
toString
(
resp
.
Properties
[
'agency'
])
);
end
;
begin
FClient
.
RawInvoke
(
'IAuthService.GetDeviceUser'
,
[
ACredentialId
],
@
OnLoad
);
end
;
procedure
TAuthService
.
LoginAutomatic
(
ACredentialId
,
AChallengeToken
,
AAuthenticatorData
,
AClientDataJSON
,
ASignature
:
string
;
ASuccess
:
TOnLoginSuccess
;
AError
:
TOnLoginError
);
procedure
OnLoad
(
Response
:
TXDataClientResponse
);
var
Token
:
JS
.
TJSObject
;
begin
Token
:=
JS
.
TJSObject
(
Response
.
Result
);
SetToken
(
JS
.
toString
(
Token
.
Properties
[
'value'
]));
ASuccess
;
end
;
procedure
OnError
(
Error
:
TXDataClientError
);
begin
AError
(
Format
(
'%s: %s'
,
[
Error
.
ErrorCode
,
Error
.
ErrorMessage
]));
end
;
begin
FClient
.
RawInvoke
(
'IAuthService.LoginAutomatic'
,
[
ACredentialId
,
AChallengeToken
,
AAuthenticatorData
,
AClientDataJSON
,
ASignature
],
@
OnLoad
,
@
OnError
);
end
;
// ---- Token helpers ----
// ---- Token helpers ----
function
TAuthService
.
TokenExpirationDate
:
TDateTime
;
function
TAuthService
.
TokenExpirationDate
:
TDateTime
;
...
...
webEMIMobile/View.DeviceManager.html
View file @
886d9a67
...
@@ -48,10 +48,11 @@
...
@@ -48,10 +48,11 @@
<tr>
<tr>
<th
style=
"min-width:130px;"
>
Device Name
</th>
<th
style=
"min-width:130px;"
>
Device Name
</th>
<th
style=
"min-width:120px;"
>
Phone
</th>
<th
style=
"min-width:120px;"
>
Phone
</th>
<th
style=
"min-width:140px;"
>
Username
</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:1
6
0px;"
>
Actions
</th>
<th
style=
"min-width:1
8
0px;"
>
Actions
</th>
</tr>
</tr>
</thead>
</thead>
<tbody
id=
"view.devmgr.tbody"
>
<tbody
id=
"view.devmgr.tbody"
>
...
...
webEMIMobile/View.DeviceManager.pas
View file @
886d9a67
...
@@ -23,13 +23,14 @@ type
...
@@ -23,13 +23,14 @@ type
procedure
ShowNotification
(
const
AMsg
:
string
;
AIsError
:
Boolean
=
True
);
procedure
ShowNotification
(
const
AMsg
:
string
;
AIsError
:
Boolean
=
True
);
procedure
HideNotification
;
procedure
HideNotification
;
procedure
ClearTable
;
procedure
ClearTable
;
procedure
AddDeviceRow
(
const
ACredentialId
,
AName
,
APhoneNumber
,
procedure
AddDeviceRow
(
const
ACredentialId
,
AName
,
APhoneNumber
,
AUsername
,
AUserAgent
,
ARegisteredAt
,
AStatus
:
string
);
AUserAgent
,
ARegisteredAt
,
AStatus
:
string
);
[
async
]
procedure
LoadDevices
;
[
async
]
procedure
LoadDevices
;
[
async
]
procedure
RevokeDevice
(
const
ACredentialId
,
AName
:
string
);
[
async
]
procedure
RevokeDevice
(
const
ACredentialId
,
AName
:
string
);
[
async
]
procedure
DeletePendingDevice
(
const
APhoneNumber
,
AName
:
string
);
[
async
]
procedure
DeletePendingDevice
(
const
APhoneNumber
,
AName
:
string
);
[
async
]
procedure
SendAppLink
(
const
APhoneNumber
,
AName
:
string
);
[
async
]
procedure
SendAppLink
(
const
APhoneNumber
,
AName
:
string
);
[
async
]
procedure
AddPendingDevice
;
[
async
]
procedure
AddPendingDevice
;
[
async
]
procedure
UpdateDeviceUsername
(
const
ACredentialId
,
AUsername
:
string
);
public
public
end
;
end
;
...
@@ -116,18 +117,19 @@ begin
...
@@ -116,18 +117,19 @@ begin
end
;
end
;
procedure
TFViewDeviceManager
.
AddDeviceRow
(
const
ACredentialId
,
AName
,
procedure
TFViewDeviceManager
.
AddDeviceRow
(
const
ACredentialId
,
AName
,
APhoneNumber
,
AUserAgent
,
ARegisteredAt
,
AStatus
:
string
);
APhoneNumber
,
AUser
name
,
AUser
Agent
,
ARegisteredAt
,
AStatus
:
string
);
var
var
tbody
,
tr
,
tdName
,
tdPhone
,
tdAgent
,
tdReg
,
tdStatus
,
tdAction
:
TJSHTMLElement
;
tbody
,
tr
,
tdName
,
tdPhone
,
td
User
,
td
Agent
,
tdReg
,
tdStatus
,
tdAction
:
TJSHTMLElement
;
btn
,
btnSend
:
TJSHTMLElement
;
btn
,
btnSend
:
TJSHTMLElement
;
displayDate
,
displayPhone
:
string
;
displayDate
,
displayPhone
:
string
;
isPending
,
isRevoked
:
Boolean
;
isPending
,
isRevoked
,
isActive
:
Boolean
;
begin
begin
tbody
:=
TJSHTMLElement
(
document
.
getElementById
(
'view.devmgr.tbody'
));
tbody
:=
TJSHTMLElement
(
document
.
getElementById
(
'view.devmgr.tbody'
));
if
not
Assigned
(
tbody
)
then
Exit
;
if
not
Assigned
(
tbody
)
then
Exit
;
isPending
:=
AStatus
=
'pending'
;
isPending
:=
AStatus
=
'pending'
;
isRevoked
:=
AStatus
=
'revoked'
;
isRevoked
:=
AStatus
=
'revoked'
;
isActive
:=
AStatus
=
'active'
;
displayPhone
:=
FormatPhoneDisplay
(
APhoneNumber
);
displayPhone
:=
FormatPhoneDisplay
(
APhoneNumber
);
tr
:=
TJSHTMLElement
(
document
.
createElement
(
'tr'
));
tr
:=
TJSHTMLElement
(
document
.
createElement
(
'tr'
));
...
@@ -147,6 +149,31 @@ begin
...
@@ -147,6 +149,31 @@ begin
tdPhone
.
innerText
:=
displayPhone
;
tdPhone
.
innerText
:=
displayPhone
;
tr
.
appendChild
(
tdPhone
);
tr
.
appendChild
(
tdPhone
);
// Username — editable for active devices
tdUser
:=
TJSHTMLElement
(
document
.
createElement
(
'td'
));
if
isActive
then
begin
tdUser
.
innerHTML
:=
'<div class="d-flex gap-1 align-items-center">'
+
'<input type="text" class="form-control form-control-sm devmgr-username-input" '
+
'style="max-width:110px;" value="'
+
AUsername
+
'" '
+
'placeholder="username">'
+
'<button class="btn btn-outline-secondary btn-sm devmgr-username-save">Save</button>'
+
'</div>'
;
var
inp
:=
TJSHTMLElement
(
tdUser
.
querySelector
(
'.devmgr-username-input'
));
var
saveBtn
:=
TJSHTMLElement
(
tdUser
.
querySelector
(
'.devmgr-username-save'
));
if
Assigned
(
saveBtn
)
and
Assigned
(
inp
)
then
saveBtn
.
addEventListener
(
'click'
,
procedure
(
Event
:
TJSMouseEvent
)
begin
UpdateDeviceUsername
(
ACredentialId
,
string
(
TJSHTMLInputElement
(
inp
).
value
));
end
);
end
else
if
AUsername
<>
''
then
tdUser
.
innerText
:=
AUsername
else
tdUser
.
innerHTML
:=
'<em class="text-muted small">—</em>'
;
tr
.
appendChild
(
tdUser
);
// User agent (truncated)
// User agent (truncated)
tdAgent
:=
TJSHTMLElement
(
document
.
createElement
(
'td'
));
tdAgent
:=
TJSHTMLElement
(
document
.
createElement
(
'td'
));
if
not
isPending
then
if
not
isPending
then
...
@@ -263,6 +290,7 @@ begin
...
@@ -263,6 +290,7 @@ begin
string
(
item
[
'credential_id'
]),
string
(
item
[
'credential_id'
]),
string
(
item
[
'device_name'
]),
string
(
item
[
'device_name'
]),
string
(
item
[
'phone_number'
]),
string
(
item
[
'phone_number'
]),
string
(
item
[
'username'
]),
string
(
item
[
'user_agent'
]),
string
(
item
[
'user_agent'
]),
string
(
item
[
'registered_at'
]),
string
(
item
[
'registered_at'
]),
string
(
item
[
'status'
])
string
(
item
[
'status'
])
...
@@ -349,6 +377,27 @@ begin
...
@@ -349,6 +377,27 @@ begin
AddPendingDevice
;
AddPendingDevice
;
end
;
end
;
procedure
TFViewDeviceManager
.
UpdateDeviceUsername
(
const
ACredentialId
,
AUsername
:
string
);
var
resp
:
TXDataClientResponse
;
res
:
TJSObject
;
status
:
string
;
begin
try
resp
:=
await
(
XDataWebClient
.
RawInvokeAsync
(
'IApiService.UpdateDeviceUsername'
,
[
ACredentialId
,
AUsername
]));
res
:=
TJSObject
(
resp
.
Result
);
status
:=
string
(
res
[
'status'
]);
if
status
=
'ok'
then
ShowNotification
(
'Username updated.'
,
False
)
else
ShowNotification
(
string
(
res
[
'message'
]));
except
on
E
:
Exception
do
ShowNotification
(
'Update failed: '
+
E
.
Message
);
end
;
end
;
procedure
TFViewDeviceManager
.
AddPendingDevice
;
procedure
TFViewDeviceManager
.
AddPendingDevice
;
var
var
deviceName
,
phoneNumber
:
string
;
deviceName
,
phoneNumber
:
string
;
...
...
webEMIMobile/View.Login.html
View file @
886d9a67
...
@@ -29,28 +29,46 @@
...
@@ -29,28 +29,46 @@
aria-label=
"Close"
></button>
aria-label=
"Close"
></button>
</div>
</div>
<div
class=
"mb-3"
>
<!-- Auto-login section (shown when device has a stored username) -->
<input
id=
"view.login.edtusername"
<div
id=
"view.login.autosection"
class=
"d-none"
>
class=
"form-control"
<p
class=
"text-center text-muted mb-1 small"
>
Signing in as
</p>
type=
"text"
<p
id=
"view.login.autouserlabel"
placeholder=
"Username"
class=
"text-center fw-semibold fs-5 mb-3"
></p>
autofocus
>
<button
id=
"view.login.btnpasskeylogin"
class=
"btn btn-primary w-100 mb-2"
>
Sign In with Passkey
</button>
<div
class=
"text-center"
>
<a
id=
"view.login.switchmanual"
href=
"#"
class=
"small text-muted"
>
Use a different account
</a>
</div>
</div>
</div>
<div
class=
"mb-3"
>
<input
id=
"view.login.edtpassword"
<!-- Manual login section -->
class=
"form-control"
<div
id=
"view.login.manualsection"
>
type=
"password"
<div
class=
"mb-3"
>
placeholder=
"Password"
>
<input
id=
"view.login.edtusername"
</div>
class=
"form-control"
<div
class=
"mb-3"
>
type=
"text"
<select
id=
"view.login.edtagency"
class=
"form-select"
>
placeholder=
"Username"
<!-- populated dynamically -->
autofocus
>
</select>
</div>
<div
class=
"mb-3"
>
<input
id=
"view.login.edtpassword"
class=
"form-control"
type=
"password"
placeholder=
"Password"
>
</div>
<div
class=
"mb-3"
>
<select
id=
"view.login.edtagency"
class=
"form-select"
>
<!-- populated dynamically -->
</select>
</div>
<button
id=
"view.login.btnlogin"
class=
"btn btn-primary w-100"
>
Login
</button>
</div>
</div>
<button
id=
"view.login.btnlogin"
class=
"btn btn-primary w-100"
>
Login
</button>
</div>
</div>
<div
class=
"card-footer text-muted small d-flex justify-content-between"
>
<div
class=
"card-footer text-muted small d-flex justify-content-between"
>
<span>
Please use your lems username
&
password to login.
</span>
<span>
Please use your lems username
&
password to login.
</span>
...
...
webEMIMobile/View.Login.pas
View file @
886d9a67
...
@@ -25,12 +25,15 @@ type
...
@@ -25,12 +25,15 @@ type
private
private
FLoginProc
:
TSuccessProc
;
FLoginProc
:
TSuccessProc
;
FMessage
:
string
;
FMessage
:
string
;
FAutoUsername
:
string
;
FAutoAgency
:
string
;
procedure
ShowNotification
(
Notification
:
string
);
procedure
ShowNotification
(
Notification
:
string
);
procedure
HideNotification
;
procedure
HideNotification
;
procedure
GetAgencyConfigList
;
procedure
GetAgencyConfigList
;
procedure
SetBusy
(
ABusy
:
Boolean
);
procedure
SetBusy
(
ABusy
:
Boolean
);
procedure
DoWebAuthnGet
(
AUser
,
APassword
,
AAgency
,
ACredentialId
,
procedure
DoWebAuthnGet
(
AUser
,
APassword
,
AAgency
,
ACredentialId
,
AChallenge
,
AChallengeToken
:
string
);
AChallenge
,
AChallengeToken
:
string
);
procedure
DoPasskeyAutoLogin
(
ACredentialId
,
AChallenge
,
AChallengeToken
:
string
);
public
public
class
procedure
Display
(
LoginProc
:
TSuccessProc
);
overload
;
class
procedure
Display
(
LoginProc
:
TSuccessProc
);
overload
;
class
procedure
Display
(
LoginProc
:
TSuccessProc
;
AMsg
:
string
);
overload
;
class
procedure
Display
(
LoginProc
:
TSuccessProc
;
AMsg
:
string
);
overload
;
...
@@ -69,16 +72,80 @@ end;
...
@@ -69,16 +72,80 @@ end;
procedure
TFViewLogin
.
WebFormCreate
(
Sender
:
TObject
);
procedure
TFViewLogin
.
WebFormCreate
(
Sender
:
TObject
);
var
var
el
:
TJSElement
;
el
:
TJSElement
;
credId
:
string
;
procedure
OnDeviceUser
(
AUsername
,
AAgency
:
string
);
begin
FAutoUsername
:=
AUsername
;
FAutoAgency
:=
AAgency
;
if
AUsername
<>
''
then
begin
asm
var
autoSection
=
document
.
getElementById
(
'view.login.autosection'
);
var
manualSection
=
document
.
getElementById
(
'view.login.manualsection'
);
var
lbl
=
document
.
getElementById
(
'view.login.autouserlabel'
);
if
(
autoSection
)
autoSection
.
classList
.
remove
(
'd-none'
);
if
(
manualSection
)
manualSection
.
classList
.
add
(
'd-none'
);
if
(
lbl
)
lbl
.
textContent
=
AUsername
;
end
;
// Wire passkey button
asm
var
btn
=
document
.
getElementById
(
'view.login.btnpasskeylogin'
);
if
(
btn
)
{
btn.addEventListener('click', function() {
document.dispatchEvent(new CustomEvent('login-passkey'));
}
);
}
var
lnk
=
document
.
getElementById
(
'view.login.switchmanual'
);
if
(
lnk
)
{
lnk.addEventListener('click', function(e) {
e.preventDefault();
document.getElementById('view.login.autosection').classList.add('d-none');
document.getElementById('view.login.manualsection').classList.remove('d-none');
}
);
}
end
;
document
.
addEventListener
(
'login-passkey'
,
procedure
(
Event
:
TJSEvent
)
var
onBeginOK
:
TOnBeginSuccess
;
onBeginErr
:
TOnLoginError
;
storedCredId
:
string
;
begin
storedCredId
:=
AuthService
.
GetCredentialId
;
SetBusy
(
True
);
HideNotification
;
onBeginOK
:=
procedure
(
AChallenge
,
AChallengeToken
:
string
)
begin
DoPasskeyAutoLogin
(
storedCredId
,
AChallenge
,
AChallengeToken
);
end
;
onBeginErr
:=
procedure
(
AMsg
:
string
)
begin
SetBusy
(
False
);
ShowNotification
(
'Login Error: '
+
AMsg
);
end
;
AuthService
.
BeginAuthentication
(
storedCredId
,
onBeginOK
,
onBeginErr
);
end
);
end
;
end
;
begin
begin
GetAgencyConfigList
;
GetAgencyConfigList
;
el
:=
Document
.
getElementById
(
'view.login.version'
);
el
:=
Document
.
getElementById
(
'view.login.version'
);
if
Assigned
(
el
)
then
if
Assigned
(
el
)
then
TJSHtmlElement
(
el
).
innerText
:=
'v'
+
TDMConnection
.
clientVersion
;
TJSHtmlElement
(
el
).
innerText
:=
'v'
+
TDMConnection
.
clientVersion
;
GetAgencyConfigList
();
if
FMessage
<>
''
then
if
FMessage
<>
''
then
ShowNotification
(
FMessage
)
ShowNotification
(
FMessage
)
else
else
HideNotification
;
HideNotification
;
credId
:=
AuthService
.
GetCredentialId
;
if
credId
<>
''
then
AuthService
.
GetDeviceUser
(
credId
,
@
OnDeviceUser
);
end
;
end
;
procedure
TFViewLogin
.
SetBusy
(
ABusy
:
Boolean
);
procedure
TFViewLogin
.
SetBusy
(
ABusy
:
Boolean
);
...
@@ -208,6 +275,82 @@ begin
...
@@ -208,6 +275,82 @@ begin
end
;
end
;
end
;
end
;
procedure
TFViewLogin
.
DoPasskeyAutoLogin
(
ACredentialId
,
AChallenge
,
AChallengeToken
:
string
);
var
credentialId
,
challenge
,
challengeToken
:
string
;
procedure
OnLoginOK
;
begin
FLoginProc
;
end
;
procedure
OnLoginError
(
AMsg
:
string
);
begin
SetBusy
(
False
);
ShowNotification
(
'Login Error: '
+
AMsg
);
// If auto-login fails, fall back to manual form
asm
var
autoSection
=
document
.
getElementById
(
'view.login.autosection'
);
var
manualSection
=
document
.
getElementById
(
'view.login.manualsection'
);
if
(
autoSection
)
autoSection
.
classList
.
add
(
'd-none'
);
if
(
manualSection
)
manualSection
.
classList
.
remove
(
'd-none'
);
end
;
end
;
procedure
OnAssertion
(
AAuthData
,
AClientDataJSON
,
ASignature
:
string
);
begin
AuthService
.
LoginAutomatic
(
credentialId
,
challengeToken
,
AAuthData
,
AClientDataJSON
,
ASignature
,
@
OnLoginOK
,
@
OnLoginError
);
end
;
procedure
OnWebAuthnError
(
AMsg
:
string
);
begin
SetBusy
(
False
);
ShowNotification
(
'Passkey Error: '
+
AMsg
);
end
;
begin
credentialId
:=
ACredentialId
;
challenge
:=
AChallenge
;
challengeToken
:=
AChallengeToken
;
asm
(
function
()
{
function b64urlToArr(b64) {
b64 = b64.replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4) b64 += '=';
var bin = atob(b64);
var arr = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return arr;
}
function
arrToB64url
(
buf
)
{
var bin = String.fromCharCode.apply(null, new Uint8Array(buf));
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
navigator
.
credentials
.
get
(
{
publicKey: {
challenge: b64urlToArr(challenge),
rpId: window.location.hostname,
allowCredentials: [{ id: b64urlToArr(credentialId), type: 'public-key' }
],
timeout
:
60000
,
userVerification
:
'required'
}
}).
then
(
function
(
assertion
)
{
var authData = arrToB64url(assertion.response.authenticatorData);
var cdJson = arrToB64url(assertion.response.clientDataJSON);
var sig = arrToB64url(assertion.response.signature);
OnAssertion(authData, cdJson, sig);
}
).
catch
(
function
(
err
)
{
OnWebAuthnError('WebAuthn error: ' + err.message);
}
);
})();
end
;
end
;
procedure
TFViewLogin
.
GetAgencyConfigList
;
procedure
TFViewLogin
.
GetAgencyConfigList
;
procedure
OnLoad
(
Response
:
TXDataClientResponse
);
procedure
OnLoad
(
Response
:
TXDataClientResponse
);
...
...
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