Skip to content

Commit b1a42e5

Browse files
authored
Merge pull request #19517 from mozilla/fxa-12422
feat(skip): Skip confirmation code for a seen device user agent
2 parents 1b8b7ba + a6033d5 commit b1a42e5

9 files changed

Lines changed: 591 additions & 3 deletions

File tree

packages/fxa-auth-server/config/dev.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@
3131
"allowedRecency": 0
3232
}
3333
},
34+
"signinConfirmation": {
35+
"deviceFingerprinting": {
36+
"enabled": true,
37+
"reportOnlyMode": true,
38+
"duration": "7 days"
39+
}
40+
},
3441
"lastAccessTimeUpdates": {
3542
"enabled": true,
3643
"sampleRate": 1

packages/fxa-auth-server/config/index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1617,6 +1617,26 @@ const convictConf = convict({
16171617
default: false,
16181618
env: 'SIGNIN_CONFIRMATION_FORCE_GLOBALLY',
16191619
},
1620+
deviceFingerprinting: {
1621+
enabled: {
1622+
doc: 'Enable device fingerprinting based verification skip',
1623+
format: Boolean,
1624+
default: false,
1625+
env: 'SIGNIN_CONFIRMATION_DEVICE_FINGERPRINTING_ENABLED',
1626+
},
1627+
reportOnlyMode: {
1628+
doc: 'When true, logs device matches but does not skip verification',
1629+
format: Boolean,
1630+
default: true,
1631+
env: 'SIGNIN_CONFIRMATION_DEVICE_FINGERPRINTING_REPORT_ONLY',
1632+
},
1633+
duration: {
1634+
doc: 'How long a recognized device can skip verification',
1635+
default: '7 days',
1636+
format: 'duration',
1637+
env: 'SIGNIN_CONFIRMATION_DEVICE_FINGERPRINTING_DURATION',
1638+
},
1639+
},
16201640
tokenVerification: {
16211641
doc: 'If set to false, force sign-in confirmation for logins that do not request scoped keys. Sets "mustVerify: 0" on created session tokens but creates an entry in unverifiedTokens, simulating an unverified session state',
16221642
format: Boolean,

packages/fxa-auth-server/lib/db.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1064,6 +1064,17 @@ export const createDB = (
10641064
);
10651065
}
10661066

1067+
async verifiedLoginSecurityEventsByUid(params: { uid: string; skipTimeframeMs: number}) {
1068+
log.trace('DB.verifiedLoginSecurityEventsByUid', {
1069+
params: params,
1070+
});
1071+
const { uid, skipTimeframeMs } = params;
1072+
return SecurityEvent.findByUidAndVerifiedLogin(
1073+
uid,
1074+
skipTimeframeMs
1075+
);
1076+
}
1077+
10671078
async securityEventsByUid(params: { uid: string }) {
10681079
log.trace('DB.securityEventsByUid', {
10691080
params: params,

packages/fxa-auth-server/lib/routes/account.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ import { DeleteAccountTasks, ReasonForDeletion } from '@fxa/shared/cloud-tasks';
4747
import { ProfileClient } from '@fxa/profile/client';
4848
import { DB } from '../db';
4949
import { StatsD } from 'hot-shots';
50-
import { recordSecurityEvent } from './utils/security-event';
50+
import { recordSecurityEvent, isRecognizedDevice } from './utils/security-event';
5151
import { RelyingPartyConfigurationManager } from '@fxa/shared/cms';
5252
import { OtpUtils } from './utils/otp';
5353

@@ -1074,11 +1074,49 @@ export class AccountHandler {
10741074
return false;
10751075
};
10761076

1077-
const skipTokenVerification = (request: AuthRequest, account: any) => {
1077+
const skipTokenVerification = async (request: AuthRequest, account: any) => {
10781078
// Skip all checks to simulate an unverified session token state
10791079
if (this.config.signinConfirmation.tokenVerification === false) {
10801080
return false;
10811081
}
1082+
1083+
// Check to see if there has been a recent, successfully-verified login from
1084+
// the same device (as indicated by User-Agent string)
1085+
if (this.config.signinConfirmation.deviceFingerprinting?.enabled) {
1086+
try {
1087+
const currentUserAgent = request.headers['user-agent'] || '';
1088+
const skipTimeframeMs = this.config.signinConfirmation.deviceFingerprinting.duration as unknown as number;
1089+
1090+
const isRecognized = await isRecognizedDevice(this.db, account.uid, currentUserAgent, skipTimeframeMs);
1091+
1092+
if (isRecognized) {
1093+
if (this.config.signinConfirmation.deviceFingerprinting.reportOnlyMode) {
1094+
this.statsd.increment('account.signin.confirm.device.match.reportOnly');
1095+
this.log.info('account.signin.confirm.device.match.reportOnly', {
1096+
uid: account.uid,
1097+
});
1098+
// Continue to existing logic instead of returning
1099+
} else {
1100+
this.statsd.increment('account.signin.confirm.device.skip');
1101+
this.log.info('account.signin.confirm.device.skip', {
1102+
uid: account.uid,
1103+
});
1104+
return true;
1105+
}
1106+
} else {
1107+
this.statsd.increment('account.signin.confirm.device.notfound');
1108+
this.log.info('account.signin.confirm.device.notfound', {
1109+
uid: account.uid,
1110+
});
1111+
}
1112+
} catch (error) {
1113+
this.log.error('account.signin.confirm.device.error', {
1114+
uid: account.uid,
1115+
error: error.message
1116+
});
1117+
// Fall through to existing logic on error
1118+
}
1119+
}
10821120
// If they're logging in from an IP address on which they recently did
10831121
// another, successfully-verified login, then we can consider this one
10841122
// verified as well without going through the loop again.
@@ -1165,7 +1203,7 @@ export class AccountHandler {
11651203
// to know for sure what flow they're going to see.
11661204
const verificationForced = forceTokenVerification(request, accountRecord);
11671205
if (!verificationForced) {
1168-
if (skipTokenVerification(request, accountRecord)) {
1206+
if (await skipTokenVerification(request, accountRecord)) {
11691207
needsVerificationId = false;
11701208
}
11711209
}

packages/fxa-auth-server/lib/routes/utils/security-event.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,35 @@ export async function recordSecurityEvent(name: SecurityEventNames, opts: any) {
2323
},
2424
});
2525
}
26+
27+
export async function isRecognizedDevice(
28+
db: any,
29+
uid: string,
30+
userAgent: string,
31+
skipTimeframeMs: number
32+
): Promise<boolean> {
33+
const verifiedLoginEvents = await db.verifiedLoginSecurityEventsByUid({
34+
uid,
35+
skipTimeframeMs
36+
});
37+
38+
if (!verifiedLoginEvents || verifiedLoginEvents.length === 0) {
39+
return false;
40+
}
41+
42+
// Search through the results for matching user agent
43+
for (const event of verifiedLoginEvents) {
44+
if (event.additionalInfo) {
45+
try {
46+
const additionalInfo = JSON.parse(event.additionalInfo);
47+
if (additionalInfo.userAgent === userAgent) {
48+
return true;
49+
}
50+
} catch (e) {
51+
// Skip events with invalid JSON
52+
}
53+
}
54+
}
55+
56+
return false;
57+
}

packages/fxa-auth-server/test/local/routes/account.js

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3573,6 +3573,219 @@ describe('/account/login', () => {
35733573
}
35743574
);
35753575
});
3576+
3577+
describe('skip for seen user agent', () => {
3578+
beforeEach(() => {
3579+
config.securityHistory.ipProfiling = {};
3580+
config.signinConfirmation.skipForNewAccounts = { enabled: false };
3581+
config.signinConfirmation.deviceFingerprinting = {
3582+
enabled: true,
3583+
reportOnlyMode: false,
3584+
duration: 604800000 // 7 days
3585+
};
3586+
3587+
const email = mockRequest.payload.email;
3588+
3589+
mockDB.accountRecord = function () {
3590+
return Promise.resolve({
3591+
authSalt: hexString(32),
3592+
createdAt: Date.now(),
3593+
data: hexString(32),
3594+
email: email,
3595+
emailVerified: true,
3596+
primaryEmail: {
3597+
normalizedEmail: normalizeEmail(email),
3598+
email: email,
3599+
isVerified: true,
3600+
isPrimary: true,
3601+
},
3602+
kA: hexString(32),
3603+
lastAuthAt: function () {
3604+
return Date.now();
3605+
},
3606+
uid: uid,
3607+
wrapWrapKb: hexString(32),
3608+
});
3609+
};
3610+
3611+
const accountRoutes = makeRoutes({
3612+
checkPassword: function () {
3613+
return Promise.resolve(true);
3614+
},
3615+
config: config,
3616+
customs: mockCustoms,
3617+
db: mockDB,
3618+
log: mockLog,
3619+
mailer: mockMailer,
3620+
push: mockPush,
3621+
cadReminders: mockCadReminders,
3622+
});
3623+
3624+
route = getRoute(accountRoutes, '/account/login');
3625+
})
3626+
3627+
it('should skip verification when device is recognized and not in report-only mode', () => {
3628+
mockDB.verifiedLoginSecurityEventsByUid = sinon.spy(() =>
3629+
Promise.resolve([
3630+
{
3631+
name: 'account.login',
3632+
verified: true,
3633+
createdAt: Date.now() - 3600000, // 1 hour ago
3634+
additionalInfo: JSON.stringify({
3635+
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
3636+
location: { country: 'US', state: 'CA' }
3637+
})
3638+
}
3639+
])
3640+
);
3641+
3642+
const requestWithUserAgent = {
3643+
...mockRequest,
3644+
headers: {
3645+
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
3646+
}
3647+
};
3648+
3649+
return runTest(route, requestWithUserAgent, (response) => {
3650+
assert.equal(
3651+
mockDB.createSessionToken.callCount,
3652+
1,
3653+
'db.createSessionToken was called'
3654+
);
3655+
const tokenData = mockDB.createSessionToken.getCall(0).args[0];
3656+
assert.ok(
3657+
!tokenData.mustVerify,
3658+
'sessionToken does not require verification'
3659+
);
3660+
assert.ok(
3661+
response.verified,
3662+
'response indicates session is verified'
3663+
);
3664+
3665+
assert.calledWith(
3666+
statsd.increment,
3667+
'account.signin.confirm.device.skip'
3668+
);
3669+
});
3670+
});
3671+
3672+
it('should not skip verification when device is not recognized', () => {
3673+
mockDB.verifiedLoginSecurityEventsByUid = sinon.spy(() =>
3674+
Promise.resolve([])
3675+
);
3676+
3677+
const requestWithDifferentUserAgent = {
3678+
...mockRequest,
3679+
headers: {
3680+
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
3681+
}
3682+
};
3683+
3684+
return runTest(route, requestWithDifferentUserAgent, (response) => {
3685+
assert.equal(
3686+
mockDB.createSessionToken.callCount,
3687+
1,
3688+
'db.createSessionToken was called'
3689+
);
3690+
const tokenData = mockDB.createSessionToken.getCall(0).args[0];
3691+
assert.ok(
3692+
tokenData.mustVerify,
3693+
'sessionToken requires verification'
3694+
);
3695+
assert.ok(
3696+
!response.verified,
3697+
'response indicates session is not verified'
3698+
);
3699+
3700+
assert.calledWith(
3701+
statsd.increment,
3702+
'account.signin.confirm.device.notfound'
3703+
);
3704+
});
3705+
});
3706+
3707+
it('should not skip verification when in report-only mode', () => {
3708+
config.signinConfirmation.deviceFingerprinting.reportOnlyMode = true;
3709+
3710+
mockDB.verifiedLoginSecurityEventsByUid = sinon.spy(() =>
3711+
Promise.resolve([
3712+
{
3713+
name: 'account.login',
3714+
verified: true,
3715+
createdAt: Date.now() - 3600000, // 1 hour ago
3716+
additionalInfo: JSON.stringify({
3717+
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
3718+
location: { country: 'US', state: 'CA' }
3719+
})
3720+
}
3721+
])
3722+
);
3723+
3724+
const requestWithUserAgent = {
3725+
...mockRequest,
3726+
headers: {
3727+
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
3728+
}
3729+
};
3730+
3731+
return runTest(route, requestWithUserAgent, (response) => {
3732+
assert.equal(
3733+
mockDB.createSessionToken.callCount,
3734+
1,
3735+
'db.createSessionToken was called'
3736+
);
3737+
const tokenData = mockDB.createSessionToken.getCall(0).args[0];
3738+
assert.ok(
3739+
tokenData.mustVerify,
3740+
'sessionToken requires verification in report-only mode'
3741+
);
3742+
assert.ok(
3743+
!response.verified,
3744+
'response indicates session is not verified'
3745+
);
3746+
3747+
// Assert StatsD metric is emitted for report-only mode
3748+
sinon.assert.calledWith(
3749+
statsd.increment,
3750+
'account.signin.confirm.device.match.reportOnly'
3751+
);
3752+
});
3753+
});
3754+
3755+
it('should handle errors gracefully and continue to existing logic', () => {
3756+
mockDB.verifiedLoginSecurityEventsByUid = sinon.spy(() =>
3757+
Promise.reject(new Error('Database connection failed'))
3758+
);
3759+
3760+
return runTest(route, mockRequest, (response) => {
3761+
assert.equal(
3762+
mockDB.createSessionToken.callCount,
3763+
1,
3764+
'db.createSessionToken was called'
3765+
);
3766+
// Should continue to existing verification logic
3767+
const tokenData = mockDB.createSessionToken.getCall(0).args[0];
3768+
assert.ok(
3769+
tokenData.mustVerify,
3770+
'sessionToken requires verification when error occurs'
3771+
);
3772+
});
3773+
});
3774+
3775+
it('should not call device fingerprinting when disabled', () => {
3776+
config.signinConfirmation.deviceFingerprinting.enabled = false;
3777+
3778+
const originalSpy = mockDB.verifiedLoginSecurityEventsByUid;
3779+
mockDB.verifiedLoginSecurityEventsByUid = sinon.spy(() => Promise.resolve([]));
3780+
3781+
return runTest(route, mockRequest, (response) => {
3782+
// Should not call the device fingerprinting database method
3783+
assert.equal(mockDB.verifiedLoginSecurityEventsByUid.callCount, 0, 'device fingerprinting was not called');
3784+
// Restore original spy
3785+
mockDB.verifiedLoginSecurityEventsByUid = originalSpy;
3786+
});
3787+
});
3788+
});
35763789
});
35773790

35783791
it('#integration - creating too many sessions causes an error to be logged', () => {

0 commit comments

Comments
 (0)