Feat | Add Device Trust Service#133
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-133/ This page is automatically updated on each push to this PR. |
09750ff to
9faa517
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-133/ This page is automatically updated on each push to this PR. |
d9beb67 to
b1ef3fa
Compare
9faa517 to
236b644
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-133/ This page is automatically updated on each push to this PR. |
236b644 to
f1e8af5
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-133/ This page is automatically updated on each push to this PR. |
b1ef3fa to
2c99a62
Compare
f1e8af5 to
e096f94
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-133/ This page is automatically updated on each push to this PR. |
e096f94 to
16c4945
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-133/ This page is automatically updated on each push to this PR. |
16c4945 to
4d99419
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-133/ This page is automatically updated on each push to this PR. |
Task:
Ref: https://app.clickup.com/t/86ba0nah4
Blocked by:
PR: Feature | MFA Challenge Strategy Pattern (Interface, Abstract, Factory, EmailOTP) #129
Changes:
New service layer (
app/Services/Auth/)IDeviceTrustService.php— Interface defining the contract:trustDevice,isDeviceTrusted,removeTrustedDevices,generateDeviceIdentifier.DeviceTrustService.php— Implementation:trustDevice: generates a 128-char random hex token, hashes it with SHA-256, persists aUserTrustedDevicerecord with configurable expiry (default 30 days), returns the raw tokenfor cookie storage.
isDeviceTrusted: hashes the cookie token, looks up the record, returnsfalseif not found / revoked / expired; updateslast_seen_aton a valid hit.removeTrustedDevices: bulk-revokes all devices for a user (setsis_revoked = true).TwoFactorServiceProvider.php— Deferred service provider that bindsIDeviceTrustService → DeviceTrustServiceas a singleton; registered inconfig/app.php.Repository changes (
IUserTrustedDeviceRepository/DoctrineUserTrustedDeviceRepository)Two new methods added:
getByUserAndDeviceIdentifier— looks up a device by user + hashed identifier with no revoked/expiry filter (used by the service to check all states).revokeAllForUser— bulk DQLUPDATEsettingis_revoked = truefor all devices belonging to a user.Entity updates (
UserTrustedDevice)last_seen_attonow().isExpired()helper (comparesexpires_atagainst now).targetEntityreference from FQCN string toUser::class.Config
config/two_factor.php— Addeddevice_trust_lifetime_days(default30) andcookie_name(default'device_trust_token') settings, driven by env vars.Tests (
tests/DeviceTrustServiceTest.php)264-line unit test suite using Mockery covering:
isDeviceTrusted: null/empty cookie, unknown token, revoked device, expired device, valid device,last_seen_atupdate.trustDevice: returns 128-char hex token, stores SHA-256 hash (not raw token), persists exactly one record.removeTrustedDevices: delegates torevokeAllForUser.generateDeviceIdentifier: SHA-256 correctness.Requested GOAL
Current state
No device trust mechanism exists. Users would need to complete 2FA on every single login, even from the same browser and device they used yesterday.
Target state
IDeviceTrustServiceand its implementation allow the system to remember trusted devices via a secure cookie mechanism. A SHA-256 hash of a cryptographically random token is storedin the user_trusted_devices table with a configurable TTL (default 30 days). The raw token is stored in a long-lived HttpOnly cookie , On subsequent logins, the cookie token is hashed and compared against stored records to bypass 2FA.
TASKS
IDeviceTrustServiceinterface with methods:isDeviceTrusted(User, ?string cookieToken): bool,trustDevice(User, string userAgent, string ipAddress): string(returns raw cookie token),removeTrustedDevices(User): void,generateDeviceIdentifier(string token): string(returns SHA-256 hash)DeviceTrustService:isDeviceTrusted()hashes the cookie token viagenerateDeviceIdentifier(), queriesIUserTrustedDeviceRepositoryfor a matching non-revoked, non-expired record for the user, updateslast_seen_aton match.trustDevice()generates a 64-byte random token viabin2hex(random_bytes(32)), stores SHA-256 hash in user_trusted_devices with TTL from config, extracts User-Agent fordevice_name, returns raw token.removeTrustedDevices()revokes all devices for the user (setsis_revoked=true).IUserTrustedDeviceRepository::getByUserAndDeviceIdentifier()query methodIDeviceTrustServiceinTwoFactorServiceProviderisDeviceTrusted()with: valid trusted device, expired device, revoked device, no cookie, wrong cookietrustDevice(): verify token generation, hash storage, and record creationremoveTrustedDevices(): verify all devices for user are revokedDeviceTrustServicemust depend onIUserTrustedDeviceRepository, notEntityManagerdirectly.trustDevice()must instantiate aUserTrustedDeviceentity, associate it with theUser, setdevice_identifier,device_name,ip_address,user_agent,trusted_at,expires_at,last_seen_at,is_revoked=false, then persist it through the repository.isDeviceTrusted()must callIUserTrustedDeviceRepository::getByUserAndDeviceIdentifier($user, $deviceIdentifier)and only return true for a non-revoked, non-expired record.isDeviceTrusted()must updatelast_seen_atand persist the change.removeTrustedDevices()must call a repository method that revokes all trusted devices for the user.ACCEPTANCE CRITERIA
trustDevice()returns a 128-character hex string (64 bytes as hex)device_identifieris a SHA-256 hash of the returned token (not the raw token itself)isDeviceTrusted()returns true when a matching non-revoked, non-expired record existsisDeviceTrusted()returnsfalsefor expired records (expires_at < now)isDeviceTrusted()returnsfalsefor revoked records (is_revoked = true)isDeviceTrusted()returnsfalsewhen cookieToken is null or emptyisDeviceTrusted()updateslast_seen_aton a valid matchremoveTrustedDevices()sets is_revoked=true on all records for the userUserTrustedDeviceaccess goes through IUserTrustedDeviceRepository.trustDevice()creates exactly oneUserTrustedDevicerecord per call.UserTrustedDevice.UserTrustedDevice::device_identifierstores hash('sha256', $rawToken).isDeviceTrusted()updatesUserTrustedDevice::last_seen_atwhen a valid device is found.config('two_factor.device_trust_lifetime_days')config('two_factor.cookie_name')