Commit 9137ff06 by Mac Stephens

Merge device management and add ADMIN login through ?devices without device…

Merge device management and add ADMIN login through ?devices without device registration; preserve server fixes and restore password visibility button
parents 46bac255 4a520774
...@@ -7,7 +7,8 @@ uses ...@@ -7,7 +7,8 @@ uses
Aurelius.Mapping.Attributes, Aurelius.Mapping.Attributes,
System.JSON, System.JSON,
System.Generics.Collections, System.Generics.Collections,
System.Classes; System.Classes,
Auth.Service; // for TDeviceItem / TDeviceList
const const
API_MODEL = 'Api'; API_MODEL = 'Api';
...@@ -30,10 +31,16 @@ type ...@@ -30,10 +31,16 @@ type
[HttpGet] function GetUnitDetails(const UnitId: string): TJSONObject; [HttpGet] function GetUnitDetails(const UnitId: string): TJSONObject;
[HttpGet] function GetUnitLogs(const UnitId: string): TJSONObject; [HttpGet] function GetUnitLogs(const UnitId: string): TJSONObject;
// Device management — requires valid JWT; caller must also have user_admin = true
[HttpGet] function GetDeviceList: TDeviceList;
function RevokeDevice(const CredentialId: string): TJSONObject;
function UnrevokeDevice(const CredentialId: string): TJSONObject;
function AddPendingDevice(const DeviceName, PhoneNumber: string): TJSONObject;
function DeletePendingDevice(const PhoneNumber: string): TJSONObject;
function SendAppLink(const PhoneNumber: string): TJSONObject;
function UpdateDeviceUsername(const CredentialId, Username: string): TJSONObject;
end; end;
implementation implementation
end. end.
...@@ -39,13 +39,64 @@ type ...@@ -39,13 +39,64 @@ type
data: TList<TAgencyConfigItem>; data: TList<TAgencyConfigItem>;
end; end;
// Device record returned by GetDeviceList (Api.Service)
TDeviceItem = class
public
id: Integer;
credential_id: string;
device_name: string;
phone_number: string;
username: string;
agency: string;
user_agent: string;
registered_at: string;
revoked_at: string;
revoked_by: string;
status: string;
end;
TDeviceList = class
public
count: Integer;
returned: Integer;
data: TList<TDeviceItem>;
end;
[ServiceContract, Model(AUTH_MODEL)] [ServiceContract, Model(AUTH_MODEL)]
IAuthService = interface(IInvokable) IAuthService = interface(IInvokable)
['{D2290B28-964C-4155-A83A-DAE87C4C7FE7}'] ['{D2290B28-964C-4155-A83A-DAE87C4C7FE7}']
function Login(const user, password, agency: string): string; // Full WebAuthn assertion login — issues JWT on success
function Login(const user, password, agency, credentialId,
challengeToken, authenticatorData, clientDataJSON,
signature: string): string;
function LoginDeviceManager(User, Password, Agency: string): string;
[HttpGet] function GetAgenciesList(): TAgenciesList; [HttpGet] function GetAgenciesList(): TAgenciesList;
[HttpGet] function GetAgencyConfigList: TAgencyConfigList; [HttpGet] function GetAgencyConfigList: TAgencyConfigList;
function VerifyVersion(ClientVersion: string): TJSONObject; function VerifyVersion(ClientVersion: string): TJSONObject;
// WebAuthn registration — step 1: server checks phone pre-auth, returns challenge
function BeginRegistration(const PhoneNumber: string): TJSONObject;
// WebAuthn registration — step 2: client submits credential, server verifies + activates pending row
function CompleteRegistration(const PhoneNumber, CredentialId,
AttestationObject, ClientDataJSON,
ChallengeToken: string): TJSONObject;
// WebAuthn authentication challenge — called before Login
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;
// Simple-key registration — client generates key, no crypto verification
function CompleteRegistrationSimple(const PhoneNumber, DeviceKey,
ChallengeToken: string): TJSONObject;
// Simple-key auto-login — verifies challenge token + credential ownership
function LoginDeviceKey(const CredentialId, ChallengeToken: string): string;
// Simple-key password login — password + challenge token (first login / fallback)
function LoginPasswordAndSimpleKey(const User, Password, Agency,
CredentialId, ChallengeToken: string): string;
end; end;
implementation implementation
......
...@@ -14,6 +14,11 @@ type ...@@ -14,6 +14,11 @@ type
FWebAppFolder: string; FWebAppFolder: string;
FReportsFolder: string; FReportsFolder: string;
FAuditEnabled: Boolean; FAuditEnabled: Boolean;
FRpId: 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;
...@@ -22,6 +27,13 @@ type ...@@ -22,6 +27,13 @@ type
property webAppFolder: string read FWebAppFolder write FWebAppFolder; property webAppFolder: string read FWebAppFolder write FWebAppFolder;
property reportsFolder: string read FReportsFolder write FReportsFolder; property reportsFolder: string read FReportsFolder write FReportsFolder;
property auditEnabled: Boolean read FAuditEnabled write FAuditEnabled; property auditEnabled: Boolean read FAuditEnabled write FAuditEnabled;
// WebAuthn Relying Party — must match the domain serving the app (e.g. "localhost")
property rpId: string read FRpId write FRpId;
property rpName: string read FRpName write FRpName;
// Twilio SMS (for sending App Store redemption links)
property twilioAccountSid: string read FTwilioAccountSid write FTwilioAccountSid;
property twilioAuthToken: string read FTwilioAuthToken write FTwilioAuthToken;
property twilioFromNumber: string read FTwilioFromNumber write FTwilioFromNumber;
end; end;
procedure LoadServerConfig; procedure LoadServerConfig;
...@@ -32,41 +44,74 @@ var ...@@ -32,41 +44,74 @@ var
implementation implementation
uses uses
Bcl.Json, System.SysUtils, System.IOUtils, System.StrUtils, System.JSON, System.SysUtils, System.IOUtils, System.StrUtils,
Common.Logging; Common.Logging;
procedure LoadServerConfig; procedure LoadServerConfig;
function GetStr(obj: TJSONObject; const key: string): string;
var
v: TJSONValue;
begin
v := obj.GetValue(key);
if Assigned(v) and not (v is TJSONNull) then
Result := v.Value
else
Result := '';
end;
var var
configFile: string; configFile, s: string;
localConfig: TServerConfig; jsonObj: TJSONObject;
begin begin
Logger.Log(1, '--LoadServerConfig - start'); Logger.Log(1, '--LoadServerConfig - start');
configFile := TPath.ChangeExtension(ParamStr(0), '.json'); configFile := TPath.ChangeExtension(ParamStr(0), '.json');
Logger.Log(1, '-- Config file: ' + configFile); Logger.Log(1, '-- Config file: ' + configFile);
if TFile.Exists(configFile) then if not TFile.Exists(configFile) then
begin
Logger.Log(1, '-- Config file found.');
localConfig := TJson.Deserialize<TServerConfig>(TFile.ReadAllText(configFile));
Logger.Log(1, '-- localConfig loaded from config file');
serverConfig.Free;
Logger.Log(1, '-- serverConfig.Free - called');
serverConfig := localConfig;
Logger.Log(1, '-- serverConfig := localConfig - called');
Logger.Log(1, '');
Logger.Log(1, '--- Server Config Values ---');
Logger.Log(1, '-- url: ' + serverConfig.url + IfThen(serverConfig.url = defaultServerUrl, ' [default]', ' [from config]'));
Logger.Log(1, '-- adminPassword: ' + serverConfig.adminPassword + IfThen(serverConfig.adminPassword = 'whatisthisusedfor', ' [default]', ' [from config]'));
Logger.Log(1, '-- jwtTokenSecret: ' + serverConfig.jwtTokenSecret + IfThen(serverConfig.jwtTokenSecret = 'super_secret0123super_secret4567', ' [default]', ' [from config]'));
Logger.Log(1, '-- webAppFolder: ' + serverConfig.webAppFolder + IfThen(serverConfig.webAppFolder = 'static', ' [default]', ' [from config]'));
Logger.Log(1, '-- auditEnabled: ' + BoolToStr(serverConfig.auditEnabled, True));
end
else
begin begin
Logger.Log(1, '-- Config file not found.'); Logger.Log(1, '-- Config file not found.');
Logger.Log(1, '--LoadServerConfig - end');
Exit;
end;
Logger.Log(1, '-- Config file found.');
jsonObj := TJSONObject.ParseJSONValue(TFile.ReadAllText(configFile)) as TJSONObject;
if not Assigned(jsonObj) then
begin
Logger.Log(1, '-- Config file could not be parsed as JSON.');
Logger.Log(1, '--LoadServerConfig - end');
Exit;
end;
try
s := GetStr(jsonObj, 'url'); if s <> '' then serverConfig.url := s;
s := GetStr(jsonObj, 'jwtTokenSecret'); if s <> '' then serverConfig.jwtTokenSecret := s;
s := GetStr(jsonObj, 'adminPassword'); if s <> '' then serverConfig.adminPassword := s;
s := GetStr(jsonObj, 'webAppFolder'); if s <> '' then serverConfig.webAppFolder := s;
s := GetStr(jsonObj, 'reportsFolder'); if s <> '' then serverConfig.reportsFolder := s;
s := GetStr(jsonObj, 'rpId'); if s <> '' then serverConfig.rpId := s;
s := GetStr(jsonObj, 'rpName'); if s <> '' then serverConfig.rpName := s;
serverConfig.twilioAccountSid := GetStr(jsonObj, 'twilioAccountSid');
serverConfig.twilioAuthToken := GetStr(jsonObj, 'twilioAuthToken');
serverConfig.twilioFromNumber := GetStr(jsonObj, 'twilioFromNumber');
serverConfig.auditEnabled := jsonObj.GetValue<Boolean>('auditEnabled', serverConfig.auditEnabled);
finally
jsonObj.Free;
end; end;
Logger.Log(1, '');
Logger.Log(1, '--- Server Config Values ---');
Logger.Log(1, '-- url: ' + serverConfig.url + IfThen(serverConfig.url = defaultServerUrl, ' [default]', ' [from config]'));
Logger.Log(1, '-- adminPassword: ' + serverConfig.adminPassword + IfThen(serverConfig.adminPassword = 'whatisthisusedfor', ' [default]', ' [from config]'));
Logger.Log(1, '-- jwtTokenSecret: ' + serverConfig.jwtTokenSecret + IfThen(serverConfig.jwtTokenSecret = 'super_secret0123super_secret4567', ' [default]', ' [from config]'));
Logger.Log(1, '-- webAppFolder: ' + serverConfig.webAppFolder + IfThen(serverConfig.webAppFolder = 'static', ' [default]', ' [from config]'));
Logger.Log(1, '-- rpId: ' + serverConfig.rpId + IfThen(serverConfig.rpId = 'wcemimobile.em-sys.net', ' [default]', ' [from config]'));
Logger.Log(1, '-- auditEnabled: ' + BoolToStr(serverConfig.auditEnabled, True));
Logger.Log(1, '-- twilioAccountSid: ' + IfThen(serverConfig.twilioAccountSid <> '', '[configured]', '[not set]'));
Logger.Log(1, '-- twilioAuthToken: ' + IfThen(serverConfig.twilioAuthToken <> '', '[configured]', '[not set]'));
Logger.Log(1, '-- twilioFromNumber: ' + serverConfig.twilioFromNumber + IfThen(serverConfig.twilioFromNumber <> '', '', ' [not set]'));
Logger.Log(1, '-------------------------------------------------------------'); Logger.Log(1, '-------------------------------------------------------------');
Logger.Log(1, '--LoadServerConfig - end'); Logger.Log(1, '--LoadServerConfig - end');
end; end;
...@@ -82,6 +127,8 @@ begin ...@@ -82,6 +127,8 @@ begin
webAppFolder := 'static'; webAppFolder := 'static';
reportsFolder := 'reports'; reportsFolder := 'reports';
auditEnabled := False; auditEnabled := False;
rpId := 'wcemimobile.em-sys.net';
rpName := 'emiMobile';
Logger.Log(1, '--TServerConfig.Create - end'); Logger.Log(1, '--TServerConfig.Create - end');
end; end;
......
unit Webauthn.Cbor;
{
Minimal CBOR (RFC 7049) decoder for WebAuthn attestationObject and authData parsing.
Supports only what is needed: unsigned/negative integers, byte strings, text strings,
arrays, and maps. Indefinite-length items are not supported.
}
interface
uses
System.SysUtils, System.Classes;
// Extract the authData byte string from a CBOR-encoded attestationObject map.
function CborGetAuthData(const AAttestationObject: TBytes; out AAuthData: TBytes): Boolean;
// Parse a COSE EC2 public key from CBOR bytes starting at APos.
// Advances APos past the COSE map on success.
// Returns True if a valid ES256 (alg=-7) P-256 key with 32-byte x and y is found.
function CborParseCoseKey(const AData: TBytes; var APos: Integer;
out AX, AY: TBytes; out AAlg: Integer): Boolean;
// Parse the fixed-layout authData structure.
// AT flag (bit 6) must be set; otherwise ACredentialId/APublicKey fields are empty.
function ParseAuthData(const AAuthData: TBytes;
out ARpIdHash: TBytes;
out AFlags: Byte;
out ASignCount: Cardinal;
out ACredentialId: TBytes;
out APubKeyX, APubKeyY: TBytes;
out APubKeyAlg: Integer): Boolean;
implementation
// ---------------------------------------------------------------------------
// Internal CBOR reader primitives
// ---------------------------------------------------------------------------
// Read the initial byte and additional length/value bytes.
// For major type 1 (negative int), AValue is returned as the negative result: -1 - raw.
// Returns False if data is truncated or an unsupported additional-info is encountered.
function CborReadHead(const AData: TBytes; var APos: Integer;
out AMajorType: Byte; out AValue: Int64): Boolean;
var
b, addInfo: Byte;
begin
Result := False;
if APos >= Length(AData) then Exit;
b := AData[APos]; Inc(APos);
AMajorType := b shr 5;
addInfo := b and $1F;
case addInfo of
0..23: AValue := addInfo;
24:
begin
if APos >= Length(AData) then Exit;
AValue := AData[APos]; Inc(APos);
end;
25:
begin
if APos + 1 > Length(AData) then Exit;
AValue := (Int64(AData[APos]) shl 8) or AData[APos + 1];
Inc(APos, 2);
end;
26:
begin
if APos + 3 > Length(AData) then Exit;
AValue := (Int64(AData[APos]) shl 24) or
(Int64(AData[APos + 1]) shl 16) or
(Int64(AData[APos + 2]) shl 8) or
AData[APos + 3];
Inc(APos, 4);
end;
27:
begin
if APos + 7 > Length(AData) then Exit;
AValue := (Int64(AData[APos]) shl 56) or
(Int64(AData[APos + 1]) shl 48) or
(Int64(AData[APos + 2]) shl 40) or
(Int64(AData[APos + 3]) shl 32) or
(Int64(AData[APos + 4]) shl 24) or
(Int64(AData[APos + 5]) shl 16) or
(Int64(AData[APos + 6]) shl 8) or
AData[APos + 7];
Inc(APos, 8);
end;
else
Exit; // indefinite-length or reserved — not supported
end;
if AMajorType = 1 then
AValue := -1 - AValue;
Result := True;
end;
function CborReadBytes(const AData: TBytes; var APos: Integer;
out AResult: TBytes): Boolean;
var
mt: Byte;
count: Int64;
begin
Result := False;
if not CborReadHead(AData, APos, mt, count) then Exit;
if mt <> 2 then Exit;
if (count < 0) or (APos + count > Length(AData)) then Exit;
SetLength(AResult, count);
if count > 0 then
Move(AData[APos], AResult[0], count);
Inc(APos, Integer(count));
Result := True;
end;
function CborReadText(const AData: TBytes; var APos: Integer;
out AResult: string): Boolean;
var
mt: Byte;
count: Int64;
raw: TBytes;
begin
Result := False;
if not CborReadHead(AData, APos, mt, count) then Exit;
if mt <> 3 then Exit;
if (count < 0) or (APos + count > Length(AData)) then Exit;
SetLength(raw, count);
if count > 0 then
Move(AData[APos], raw[0], count);
Inc(APos, Integer(count));
AResult := TEncoding.UTF8.GetString(raw);
Result := True;
end;
function CborReadInt(const AData: TBytes; var APos: Integer;
out AResult: Int64): Boolean;
var
mt: Byte;
begin
Result := False;
if not CborReadHead(AData, APos, mt, AResult) then Exit;
Result := (mt = 0) or (mt = 1);
end;
// Skip any CBOR item at APos, advancing APos past it.
function CborSkipItem(const AData: TBytes; var APos: Integer): Boolean;
var
mt: Byte;
count, i: Int64;
begin
Result := False;
if not CborReadHead(AData, APos, mt, count) then Exit;
case mt of
0, 1: Result := True; // integer — head already consumed
2, 3: // byte string or text string
begin
if (count < 0) or (APos + count > Length(AData)) then Exit;
Inc(APos, Integer(count));
Result := True;
end;
4: // array
begin
for i := 0 to count - 1 do
if not CborSkipItem(AData, APos) then Exit;
Result := True;
end;
5: // map
begin
for i := 0 to count - 1 do
begin
if not CborSkipItem(AData, APos) then Exit; // key
if not CborSkipItem(AData, APos) then Exit; // value
end;
Result := True;
end;
6: // tag — skip tagged item
Result := CborSkipItem(AData, APos);
7: // simple / float — additional bytes already consumed by CborReadHead
Result := True;
end;
end;
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
function CborGetAuthData(const AAttestationObject: TBytes;
out AAuthData: TBytes): Boolean;
var
pos: Integer;
mt: Byte;
mapCount, i: Int64;
key: string;
begin
Result := False;
pos := 0;
if not CborReadHead(AAttestationObject, pos, mt, mapCount) then Exit;
if mt <> 5 then Exit; // must be a CBOR map
for i := 0 to mapCount - 1 do
begin
if not CborReadText(AAttestationObject, pos, key) then Exit;
if key = 'authData' then
begin
Result := CborReadBytes(AAttestationObject, pos, AAuthData);
Exit;
end;
// skip the value for any other key
if not CborSkipItem(AAttestationObject, pos) then Exit;
end;
end;
function CborParseCoseKey(const AData: TBytes; var APos: Integer;
out AX, AY: TBytes; out AAlg: Integer): Boolean;
var
mt: Byte;
mapCount, i, keyInt, valInt: Int64;
begin
Result := False;
AAlg := 0;
SetLength(AX, 0);
SetLength(AY, 0);
if not CborReadHead(AData, APos, mt, mapCount) then Exit;
if mt <> 5 then Exit;
for i := 0 to mapCount - 1 do
begin
if not CborReadInt(AData, APos, keyInt) then Exit;
case keyInt of
3: // alg
begin
if not CborReadInt(AData, APos, valInt) then Exit;
AAlg := Integer(valInt);
end;
-2: // x coordinate
begin
if not CborReadBytes(AData, APos, AX) then Exit;
end;
-3: // y coordinate
begin
if not CborReadBytes(AData, APos, AY) then Exit;
end;
else
if not CborSkipItem(AData, APos) then Exit;
end;
end;
Result := (Length(AX) = 32) and (Length(AY) = 32);
end;
function ParseAuthData(const AAuthData: TBytes;
out ARpIdHash: TBytes;
out AFlags: Byte;
out ASignCount: Cardinal;
out ACredentialId: TBytes;
out APubKeyX, APubKeyY: TBytes;
out APubKeyAlg: Integer): Boolean;
var
pos: Integer;
credIdLen: Word;
begin
Result := False;
pos := 0;
// Minimum length for fixed header: 32 (rpIdHash) + 1 (flags) + 4 (signCount) = 37
if Length(AAuthData) < 37 then Exit;
SetLength(ARpIdHash, 32);
Move(AAuthData[0], ARpIdHash[0], 32);
pos := 32;
AFlags := AAuthData[pos]; Inc(pos);
ASignCount := (Cardinal(AAuthData[pos]) shl 24) or
(Cardinal(AAuthData[pos + 1]) shl 16) or
(Cardinal(AAuthData[pos + 2]) shl 8) or
AAuthData[pos + 3];
Inc(pos, 4);
// AT flag = bit 6 ($40) — attested credential data present
if (AFlags and $40) = 0 then
begin
Result := True; // no credential data, valid for authentication assertions
Exit;
end;
// Skip AAGUID (16 bytes)
if pos + 16 > Length(AAuthData) then Exit;
Inc(pos, 16);
// Credential ID length (2 bytes big-endian)
if pos + 2 > Length(AAuthData) then Exit;
credIdLen := (Word(AAuthData[pos]) shl 8) or AAuthData[pos + 1];
Inc(pos, 2);
// Credential ID
if pos + Integer(credIdLen) > Length(AAuthData) then Exit;
SetLength(ACredentialId, credIdLen);
if credIdLen > 0 then
Move(AAuthData[pos], ACredentialId[0], credIdLen);
Inc(pos, credIdLen);
// COSE public key
Result := CborParseCoseKey(AAuthData, pos, APubKeyX, APubKeyY, APubKeyAlg);
end;
end.
unit Webauthn.Crypto;
interface
uses
System.SysUtils, System.NetEncoding, System.Hash;
function Base64UrlEncode(const ABytes: TBytes): string;
function Base64UrlDecode(const AStr: string): TBytes;
function SHA256Bytes(const AData: TBytes): TBytes;
function HMACSHA256Bytes(const AKey, AData: TBytes): TBytes;
function RandomBytes(ACount: Integer): TBytes;
// Convert DER-encoded ECDSA signature (from WebAuthn) to raw 64-byte r||s
function DerSigToRaw(const ADer: TBytes): TBytes;
// Verify ES256 ECDSA-P256 signature over AMessage (raw bytes, hashed internally)
// APubKeyX, APubKeyY: raw 32-byte big-endian coordinates
// ASignatureDer: DER-encoded signature bytes from WebAuthn assertion
function VerifyECDSAP256(const APubKeyX, APubKeyY, AMessage, ASignatureDer: TBytes): Boolean;
implementation
uses
Winapi.Windows;
const
BCRYPT_ECDSA_PUBLIC_P256_MAGIC: DWORD = $31534345;
BCRYPT_ECC_PUBLIC_BLOB = 'ECCPUBLICBLOB';
STATUS_SUCCESS = LongInt(0);
BCRYPT_USE_SYSTEM_PREFERRED_RNG: DWORD = 2;
type
NTSTATUS = LongInt;
BCRYPT_ALG_HANDLE = THandle;
BCRYPT_KEY_HANDLE = THandle;
BCRYPT_ECCKEY_BLOB = packed record
dwMagic: DWORD;
cbKey: DWORD;
end;
function BCryptOpenAlgorithmProvider(out phAlgorithm: BCRYPT_ALG_HANDLE;
pszAlgId, pszImplementation: PWideChar; dwFlags: DWORD): NTSTATUS;
stdcall; external 'bcrypt.dll';
function BCryptCloseAlgorithmProvider(hAlgorithm: BCRYPT_ALG_HANDLE;
dwFlags: DWORD): NTSTATUS;
stdcall; external 'bcrypt.dll';
function BCryptImportKeyPair(hAlgorithm: BCRYPT_ALG_HANDLE;
hImportKey: BCRYPT_KEY_HANDLE; pszBlobType: PWideChar;
out phKey: BCRYPT_KEY_HANDLE; pbInput: PByte; cbInput: DWORD;
dwFlags: DWORD): NTSTATUS;
stdcall; external 'bcrypt.dll';
function BCryptDestroyKey(hKey: BCRYPT_KEY_HANDLE): NTSTATUS;
stdcall; external 'bcrypt.dll';
function BCryptVerifySignature(hKey: BCRYPT_KEY_HANDLE; pPaddingInfo: Pointer;
pbHash: PByte; cbHash: DWORD; pbSignature: PByte; cbSignature: DWORD;
dwFlags: DWORD): NTSTATUS;
stdcall; external 'bcrypt.dll';
function BCryptGenRandom(hAlgorithm: BCRYPT_ALG_HANDLE; pbBuffer: PByte;
cbBuffer: DWORD; dwFlags: DWORD): NTSTATUS;
stdcall; external 'bcrypt.dll';
// ---------------------------------------------------------------------------
function Base64UrlEncode(const ABytes: TBytes): string;
begin
Result := TNetEncoding.Base64.EncodeBytesToString(ABytes);
Result := Result.Replace('+', '-').Replace('/', '_').TrimRight(['=']);
end;
function Base64UrlDecode(const AStr: string): TBytes;
var
s: string;
padLen: Integer;
begin
s := AStr.Replace('-', '+').Replace('_', '/');
padLen := (4 - (Length(s) mod 4)) mod 4;
if padLen > 0 then
s := s + StringOfChar('=', padLen);
Result := TNetEncoding.Base64.DecodeStringToBytes(s);
end;
function SHA256Bytes(const AData: TBytes): TBytes;
var
Hash: THashSHA2;
begin
Hash := THashSHA2.Create(SHA256);
Hash.Update(AData);
Result := Hash.HashAsBytes;
end;
function HMACSHA256Bytes(const AKey, AData: TBytes): TBytes;
const
BlockSize = 64;
var
normKey: TBytes;
ipadKey, opadKey: TBytes;
innerData, outerData: TBytes;
innerHash: TBytes;
i: Integer;
begin
// Normalize key to block size
SetLength(normKey, BlockSize);
FillChar(normKey[0], BlockSize, 0);
if Length(AKey) > BlockSize then
begin
innerHash := SHA256Bytes(AKey);
Move(innerHash[0], normKey[0], Length(innerHash));
end
else if Length(AKey) > 0 then
Move(AKey[0], normKey[0], Length(AKey));
SetLength(ipadKey, BlockSize);
SetLength(opadKey, BlockSize);
for i := 0 to BlockSize - 1 do
begin
ipadKey[i] := normKey[i] xor $36;
opadKey[i] := normKey[i] xor $5C;
end;
// inner = SHA256(ipadKey || data)
SetLength(innerData, BlockSize + Length(AData));
Move(ipadKey[0], innerData[0], BlockSize);
if Length(AData) > 0 then
Move(AData[0], innerData[BlockSize], Length(AData));
innerHash := SHA256Bytes(innerData);
// result = SHA256(opadKey || innerHash)
SetLength(outerData, BlockSize + 32);
Move(opadKey[0], outerData[0], BlockSize);
Move(innerHash[0], outerData[BlockSize], 32);
Result := SHA256Bytes(outerData);
end;
function RandomBytes(ACount: Integer): TBytes;
begin
SetLength(Result, ACount);
if ACount > 0 then
BCryptGenRandom(0, @Result[0], ACount, BCRYPT_USE_SYSTEM_PREFERRED_RNG);
end;
function DerSigToRaw(const ADer: TBytes): TBytes;
var
pos, rLen, sLen, rStart, sStart: Integer;
begin
SetLength(Result, 64);
FillChar(Result[0], 64, 0);
pos := 0;
if (Length(ADer) < 8) or (ADer[pos] <> $30) then Exit;
Inc(pos);
// Skip sequence length (handle 1-byte and 2-byte forms)
if ADer[pos] = $81 then Inc(pos);
Inc(pos);
// r integer
if (pos >= Length(ADer)) or (ADer[pos] <> $02) then Exit;
Inc(pos);
rLen := ADer[pos]; Inc(pos);
rStart := pos;
Inc(pos, rLen);
// s integer
if (pos >= Length(ADer)) or (ADer[pos] <> $02) then Exit;
Inc(pos);
sLen := ADer[pos]; Inc(pos);
sStart := pos;
// Copy r right-justified into Result[0..31], stripping leading 0x00
if (rLen > 0) and (ADer[rStart] = $00) then begin Inc(rStart); Dec(rLen); end;
if rLen > 32 then begin Inc(rStart, rLen - 32); rLen := 32; end;
if rLen > 0 then
Move(ADer[rStart], Result[32 - rLen], rLen);
// Copy s right-justified into Result[32..63], stripping leading 0x00
if (sLen > 0) and (ADer[sStart] = $00) then begin Inc(sStart); Dec(sLen); end;
if sLen > 32 then begin Inc(sStart, sLen - 32); sLen := 32; end;
if sLen > 0 then
Move(ADer[sStart], Result[64 - sLen], sLen);
end;
function VerifyECDSAP256(const APubKeyX, APubKeyY, AMessage, ASignatureDer: TBytes): Boolean;
var
algHandle: BCRYPT_ALG_HANDLE;
keyHandle: BCRYPT_KEY_HANDLE;
blob: TBytes;
header: BCRYPT_ECCKEY_BLOB;
msgHash, rawSig: TBytes;
status: NTSTATUS;
begin
Result := False;
if (Length(APubKeyX) <> 32) or (Length(APubKeyY) <> 32) then Exit;
header.dwMagic := BCRYPT_ECDSA_PUBLIC_P256_MAGIC;
header.cbKey := 32;
SetLength(blob, SizeOf(BCRYPT_ECCKEY_BLOB) + 64);
Move(header, blob[0], SizeOf(BCRYPT_ECCKEY_BLOB));
Move(APubKeyX[0], blob[SizeOf(BCRYPT_ECCKEY_BLOB)], 32);
Move(APubKeyY[0], blob[SizeOf(BCRYPT_ECCKEY_BLOB) + 32], 32);
// ES256 signs SHA-256(message)
msgHash := SHA256Bytes(AMessage);
rawSig := DerSigToRaw(ASignatureDer);
if Length(rawSig) <> 64 then Exit;
status := BCryptOpenAlgorithmProvider(algHandle, 'ECDSA_P256', nil, 0);
if status <> STATUS_SUCCESS then Exit;
try
status := BCryptImportKeyPair(algHandle, 0, BCRYPT_ECC_PUBLIC_BLOB,
keyHandle, @blob[0], Length(blob), 0);
if status <> STATUS_SUCCESS then Exit;
try
status := BCryptVerifySignature(keyHandle, nil,
@msgHash[0], Length(msgHash),
@rawSig[0], Length(rawSig), 0);
Result := (status = STATUS_SUCCESS);
finally
BCryptDestroyKey(keyHandle);
end;
finally
BCryptCloseAlgorithmProvider(algHandle, 0);
end;
end;
end.
[Settings] [Settings]
LogFileNum=112 LogFileNum=116
webClientVersion=0.9.4.5 webClientVersion=0.9.4.5
[Database] [Database]
......
-- WebAuthn device registrations for emiMobile
-- Run against the LEMS database.
-- Drop and recreate if upgrading from the UUID-based schema.
DROP TABLE IF EXISTS lems.device_registrations;
CREATE TABLE lems.device_registrations (
id SERIAL PRIMARY KEY,
credential_id TEXT NOT NULL UNIQUE,
device_name VARCHAR(255),
user_agent TEXT,
public_key_x BYTEA NOT NULL,
public_key_y BYTEA NOT NULL,
public_key_alg INTEGER NOT NULL DEFAULT -7, -- -7 = ES256
sign_count BIGINT NOT NULL DEFAULT 0,
registered_at TIMESTAMPTZ DEFAULT NOW(),
revoked_at TIMESTAMPTZ,
revoked_by VARCHAR(255)
);
CREATE INDEX IF NOT EXISTS idx_device_reg_cred_id
ON lems.device_registrations (credential_id);
COMMENT ON TABLE lems.device_registrations IS
'WebAuthn (FIDO2) credential store for emiMobile device access control. '
'credential_id is the base64url-encoded credential ID from navigator.credentials.create(). '
'public_key_x/y are the raw 32-byte big-endian EC P-256 coordinates. '
'sign_count is updated after each successful authentication assertion. '
'Admins revoke access by setting revoked_at.';
-- Add key_type column to distinguish WebAuthn vs simple-key devices
ALTER TABLE lems.device_registrations
ADD COLUMN IF NOT EXISTS key_type VARCHAR(10) DEFAULT 'webauthn';
-- Back-fill existing active rows as webauthn
UPDATE lems.device_registrations
SET key_type = 'webauthn'
WHERE key_type IS NULL AND status = 'active';
-- Migration: add pending-device support to device_registrations
-- Run once against the lems database.
-- 1. Add status column (active for all existing rows)
ALTER TABLE lems.device_registrations
ADD COLUMN IF NOT EXISTS status VARCHAR(10) NOT NULL DEFAULT 'active';
-- 2. Mark already-revoked rows correctly
UPDATE lems.device_registrations
SET status = 'revoked'
WHERE revoked_at IS NOT NULL AND status = 'active';
-- 3. Allow credential_id / public-key columns to be NULL for pending rows
ALTER TABLE lems.device_registrations
ALTER COLUMN credential_id DROP NOT NULL;
ALTER TABLE lems.device_registrations
ALTER COLUMN public_key_x DROP NOT NULL;
ALTER TABLE lems.device_registrations
ALTER COLUMN public_key_y DROP NOT NULL;
-- 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;
-- 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);
...@@ -32,7 +32,9 @@ uses ...@@ -32,7 +32,9 @@ uses
Ws.Server.Module in 'Source\Ws.Server.Module.pas' {WsServerModule: TDataModule}, Ws.Server.Module in 'Source\Ws.Server.Module.pas' {WsServerModule: TDataModule},
Ws.DataModel in 'Source\Ws.DataModel.pas', Ws.DataModel in 'Source\Ws.DataModel.pas',
WsMessages in 'Source\shared\WsMessages.pas', WsMessages in 'Source\shared\WsMessages.pas',
WebSocket.Manager in 'Source\WebSocket.Manager.pas'; WebSocket.Manager in 'Source\WebSocket.Manager.pas',
Webauthn.Cbor in 'Source\Webauthn.Cbor.pas',
Webauthn.Crypto in 'Source\Webauthn.Crypto.pas';
type type
TMemoLogAppender = class( TInterfacedObject, ILogAppender ) TMemoLogAppender = class( TInterfacedObject, ILogAppender )
......
...@@ -180,6 +180,8 @@ ...@@ -180,6 +180,8 @@
<DCCReference Include="Source\Ws.DataModel.pas"/> <DCCReference Include="Source\Ws.DataModel.pas"/>
<DCCReference Include="Source\shared\WsMessages.pas"/> <DCCReference Include="Source\shared\WsMessages.pas"/>
<DCCReference Include="Source\WebSocket.Manager.pas"/> <DCCReference Include="Source\WebSocket.Manager.pas"/>
<DCCReference Include="Source\Webauthn.Cbor.pas"/>
<DCCReference Include="Source\Webauthn.Crypto.pas"/>
<BuildConfiguration Include="Base"> <BuildConfiguration Include="Base">
<Key>Base</Key> <Key>Base</Key>
</BuildConfiguration> </BuildConfiguration>
...@@ -872,9 +874,6 @@ ...@@ -872,9 +874,6 @@
<Platform Name="Win64x"> <Platform Name="Win64x">
<Operation>1</Operation> <Operation>1</Operation>
</Platform> </Platform>
<Platform Name="WinARM64EC">
<Operation>1</Operation>
</Platform>
</DeployClass> </DeployClass>
<DeployClass Name="ProjectiOSDeviceDebug"> <DeployClass Name="ProjectiOSDeviceDebug">
<Platform Name="iOSDevice32"> <Platform Name="iOSDevice32">
...@@ -945,10 +944,6 @@ ...@@ -945,10 +944,6 @@
<RemoteDir>Assets</RemoteDir> <RemoteDir>Assets</RemoteDir>
<Operation>1</Operation> <Operation>1</Operation>
</Platform> </Platform>
<Platform Name="WinARM64EC">
<RemoteDir>Assets</RemoteDir>
<Operation>1</Operation>
</Platform>
</DeployClass> </DeployClass>
<DeployClass Name="UWP_DelphiLogo44"> <DeployClass Name="UWP_DelphiLogo44">
<Platform Name="Win32"> <Platform Name="Win32">
...@@ -959,10 +954,6 @@ ...@@ -959,10 +954,6 @@
<RemoteDir>Assets</RemoteDir> <RemoteDir>Assets</RemoteDir>
<Operation>1</Operation> <Operation>1</Operation>
</Platform> </Platform>
<Platform Name="WinARM64EC">
<RemoteDir>Assets</RemoteDir>
<Operation>1</Operation>
</Platform>
</DeployClass> </DeployClass>
<DeployClass Name="iOS_AppStore1024"> <DeployClass Name="iOS_AppStore1024">
<Platform Name="iOSDevice64"> <Platform Name="iOSDevice64">
......
object FViewDeviceManager: TFViewDeviceManager
Width = 900
Height = 600
Font.Charset = DEFAULT_CHARSET
Font.Color = clWindowText
Font.Height = -11
Font.Name = 'Tahoma'
Font.Style = []
ParentFont = False
OnCreate = WebFormCreate
object pnlMessage: TWebPanel
Left = 8
Top = 8
Width = 200
Height = 33
ElementID = 'view.devmgr.message'
TabOrder = 0
object lblMessage: TWebLabel
Left = 8
Top = 8
Width = 42
Height = 13
Caption = 'Message'
ElementID = 'view.devmgr.message.label'
HeightPercent = 100.000000000000000000
WidthPercent = 100.000000000000000000
end
object btnCloseNotification: TWebButton
Left = 170
Top = 4
Width = 22
Height = 25
ElementID = 'view.devmgr.message.button'
HeightPercent = 100.000000000000000000
WidthPercent = 100.000000000000000000
OnClick = btnCloseNotificationClick
end
end
object edtNewDeviceName: TWebEdit
Left = 8
Top = 50
Width = 175
Height = 25
ElementID = 'view.devmgr.newname'
HeightPercent = 100.000000000000000000
TabOrder = 1
WidthPercent = 100.000000000000000000
end
object edtNewPhoneNumber: TWebEdit
Left = 192
Top = 50
Width = 155
Height = 25
ElementID = 'view.devmgr.newphone'
HeightPercent = 100.000000000000000000
TabOrder = 2
TextHint = '(303) 555-1234'
WidthPercent = 100.000000000000000000
end
object btnAddDevice: TWebButton
Left = 356
Top = 50
Width = 60
Height = 25
Caption = 'Add'
ElementID = 'view.devmgr.btnadd'
HeightPercent = 100.000000000000000000
TabOrder = 3
WidthPercent = 100.000000000000000000
OnClick = btnAddDeviceClick
end
object btnLogout: TWebButton
Left = 720
Top = 50
Width = 75
Height = 25
Caption = 'Log Out'
ElementID = 'view.devmgr.btnlogout'
HeightPercent = 100.000000000000000000
Visible = False
WidthPercent = 100.000000000000000000
OnClick = btnLogoutClick
end
object XDataWebClient: TXDataWebClient
Connection = DMConnection.ApiConnection
Left = 800
Top = 8
end
end
<div class="container-fluid p-3 h-100 d-flex flex-column">
<div class="d-flex align-items-center mb-3">
<h5 class="mb-0 me-auto">Device Management</h5>
<button id="view.devmgr.btnlogout" class="btn btn-outline-secondary btn-sm me-2" type="button">
Log Out
</button>
<button id="view.devmgr.btnrefresh"
class="btn btn-outline-secondary btn-sm"
onclick="document.dispatchEvent(new CustomEvent('devmgr-refresh'))">
Refresh
</button>
</div>
<!-- Notification bar -->
<div id="view.devmgr.message"
class="alert alert-danger d-flex align-items-start d-none mb-3"
role="alert">
<span id="view.devmgr.message.label" class="me-auto"></span>
<button id="view.devmgr.message.button"
type="button"
class="btn-close ms-2"
aria-label="Close"></button>
</div>
<!-- Add pending device -->
<div class="card mb-3">
<div class="card-body py-2">
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="fw-semibold text-nowrap small">Add Device:</span>
<input type="text"
id="view.devmgr.newname"
class="form-control form-control-sm"
placeholder="Device name"
style="max-width:180px;">
<input type="tel"
id="view.devmgr.newphone"
class="form-control form-control-sm"
placeholder="(303) 555-1234"
style="max-width:160px;">
<button id="view.devmgr.btnadd"
class="btn btn-primary btn-sm">Add</button>
</div>
</div>
</div>
<!-- Device table -->
<div class="table-responsive flex-grow-1">
<table class="table table-sm table-hover align-middle" id="view.devmgr.table">
<thead class="table-light sticky-top">
<tr>
<th style="min-width:130px;">Device Name</th>
<th style="min-width:120px;">Phone</th>
<th style="min-width:140px;">Username</th>
<th>Browser / User Agent</th>
<th style="min-width:135px;">Registered</th>
<th style="min-width:80px;">Status</th>
<th style="min-width:180px;">Actions</th>
</tr>
</thead>
<tbody id="view.devmgr.tbody">
</tbody>
</table>
<p id="view.devmgr.empty" class="text-muted d-none text-center py-4">
No devices found.
</p>
</div>
</div>
...@@ -18,6 +18,19 @@ object FViewDeviceRegistration: TFViewDeviceRegistration ...@@ -18,6 +18,19 @@ object FViewDeviceRegistration: TFViewDeviceRegistration
TextHint = '(303) 555-1234' TextHint = '(303) 555-1234'
WidthPercent = 100.000000000000000000 WidthPercent = 100.000000000000000000
end end
object chkUseWebAuthn: TWebCheckBox
Left = 240
Top = 163
Width = 160
Height = 21
Caption = 'Use Passkey (WebAuthn)'
Checked = True
ElementID = 'view.devicereg.chkwebauthn'
HeightPercent = 100.000000000000000000
State = cbChecked
TabOrder = 1
WidthPercent = 100.000000000000000000
end
object btnRegister: TWebButton object btnRegister: TWebButton
Left = 240 Left = 240
Top = 190 Top = 190
...@@ -26,7 +39,7 @@ object FViewDeviceRegistration: TFViewDeviceRegistration ...@@ -26,7 +39,7 @@ object FViewDeviceRegistration: TFViewDeviceRegistration
Caption = 'Register This Device' Caption = 'Register This Device'
ElementID = 'view.devicereg.btnregister' ElementID = 'view.devicereg.btnregister'
HeightPercent = 100.000000000000000000 HeightPercent = 100.000000000000000000
TabOrder = 1 TabOrder = 2
WidthPercent = 100.000000000000000000 WidthPercent = 100.000000000000000000
OnClick = btnRegisterClick OnClick = btnRegisterClick
end end
......
...@@ -31,12 +31,18 @@ ...@@ -31,12 +31,18 @@
aria-label="Close"></button> aria-label="Close"></button>
</div> </div>
<p class="text-muted small mb-3"> <p id="view.devicereg.desc-webauthn" class="text-muted small mb-3">
This browser has not been registered for emiMobile access. This browser has not been registered for emiMobile access.
Enter the phone number your administrator registered for this device, Enter the phone number your administrator registered for this device,
then click <strong>Register</strong>. then click <strong>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.
</p> </p>
<p id="view.devicereg.desc-simplekey" class="text-muted small mb-3 d-none">
This browser has not been registered for emiMobile access.
Enter the phone number your administrator registered for this device,
then click <strong>Register</strong>.
A secure key will be generated and stored in your browser.
</p>
<div class="mb-3"> <div class="mb-3">
<label class="form-label small text-muted">Phone number</label> <label class="form-label small text-muted">Phone number</label>
...@@ -54,6 +60,25 @@ ...@@ -54,6 +60,25 @@
style="max-width:100%; overflow:hidden; white-space:nowrap;"></p> style="max-width:100%; overflow:hidden; white-space:nowrap;"></p>
</div> </div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="view.devicereg.chkwebauthn" checked>
<label class="form-check-label small text-muted"
for="view.devicereg.chkwebauthn">
Use Passkey (WebAuthn)
</label>
</div>
<p id="view.devicereg.chk-webauthn-help"
class="text-muted mb-0" style="font-size:0.75rem; padding-left:1.5rem;">
Biometrics, PIN, or security key — hardware-backed.
</p>
<p id="view.devicereg.chk-simplekey-help"
class="text-muted mb-0 d-none" style="font-size:0.75rem; padding-left:1.5rem;">
Browser-stored key — no hardware required.
</p>
</div>
<button id="view.devicereg.btnregister" <button id="view.devicereg.btnregister"
class="btn btn-primary w-100"> class="btn btn-primary w-100">
Register This Device Register This Device
......
...@@ -11,6 +11,7 @@ uses ...@@ -11,6 +11,7 @@ uses
type type
TFViewDeviceRegistration = class(TWebForm) TFViewDeviceRegistration = class(TWebForm)
edtPhoneNumber: TWebEdit; edtPhoneNumber: TWebEdit;
chkUseWebAuthn: TWebCheckBox;
btnRegister: TWebButton; btnRegister: TWebButton;
pnlMessage: TWebPanel; pnlMessage: TWebPanel;
lblMessage: TWebLabel; lblMessage: TWebLabel;
...@@ -25,6 +26,7 @@ type ...@@ -25,6 +26,7 @@ type
procedure HideNotification; procedure HideNotification;
procedure SetBusy(ABusy: Boolean); procedure SetBusy(ABusy: Boolean);
procedure DoWebAuthnCreate(APhoneNumber, AChallenge, AChallengeToken: string); procedure DoWebAuthnCreate(APhoneNumber, AChallenge, AChallengeToken: string);
procedure DoSimpleKeyCreate(APhoneNumber, AChallengeToken: string);
public public
class procedure Display(ARegistrationProc: TSuccessProc); class procedure Display(ARegistrationProc: TSuccessProc);
end; end;
...@@ -77,6 +79,28 @@ begin ...@@ -77,6 +79,28 @@ begin
inp.value = formatted; inp.value = formatted;
}); });
} }
// Toggle description text when checkbox changes
var chk = document.getElementById('view.devicereg.chkwebauthn');
if (chk) {
chk.addEventListener('change', function() {
var waDesc = document.getElementById('view.devicereg.desc-webauthn');
var skDesc = document.getElementById('view.devicereg.desc-simplekey');
var waHelp = document.getElementById('view.devicereg.chk-webauthn-help');
var skHelp = document.getElementById('view.devicereg.chk-simplekey-help');
if (chk.checked) {
if (waDesc) waDesc.classList.remove('d-none');
if (skDesc) skDesc.classList.add('d-none');
if (waHelp) waHelp.classList.remove('d-none');
if (skHelp) skHelp.classList.add('d-none');
} else {
if (waDesc) waDesc.classList.add('d-none');
if (skDesc) skDesc.classList.remove('d-none');
if (waHelp) waHelp.classList.add('d-none');
if (skHelp) skHelp.classList.remove('d-none');
}
});
}
end; end;
end; end;
...@@ -84,9 +108,14 @@ procedure TFViewDeviceRegistration.SetBusy(ABusy: Boolean); ...@@ -84,9 +108,14 @@ procedure TFViewDeviceRegistration.SetBusy(ABusy: Boolean);
begin begin
asm asm
var btn = document.getElementById('view.devicereg.btnregister'); var btn = document.getElementById('view.devicereg.btnregister');
var chk = document.getElementById('view.devicereg.chkwebauthn');
if (btn) { if (btn) {
btn.disabled = ABusy; btn.disabled = ABusy;
btn.textContent = ABusy ? 'Waiting for authenticator...' : 'Register This Device'; if (ABusy) {
btn.textContent = (chk && chk.checked) ? 'Waiting for authenticator...' : 'Activating device...';
} else {
btn.textContent = 'Register This Device';
}
} }
end; end;
end; end;
...@@ -94,10 +123,14 @@ end; ...@@ -94,10 +123,14 @@ end;
procedure TFViewDeviceRegistration.btnRegisterClick(Sender: TObject); procedure TFViewDeviceRegistration.btnRegisterClick(Sender: TObject);
var var
phoneNumber: string; phoneNumber: string;
useWebAuthn: Boolean;
procedure OnBeginOK(AChallenge, AChallengeToken: string); procedure OnBeginOK(AChallenge, AChallengeToken: string);
begin begin
DoWebAuthnCreate(phoneNumber, AChallenge, AChallengeToken); if useWebAuthn then
DoWebAuthnCreate(phoneNumber, AChallenge, AChallengeToken)
else
DoSimpleKeyCreate(phoneNumber, AChallengeToken);
end; end;
procedure OnBeginError(AMsg: string); procedure OnBeginError(AMsg: string);
...@@ -114,6 +147,7 @@ begin ...@@ -114,6 +147,7 @@ begin
Exit; Exit;
end; end;
useWebAuthn := chkUseWebAuthn.Checked;
SetBusy(True); SetBusy(True);
HideNotification; HideNotification;
...@@ -198,6 +232,46 @@ begin ...@@ -198,6 +232,46 @@ begin
end; end;
end; end;
procedure TFViewDeviceRegistration.DoSimpleKeyCreate(APhoneNumber, AChallengeToken: string);
var
phoneNumber, challengeToken, deviceKey: string;
procedure OnCompleteOK;
begin
FRegistrationProc;
end;
procedure OnCompleteError(AMsg: string);
begin
SetBusy(False);
ShowNotification(AMsg);
end;
begin
phoneNumber := APhoneNumber;
challengeToken := AChallengeToken;
deviceKey := '';
asm
var keyBytes = new Uint8Array(32);
crypto.getRandomValues(keyBytes);
var bin = String.fromCharCode.apply(null, keyBytes);
deviceKey = btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
end;
if deviceKey = '' then
begin
SetBusy(False);
ShowNotification('Failed to generate device key.');
Exit;
end;
AuthService.CompleteRegistrationSimple(
phoneNumber, deviceKey, challengeToken,
@OnCompleteOK, @OnCompleteError
);
end;
procedure TFViewDeviceRegistration.btnCloseNotificationClick(Sender: TObject); procedure TFViewDeviceRegistration.btnCloseNotificationClick(Sender: TObject);
begin begin
HideNotification; HideNotification;
......
...@@ -109,6 +109,18 @@ object FViewLogin: TFViewLogin ...@@ -109,6 +109,18 @@ object FViewLogin: TFViewLogin
DisplayText = 'BUF - Buffalo Police Department' DisplayText = 'BUF - Buffalo Police Department'
end> end>
end end
object btnPasskeyLogin: TWebButton
Left = 240
Top = 244
Width = 121
Height = 25
Caption = 'Sign In with Passkey'
ElementID = 'view.login.btnpasskeylogin'
HeightPercent = 100.000000000000000000
TabOrder = 5
WidthPercent = 100.000000000000000000
OnClick = btnPasskeyLoginClick
end
object XDataWebClient: TXDataWebClient object XDataWebClient: TXDataWebClient
Connection = DMConnection.AuthConnection Connection = DMConnection.AuthConnection
Left = 492 Left = 492
......
...@@ -29,35 +29,49 @@ ...@@ -29,35 +29,49 @@
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">
<div class="input-group"> <!-- Manual login section -->
<div id="view.login.manualsection">
<div class="mb-3">
<input id="view.login.edtusername"
class="form-control"
type="text"
placeholder="Username"
autofocus>
</div>
<div class="input-group mb-3">
<input id="view.login.edtpassword" <input id="view.login.edtpassword"
class="form-control" class="form-control"
type="password" type="password"
placeholder="Password"> placeholder="Password">
<button id="view.login.btnshowpassword" <button id="view.login.btnshowpassword"
class="btn btn-outline-secondary" class="btn btn-outline-secondary"
type="button"> type="button">Show</button>
Show
</button>
</div> </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>
<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>
<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>
......
...@@ -221,6 +221,21 @@ object FViewMain: TFViewMain ...@@ -221,6 +221,21 @@ object FViewMain: TFViewMain
WidthPercent = 100.000000000000000000 WidthPercent = 100.000000000000000000
OnClick = btnLogoutClick OnClick = btnLogoutClick
end end
object btnDevices: TWebButton
Left = 320
Top = 66
Width = 96
Height = 25
Caption = 'Devices'
ChildOrder = 17
ElementID = 'btn_devices'
ElementFont = efCSS
HeightStyle = ssAuto
HeightPercent = 100.000000000000000000
Visible = False
WidthPercent = 100.000000000000000000
OnClick = btnDevicesClick
end
object xdwcBadgeCounts: TXDataWebClient object xdwcBadgeCounts: TXDataWebClient
Connection = DMConnection.ApiConnection Connection = DMConnection.ApiConnection
Left = 44 Left = 44
......
...@@ -36,6 +36,7 @@ ...@@ -36,6 +36,7 @@
<li><button id="btn_logout" type="button" class="dropdown-item">Logout</button></li> <li><button id="btn_logout" type="button" class="dropdown-item">Logout</button></li>
</ul> </ul>
</div> </div>
<button id="btn_devices" type="button" class="btn btn-outline-light btn-sm d-none">Devices</button>
</div> </div>
</div> </div>
</nav> </nav>
......
...@@ -33,6 +33,7 @@ type ...@@ -33,6 +33,7 @@ type
pnlArchive: TWebPanel; pnlArchive: TWebPanel;
btnArchiveModalClose: TWebButton; btnArchiveModalClose: TWebButton;
btnLogout: TWebButton; btnLogout: TWebButton;
btnDevices: TWebButton;
procedure WebFormCreate(Sender: TObject); procedure WebFormCreate(Sender: TObject);
procedure mnuLogoutClick(Sender: TObject); procedure mnuLogoutClick(Sender: TObject);
procedure lblLogoutClick(Sender: TObject); procedure lblLogoutClick(Sender: TObject);
...@@ -44,6 +45,7 @@ type ...@@ -44,6 +45,7 @@ type
procedure btnDetailsModalCloseClick(Sender: TObject); procedure btnDetailsModalCloseClick(Sender: TObject);
procedure btnArchiveModalCloseClick(Sender: TObject); procedure btnArchiveModalCloseClick(Sender: TObject);
procedure btnLogoutClick(Sender: TObject); procedure btnLogoutClick(Sender: TObject);
procedure btnDevicesClick(Sender: TObject);
private private
{ Private declarations } { Private declarations }
FUserInfo: string; FUserInfo: string;
...@@ -119,6 +121,7 @@ uses ...@@ -119,6 +121,7 @@ uses
View.EditUser, View.EditUser,
View.UnitDetails, View.UnitDetails,
View.ComplaintArchive, View.ComplaintArchive,
View.DeviceManager,
Utils; Utils;
{$R *.dfm} {$R *.dfm}
...@@ -147,8 +150,17 @@ begin ...@@ -147,8 +150,17 @@ begin
FBadgeRefreshPending := False; FBadgeRefreshPending := False;
FPendingWsBadgeCounts := nil; FPendingWsBadgeCounts := nil;
if (not (JS.toBoolean(AuthService.TokenPayload.Properties['user_admin']))) then if JS.toBoolean(AuthService.TokenPayload.Properties['user_admin']) then
lblUsers.Visible := false; begin
// Show admin controls
btnDevices.Visible := True;
asm
var el = document.getElementById('btn_devices');
if (el) el.classList.remove('d-none');
end;
end
else
lblUsers.Visible := False;
Utils.HideSpinner('spinner'); Utils.HideSpinner('spinner');
...@@ -309,6 +321,11 @@ begin ...@@ -309,6 +321,11 @@ begin
FLogoutProc; FLogoutProc;
end; end;
procedure TFViewMain.btnDevicesClick(Sender: TObject);
begin
ShowForm(TFViewDeviceManager);
end;
procedure TFViewMain.btnMapClick(Sender: TObject); procedure TFViewMain.btnMapClick(Sender: TObject);
begin begin
ShowForm(TFViewMap); ShowForm(TFViewMap);
......
...@@ -69,6 +69,16 @@ html, body { ...@@ -69,6 +69,16 @@ html, body {
overflow: hidden; overflow: hidden;
} }
/* iOS safe area: absorb status bar into top nav, home indicator into bottom nav */
@supports (padding-top: env(safe-area-inset-top)) {
#top_nav {
padding-top: calc(0.5rem + env(safe-area-inset-top));
}
#bottom_nav {
padding-bottom: calc(0.5rem + env(safe-area-inset-bottom));
}
}
@supports (-webkit-touch-callout: none) { @supports (-webkit-touch-callout: none) {
/* CSS specific to iOS devices */ /* CSS specific to iOS devices */
span.card { span.card {
......
program wcEmiMobile; program wcEmiMobile;
{$R *.dres} {$R *.dres}
uses uses
Vcl.Forms, Vcl.Forms,
System.SysUtils,
XData.Web.Connection, XData.Web.Connection,
Auth.Service in 'Auth.Service.pas', Auth.Service in 'Auth.Service.pas',
App.Types in 'App.Types.pas', App.Types in 'App.Types.pas',
...@@ -27,19 +28,24 @@ uses ...@@ -27,19 +28,24 @@ uses
View.ComplaintArchive in 'View.ComplaintArchive.pas' {FViewComplaintArchive: TWebForm} {*.html}, View.ComplaintArchive in 'View.ComplaintArchive.pas' {FViewComplaintArchive: TWebForm} {*.html},
uMapMarkerJs in 'uMapMarkerJs.pas', uMapMarkerJs in 'uMapMarkerJs.pas',
Module.Websocket in 'Module.Websocket.pas' {dmWebsocket: TDataModule}, Module.Websocket in 'Module.Websocket.pas' {dmWebsocket: TDataModule},
View.DeviceRegistration in 'View.DeviceRegistration.pas' {FViewDeviceRegistration: TWebForm} {*.html}; View.DeviceRegistration in 'View.DeviceRegistration.pas' {FViewDeviceRegistration: TWebForm} {*.html},
View.DeviceManager in 'View.DeviceManager.pas' {FViewDeviceManager: TWebForm} {*.html};
{$R *.res} {$R *.res}
procedure DisplayLoginView(AMessage: string = ''); forward; procedure DisplayLoginView(AMessage: string = ''); forward;
procedure DisplayDeviceRegistrationView; forward;
procedure DisplayMainView; procedure DisplayMainView;
procedure ConnectProc; procedure ConnectProc;
begin begin
if Assigned(FViewLogin) then if Assigned(FViewLogin) then
FViewLogin.Free; FreeAndNil(FViewLogin);
TFViewMain.Display(@DisplayLoginView); if AuthService.DeviceManagementMode then
FViewDeviceManager := TFViewDeviceManager.CreateNew
else
TFViewMain.Display(@DisplayLoginView);
end; end;
begin begin
...@@ -53,11 +59,29 @@ procedure DisplayLoginView(AMessage: string); ...@@ -53,11 +59,29 @@ procedure DisplayLoginView(AMessage: string);
begin begin
AuthService.Logout; AuthService.Logout;
DMConnection.ApiConnection.Connected := False; DMConnection.ApiConnection.Connected := False;
if Assigned(FViewDeviceManager) then
FreeAndNil(FViewDeviceManager);
if Assigned(FViewMain) then if Assigned(FViewMain) then
FViewMain.Free; FViewMain.Free;
TFViewLogin.Display(@DisplayMainView, AMessage); TFViewLogin.Display(@DisplayMainView, AMessage);
end; end;
procedure DisplayDeviceRegistrationView;
procedure OnRegistered;
begin
// Device registered — proceed to login
if Assigned(FViewDeviceRegistration) then
FViewDeviceRegistration.Free;
TFViewLogin.Display(@DisplayMainView);
end;
begin
if Assigned(FViewDeviceRegistration) then
FViewDeviceRegistration.Free;
TFViewDeviceRegistration.Display(@OnRegistered);
end;
procedure UnauthorizedAccessProc(AMessage: string); procedure UnauthorizedAccessProc(AMessage: string);
begin begin
DisplayLoginView(AMessage); DisplayLoginView(AMessage);
...@@ -65,6 +89,20 @@ end; ...@@ -65,6 +89,20 @@ end;
procedure StartApplication; procedure StartApplication;
begin begin
if AuthService.DeviceManagementMode then
begin
DisplayLoginView;
Exit;
end;
// Step 1: device must be registered before login is allowed
if not AuthService.IsDeviceRegistered then
begin
DisplayDeviceRegistrationView;
Exit;
end;
// Step 2: normal JWT auth check
if (not AuthService.Authenticated) or AuthService.TokenExpired then if (not AuthService.Authenticated) or AuthService.TokenExpired then
DisplayLoginView DisplayLoginView
else else
......
...@@ -203,7 +203,10 @@ ...@@ -203,7 +203,10 @@
</DCCReference> </DCCReference>
<DCCReference Include="View.DeviceRegistration.pas"> <DCCReference Include="View.DeviceRegistration.pas">
<Form>FViewDeviceRegistration</Form> <Form>FViewDeviceRegistration</Form>
<FormType>dfm</FormType> <DesignClass>TWebForm</DesignClass>
</DCCReference>
<DCCReference Include="View.DeviceManager.pas">
<Form>FViewDeviceManager</Form>
<DesignClass>TWebForm</DesignClass> <DesignClass>TWebForm</DesignClass>
</DCCReference> </DCCReference>
<None Include="index.html"/> <None Include="index.html"/>
......
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