Commit 886d9a67 by Michael Brachmann

updates for auto login after passkey

parent cf24b2b6
...@@ -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
......
...@@ -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, username, ' +
' 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);
......
...@@ -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
......
...@@ -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;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
......
-- 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);
...@@ -17,6 +17,7 @@ type ...@@ -17,6 +17,7 @@ type
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;
......
...@@ -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:160px;">Actions</th> <th style="min-width:180px;">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody id="view.devmgr.tbody"> <tbody id="view.devmgr.tbody">
......
...@@ -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, AUsername, AUserAgent, ARegisteredAt, AStatus: string);
var var
tbody, tr, tdName, tdPhone, tdAgent, tdReg, tdStatus, tdAction: TJSHTMLElement; tbody, tr, tdName, tdPhone, tdUser, tdAgent, 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;
......
...@@ -29,6 +29,23 @@ ...@@ -29,6 +29,23 @@
aria-label="Close"></button> aria-label="Close"></button>
</div> </div>
<!-- Auto-login section (shown when device has a stored username) -->
<div id="view.login.autosection" class="d-none">
<p class="text-center text-muted mb-1 small">Signing in as</p>
<p id="view.login.autouserlabel"
class="text-center fw-semibold fs-5 mb-3"></p>
<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>
<!-- Manual login section -->
<div id="view.login.manualsection">
<div class="mb-3"> <div class="mb-3">
<input id="view.login.edtusername" <input id="view.login.edtusername"
class="form-control" class="form-control"
...@@ -52,6 +69,7 @@ ...@@ -52,6 +69,7 @@
Login Login
</button> </button>
</div> </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 &amp; password to login.</span> <span>Please use your lems username &amp; password to login.</span>
<span id="view.login.version" class="opacity-75"></span> <span id="view.login.version" class="opacity-75"></span>
......
...@@ -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);
......
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