feat(push): Add views for confirming a push notification login

This commit is contained in:
Vijay Budhram 2021-06-10 13:03:17 -04:00
Родитель 334d907346
Коммит 5610838255
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: EBAEC5D86596C9EE
22 изменённых файлов: 718 добавлений и 144 удалений

1
.gitignore поставляемый
Просмотреть файл

@ -34,6 +34,7 @@ secrets.json
storybooks-publish
tsconfig.tsbuildinfo
tailwind.out.*
.idea
# Dependencies
**/node_modules

Просмотреть файл

@ -1238,4 +1238,8 @@ export default class AuthClient {
new Headers(headers)
);
}
async sendPushLoginRequest(sessionToken: string) {
return this.sessionPost('/session/verify/send_push', sessionToken, {});
}
}

Просмотреть файл

@ -111,8 +111,7 @@ const conf = convict({
},
memcached: {
address: {
doc:
'Address:port of the memcached server (or `none` to disable memcached)',
doc: 'Address:port of the memcached server (or `none` to disable memcached)',
default: 'localhost:11211',
env: 'MEMCACHE_METRICS_CONTEXT_ADDRESS',
},
@ -180,8 +179,7 @@ const conf = convict({
connectionTimeout: {
default: '5 minutes',
env: 'DB_CONNECTION_TIMEOUT',
doc:
'Timeout in milliseconds after which the mailer will stop trying to connect to the database',
doc: 'Timeout in milliseconds after which the mailer will stop trying to connect to the database',
format: 'duration',
},
poolee: {
@ -246,8 +244,7 @@ const conf = convict({
env: 'EMAIL_SERVICE_PORT',
},
forcedEmailAddresses: {
doc:
'force usage of fxa-email-service when sending emails to addresses that match this pattern',
doc: 'force usage of fxa-email-service when sending emails to addresses that match this pattern',
format: RegExp,
default: /emailservice\.[A-Za-z0-9._%+-]+@restmail\.net$/,
env: 'EMAIL_SERVICE_FORCE_EMAIL_REGEX',
@ -560,8 +557,7 @@ const conf = convict({
default: '100 milliseconds',
env: 'REDIS_POOL_TIMEOUT',
format: 'duration',
doc:
'Initial backoff for Redis connection retries, increases exponentially with each attempt',
doc: 'Initial backoff for Redis connection retries, increases exponentially with each attempt',
},
},
subhubServerMessaging: {
@ -572,8 +568,7 @@ const conf = convict({
default: '',
},
subhubUpdatesQueueUrl: {
doc:
'The queue URL to use (should include https://sqs.<region>.amazonaws.com/<account-id>/<queue-name>)',
doc: 'The queue URL to use (should include https://sqs.<region>.amazonaws.com/<account-id>/<queue-name>)',
format: String,
env: 'SUBHUB_QUEUE_URL',
default: '',
@ -596,8 +591,7 @@ const conf = convict({
default: '15 minutes',
},
sessionTokenWithoutDevice: {
doc:
'Maximum age for session tokens without a device record, specify zero to disable',
doc: 'Maximum age for session tokens without a device record, specify zero to disable',
format: 'duration',
env: 'SESSION_TOKEN_WITHOUT_DEVICE_TTL',
default: '4 weeks',
@ -624,8 +618,7 @@ const conf = convict({
default: 1,
},
snsTopicArn: {
doc:
'Amazon SNS topic on which to send account event notifications. Set to "disabled" to turn off the notifier',
doc: 'Amazon SNS topic on which to send account event notifications. Set to "disabled" to turn off the notifier',
format: String,
env: 'SNS_TOPIC_ARN',
default: '',
@ -638,36 +631,31 @@ const conf = convict({
},
emailNotifications: {
region: {
doc:
'The region where the queues live, most likely the same region we are sending email e.g. us-east-1, us-west-2',
doc: 'The region where the queues live, most likely the same region we are sending email e.g. us-east-1, us-west-2',
format: String,
env: 'BOUNCE_REGION',
default: '',
},
bounceQueueUrl: {
doc:
'The bounce queue URL to use (should include https://sqs.<region>.amazonaws.com/<account-id>/<queue-name>)',
doc: 'The bounce queue URL to use (should include https://sqs.<region>.amazonaws.com/<account-id>/<queue-name>)',
format: String,
env: 'BOUNCE_QUEUE_URL',
default: '',
},
complaintQueueUrl: {
doc:
'The complaint queue URL to use (should include https://sqs.<region>.amazonaws.com/<account-id>/<queue-name>)',
doc: 'The complaint queue URL to use (should include https://sqs.<region>.amazonaws.com/<account-id>/<queue-name>)',
format: String,
env: 'COMPLAINT_QUEUE_URL',
default: '',
},
deliveryQueueUrl: {
doc:
'The email delivery queue URL to use (should include https://sqs.<region>.amazonaws.com/<account-id>/<queue-name>)',
doc: 'The email delivery queue URL to use (should include https://sqs.<region>.amazonaws.com/<account-id>/<queue-name>)',
format: String,
env: 'DELIVERY_QUEUE_URL',
default: '',
},
notificationQueueUrl: {
doc:
'Queue URL for notifications from fxa-email-service (eventually this will be the only email-related queue)',
doc: 'Queue URL for notifications from fxa-email-service (eventually this will be the only email-related queue)',
format: String,
env: 'NOTIFICATION_QUEUE_URL',
default: '',
@ -681,8 +669,7 @@ const conf = convict({
default: '',
},
profileUpdatesQueueUrl: {
doc:
'The queue URL to use (should include https://sqs.<region>.amazonaws.com/<account-id>/<queue-name>)',
doc: 'The queue URL to use (should include https://sqs.<region>.amazonaws.com/<account-id>/<queue-name>)',
format: String,
env: 'PROFILE_UPDATES_QUEUE_URL',
default: '',
@ -697,8 +684,7 @@ const conf = convict({
},
secretBearerToken: {
default: 'YOU MUST CHANGE ME',
doc:
'Secret for server-to-server bearer token auth for fxa-profile-server',
doc: 'Secret for server-to-server bearer token auth for fxa-profile-server',
env: 'PROFILE_SERVER_AUTH_SECRET_BEARER_TOKEN',
format: 'String',
},
@ -748,8 +734,7 @@ const conf = convict({
env: 'SUBHUB_ENABLED',
},
useStubs: {
doc:
'Indicates whether to use stub methods for SubHub instead of talking to the server',
doc: 'Indicates whether to use stub methods for SubHub instead of talking to the server',
format: Boolean,
default: false,
env: 'SUBHUB_USE_STUBS',
@ -835,8 +820,7 @@ const conf = convict({
},
},
sharedSecret: {
doc:
'Shared secret for authentication between backend subscription services',
doc: 'Shared secret for authentication between backend subscription services',
format: String,
default: 'YOU MUST CHANGE ME',
env: 'SUBSCRIPTIONS_SHARED_SECRET',
@ -856,8 +840,7 @@ const conf = convict({
transactionalEmails: {
// See also: https://jira.mozilla.com/browse/FXA-1148
enabled: {
doc:
'Indicates whether FxA sends transactional lifecycle emails for subscriptions (i.e. versus Marketing Cloud)',
doc: 'Indicates whether FxA sends transactional lifecycle emails for subscriptions (i.e. versus Marketing Cloud)',
format: Boolean,
env: 'SUBSCRIPTIONS_TRANSACTIONAL_EMAILS_ENABLED',
default: false,
@ -865,8 +848,7 @@ const conf = convict({
},
},
currenciesToCountries: {
doc:
'Mapping from ISO 4217 three-letter currency codes to list of ISO 3166-1 alpha-2 two-letter country codes: {"EUR": ["DE", "FR"], "USD": ["CA", "GB", "US" ]} Requirement for only one currency per country. Tested at runtime. Must be uppercased.',
doc: 'Mapping from ISO 4217 three-letter currency codes to list of ISO 3166-1 alpha-2 two-letter country codes: {"EUR": ["DE", "FR"], "USD": ["CA", "GB", "US" ]} Requirement for only one currency per country. Tested at runtime. Must be uppercased.',
format: Object,
default: {
USD: ['US', 'GB', 'NZ', 'MY', 'SG', 'CA', 'AS', 'GU', 'MP', 'PR', 'VI'],
@ -887,8 +869,7 @@ const conf = convict({
default: false,
},
clientIds: {
doc:
'Mappings from client id to service name: { "id1": "name-1", "id2": "name-2" }',
doc: 'Mappings from client id to service name: { "id1": "name-1", "id2": "name-2" }',
format: Object,
default: {},
env: 'OAUTH_CLIENT_IDS',
@ -902,8 +883,7 @@ const conf = convict({
// A safety switch for disabling new signins/signups from particular clients,
// as a hedge against unexpected client behaviour.
disableNewConnectionsForClients: {
doc:
'Comma-separated list of oauth client ids for which new connections should be temporarily refused',
doc: 'Comma-separated list of oauth client ids for which new connections should be temporarily refused',
env: 'OAUTH_DISABLE_NEW_CONNECTIONS_FOR_CLIENTS',
format: Array,
default: [],
@ -935,8 +915,7 @@ const conf = convict({
default: 'megaz0rd',
},
jwtSecretKeys: {
doc:
'Comma-separated list of secret keys for verifying oauth-to-auth server JWTs',
doc: 'Comma-separated list of secret keys for verifying oauth-to-auth server JWTs',
env: 'OAUTH_SERVER_SECRETS',
format: Array,
default: ['megaz0rd'],
@ -1011,8 +990,7 @@ const conf = convict({
},
},
authServerSecrets: {
doc:
'Comma-separated list of secret keys for verifying server-to-server JWTs',
doc: 'Comma-separated list of secret keys for verifying server-to-server JWTs',
env: 'AUTH_SERVER_SECRETS',
format: 'Array',
default: [],
@ -1043,8 +1021,7 @@ const conf = convict({
},
clientManagement: {
enabled: {
doc:
'Enable client management in OAuth server routes. Do NOT set this to true in production.',
doc: 'Enable client management in OAuth server routes. Do NOT set this to true in production.',
default: false,
format: 'Boolean',
env: 'CLIENT_MANAGEMENT_ENABLED',
@ -1054,15 +1031,13 @@ const conf = convict({
// This is used by oauth/db/index.js to identify pocket client ids so that it
// can store them separately in mysql.
// It's also used for amplitude stats
doc:
'Mappings from client id to service name: { "id1": "name-1", "id2": "name-2" }',
doc: 'Mappings from client id to service name: { "id1": "name-1", "id2": "name-2" }',
default: {},
format: 'Object',
env: 'OAUTH_CLIENT_IDS',
},
disabledClients: {
doc:
'Comma-separated list of client ids for which service should be temporarily refused',
doc: 'Comma-separated list of client ids for which service should be temporarily refused',
env: 'OAUTH_CLIENTS_DISABLED',
format: 'Array',
default: [],
@ -1106,8 +1081,7 @@ const conf = convict({
},
events: {
enabled: {
doc:
'Whether or not config.events has to be properly set in production',
doc: 'Whether or not config.events has to be properly set in production',
default: true,
format: 'Boolean',
env: 'EVENTS_ENABLED',
@ -1203,8 +1177,7 @@ const conf = convict({
},
timezone: {
default: 'Z',
doc:
'The timezone configured on the MySQL server. This is used to type cast server date/time values to JavaScript `Date` object. Can be `local`, `Z`, or an offset in the form of or an offset in the form +HH:MM or -HH:MM.',
doc: 'The timezone configured on the MySQL server. This is used to type cast server date/time values to JavaScript `Date` object. Can be `local`, `Z`, or an offset in the form of or an offset in the form +HH:MM or -HH:MM.',
env: 'MYSQL_TIMEZONE',
format: 'String',
},
@ -1217,8 +1190,7 @@ const conf = convict({
env: 'FXA_OPENID_KEYFILE',
},
newKeyFile: {
doc:
'Path to private key JWK that will be used to sign JWTs in the future',
doc: 'Path to private key JWK that will be used to sign JWTs in the future',
default: '',
format: 'String',
env: 'FXA_OPENID_NEWKEYFILE',
@ -1245,8 +1217,7 @@ const conf = convict({
env: 'FXA_OPENID_OLDKEY',
},
unsafelyAllowMissingActiveKey: {
doc:
'Do not error out if there is no active key; should only be used when initializing keys',
doc: 'Do not error out if there is no active key; should only be used when initializing keys',
default: false,
format: 'Boolean',
env: 'FXA_OPENID_UNSAFELY_ALLOW_MISSING_ACTIVE_KEY',
@ -1277,8 +1248,7 @@ const conf = convict({
env: 'PPID_CLIENT_IDS',
},
rotatingClientIds: {
doc:
'client_ids that receive automatically rotating PPIDs based on server time',
doc: 'client_ids that receive automatically rotating PPIDs based on server time',
default: [],
format: 'Array',
env: 'PPID_ROTATING_CLIENT_IDS',
@ -1332,8 +1302,7 @@ const conf = convict({
developerId: { doc: 'Bytes of generated developer ids', default: 16 },
},
cacheControl: {
doc:
'Hapi: a string with the value of the "Cache-Control" header when caching is disabled',
doc: 'Hapi: a string with the value of the "Cache-Control" header when caching is disabled',
format: 'String',
default: 'private, no-cache, no-store, must-revalidate',
},
@ -1410,15 +1379,13 @@ const conf = convict({
},
signinConfirmation: {
forcedEmailAddresses: {
doc:
'Force sign-in confirmation for email addresses matching this regex.',
doc: 'Force sign-in confirmation for email addresses matching this regex.',
format: RegExp,
default: /.+@mozilla\.com$/,
env: 'SIGNIN_CONFIRMATION_FORCE_EMAIL_REGEX',
},
skipForEmailAddresses: {
doc:
'Comma separated list of email addresses that will always skip any non TOTP sign-in confirmation',
doc: 'Comma separated list of email addresses that will always skip any non TOTP sign-in confirmation',
format: Array,
default: [],
env: 'SIGNIN_CONFIRMATION_SKIP_FOR_EMAIL_ADDRESS',
@ -1467,8 +1434,7 @@ const conf = convict({
securityHistory: {
ipProfiling: {
allowedRecency: {
doc:
'Length of time since previously verified event to allow skipping confirmation',
doc: 'Length of time since previously verified event to allow skipping confirmation',
default: '72 hours',
format: 'duration',
env: 'IP_PROFILING_RECENCY',
@ -1483,15 +1449,13 @@ const conf = convict({
env: 'LASTACCESSTIME_UPDATES_ENABLED',
},
sampleRate: {
doc:
'sample rate for updates to the lastAccessTime session token property, in the range 0..1',
doc: 'sample rate for updates to the lastAccessTime session token property, in the range 0..1',
format: Number,
default: 0.3,
env: 'LASTACCESSTIME_UPDATES_SAMPLE_RATE',
},
earliestSaneTimestamp: {
doc:
'timestamp used as the basis of the fallback value for lastAccessTimeFormatted, currently pinned to the deployment of 1.96.4 / a0940d7dc51e2ba20fa18aa3a830810e35c9a9d9',
doc: 'timestamp used as the basis of the fallback value for lastAccessTimeFormatted, currently pinned to the deployment of 1.96.4 / a0940d7dc51e2ba20fa18aa3a830810e35c9a9d9',
format: 'timestamp',
default: 1507081020000,
env: 'LASTACCESSTIME_EARLIEST_SANE_TIMESTAMP',
@ -1510,8 +1474,7 @@ const conf = convict({
env: 'SIGNIN_UNBLOCK_CODE_LIFETIME',
},
forcedEmailAddresses: {
doc:
'If feature enabled, force sign-in unblock for email addresses matching this regex.',
doc: 'If feature enabled, force sign-in unblock for email addresses matching this regex.',
format: RegExp,
default: /^$/, // default is no one
env: 'SIGNIN_UNBLOCK_FORCED_EMAILS',
@ -1522,7 +1485,8 @@ const conf = convict({
doc: 'RegExp that validates the URI format of the Push Server',
format: RegExp,
// eslint-disable-next-line no-useless-escape
default: /^https:\/\/[a-zA-Z0-9._-]+(\.services\.mozilla\.com|autopush\.dev\.mozaws\.net|autopush\.stage\.mozaws\.net)(?:\:\d+)?(\/.*)?$/,
default:
/^https:\/\/[a-zA-Z0-9._-]+(\.services\.mozilla\.com|autopush\.dev\.mozaws\.net|autopush\.stage\.mozaws\.net)(?:\:\d+)?(\/.*)?$/,
},
},
pushbox: {
@ -1604,22 +1568,19 @@ const conf = convict({
env: 'SMS_INSTALL_FIREFOX_LINK',
},
installFirefoxWithSigninCodeBaseUri: {
doc:
'Base URI for the SMS template when the signinCodes feature is active',
doc: 'Base URI for the SMS template when the signinCodes feature is active',
default: 'https://accounts.firefox.com/m',
format: 'url',
env: 'SMS_SIGNIN_CODES_BASE_URI',
},
enableBudgetChecks: {
doc:
'enable checks of the monthly SMS spend against the available budget',
doc: 'enable checks of the monthly SMS spend against the available budget',
default: true,
format: Boolean,
env: 'SMS_ENABLE_BUDGET_CHECKS',
},
minimumCreditThresholdUSD: {
doc:
'The minimum amount of available credit that is necessary to enable SMS, in US dollars',
doc: 'The minimum amount of available credit that is necessary to enable SMS, in US dollars',
default: 200,
format: 'nat',
env: 'SMS_MINIMUM_CREDIT_THRESHOLD',
@ -1631,8 +1592,7 @@ const conf = convict({
env: 'SMS_POLL_CURRENT_SPEND_INTERVAL',
},
smsType: {
doc:
'AWS.SNS.SMS.SMSType argument value. "Promotional" or "Transactional".',
doc: 'AWS.SNS.SMS.SMSType argument value. "Promotional" or "Transactional".',
default: 'Promotional',
format: 'String',
env: 'SMS_AWS_SNS_SMSTYPE',
@ -1640,8 +1600,7 @@ const conf = convict({
},
secondaryEmail: {
minUnverifiedAccountTime: {
doc:
'The minimum amount of time an account can be unverified before another account can use it for secondary email',
doc: 'The minimum amount of time an account can be unverified before another account can use it for secondary email',
default: '1 day',
format: 'duration',
env: 'SECONDARY_EMAIL_MIN_UNVERIFIED_ACCOUNT_TIME',
@ -1691,8 +1650,7 @@ const conf = convict({
env: 'RECOVERY_CODE_COUNT',
},
notifyLowCount: {
doc:
'Notify the user when there are less than these many recovery codes',
doc: 'Notify the user when there are less than these many recovery codes',
default: 2,
env: 'RECOVERY_CODE_NOTIFY_LOW_COUNT',
},
@ -1712,8 +1670,7 @@ const conf = convict({
format: 'duration',
},
secondInterval: {
doc:
'Time since account creation after which the second reminder is sent',
doc: 'Time since account creation after which the second reminder is sent',
default: '5 days',
env: 'VERIFICATION_REMINDERS_SECOND_INTERVAL',
format: 'duration',
@ -1727,15 +1684,13 @@ const conf = convict({
},
maxConnections: {
default: 10,
doc:
'Maximum connection count for the verification reminders Redis pool',
doc: 'Maximum connection count for the verification reminders Redis pool',
env: 'VERIFICATION_REMINDERS_REDIS_MAX_CONNECTIONS',
format: 'nat',
},
minConnections: {
default: 1,
doc:
'Minimum connection count for the verification reminders Redis pool',
doc: 'Minimum connection count for the verification reminders Redis pool',
env: 'VERIFICATION_REMINDERS_REDIS_MIN_CONNECTIONS',
format: 'nat',
},
@ -1813,15 +1768,13 @@ const conf = convict({
format: Number,
},
locationStateFieldId: {
doc:
'Zendesk support ticket custom field for the state/region of the location',
doc: 'Zendesk support ticket custom field for the state/region of the location',
default: 360026463491,
env: 'ZENDESK_LOCATION_STATE_FIELD_ID',
format: Number,
},
locationCountryFieldId: {
doc:
'Zendesk support ticket custom field for the country of the location',
doc: 'Zendesk support ticket custom field for the country of the location',
default: 360026463511,
env: 'ZENDESK_LOCATION_COUNTRY_FIELD_ID',
format: Number,
@ -1833,8 +1786,7 @@ const conf = convict({
format: Number,
},
appFieldId: {
doc:
'Zendesk support ticket custom field for product specific app or service',
doc: 'Zendesk support ticket custom field for product specific app or service',
default: 360030780972,
env: 'ZENDESK_APP_FIELD_ID',
format: Number,
@ -1863,8 +1815,7 @@ const conf = convict({
supportPanel: {
secretBearerToken: {
default: 'YOU MUST CHANGE ME',
doc:
'Shared secret to access certain endpoints. Please only use for GET. No state mutation allowed!',
doc: 'Shared secret to access certain endpoints. Please only use for GET. No state mutation allowed!',
env: 'SUPPORT_PANEL_AUTH_SECRET_BEARER_TOKEN',
format: 'String',
},
@ -1898,6 +1849,7 @@ conf.set(
`${baseUri}/settings/two_step_authentication/replace_codes`
);
conf.set('smtp.verificationUrl', `${baseUri}/verify_email`);
conf.set('smtp.pushVerificationUrl', `${baseUri}/push/confirm_login`);
conf.set('smtp.passwordResetUrl', `${baseUri}/complete_reset_password`);
conf.set('smtp.initiatePasswordResetUrl', `${baseUri}/reset_password`);
conf.set(

Просмотреть файл

@ -447,25 +447,27 @@ module.exports = function (
const sessionToken = request.auth.credentials;
const { uid, tokenVerificationId } = sessionToken;
const devices = await db.devices(uid);
const geoData = request.app.geo;
const allDevices = await db.devices(uid);
const location = request.app.geo.location || {};
const { ua } = request.app;
const uaInfo = {
uaBrowser: ua.browser,
uaBrowserVersion: ua.browserVersion,
uaOS: ua.os,
uaOSVersion: ua.osVersion,
uaDeviceType: ua.deviceType,
uaFormFactor: ua.formFactor,
};
// Don't send notification to current device
const filteredDevices = allDevices.filter((d) => {
return d.sessionTokenId !== sessionToken.id;
});
const url = `${
config.smtp.verificationUrl
config.smtp.pushVerificationUrl
}?type=push_login_verification&code=${tokenVerificationId}&ua=${encodeURIComponent(
JSON.stringify(uaInfo)
)}&location=${encodeURIComponent(
JSON.stringify(geoData.location)
JSON.stringify(location)
)}&ip=${encodeURIComponent(request.app.clientAddress)}`;
const options = {
@ -475,7 +477,7 @@ module.exports = function (
};
try {
await push.notifyVerifyLoginRequest(uid, devices, options);
await push.notifyVerifyLoginRequest(uid, filteredDevices, options);
} catch (err) {
log.error('Session.verify.send_push', {
uid: uid,

Просмотреть файл

@ -1416,6 +1416,14 @@ FxaClientWrapper.prototype = {
);
}
),
/**
* Sends a push notification to compatible devices that can verify a login
* request
*
* @returns {Promise} resolves with response when complete.
*/
sendPushLoginRequest: createClientDelegate('sendPushLoginRequest'),
};
export default FxaClientWrapper;

Просмотреть файл

@ -184,6 +184,9 @@ const Router = Backbone.Router.extend({
type: VerificationReasons.SECONDARY_EMAIL_VERIFIED,
}
),
'push/confirm_login(/)': createViewHandler('push/confirm_login'),
'push/send_login(/)': createViewHandler('push/send_login'),
'push/completed(/)': createViewHandler('push/completed'),
'primary_email_verified(/)': createViewHandler(ReadyView, {
type: VerificationReasons.PRIMARY_EMAIL_VERIFIED,
}),
@ -208,11 +211,8 @@ const Router = Backbone.Router.extend({
'settings(/)': function () {
// Because settings is a separate js app, we need to ensure navigating
// from the content-server app passes along flow parameters.
const {
deviceId,
flowBeginTime,
flowId,
} = this.metrics.getFlowEventMetadata();
const { deviceId, flowBeginTime, flowId } =
this.metrics.getFlowEventMetadata();
const {
broker,
@ -343,9 +343,8 @@ const Router = Backbone.Router.extend({
url = this.broker.transformLink(url);
if (options.replace && this._viewModelStack.length) {
this._viewModelStack[this._viewModelStack.length - 1] = createViewModel(
nextViewData
);
this._viewModelStack[this._viewModelStack.length - 1] =
createViewModel(nextViewData);
} else {
this._viewModelStack.push(createViewModel(nextViewData));
}

Просмотреть файл

@ -1116,30 +1116,28 @@ const Account = Backbone.Model.extend(
*
* @method set
*/
set: _.wrap(Backbone.Model.prototype.set, function (
func,
attribute,
value,
options
) {
let attributes;
// Handle both `"key", value` and `{key: value}` -style arguments.
if (_.isObject(attribute)) {
attributes = attribute;
} else {
attributes = {};
attributes[attribute] = value;
}
// eslint-disable-next-line no-unused-vars
for (const key in attributes) {
if (!_.contains(ALLOWED_KEYS, key)) {
throw new Error(key + ' cannot be set on an Account');
set: _.wrap(
Backbone.Model.prototype.set,
function (func, attribute, value, options) {
let attributes;
// Handle both `"key", value` and `{key: value}` -style arguments.
if (_.isObject(attribute)) {
attributes = attribute;
} else {
attributes = {};
attributes[attribute] = value;
}
}
return func.call(this, attribute, value, options);
}),
// eslint-disable-next-line no-unused-vars
for (const key in attributes) {
if (!_.contains(ALLOWED_KEYS, key)) {
throw new Error(key + ' cannot be set on an Account');
}
}
return func.call(this, attribute, value, options);
}
),
/**
* Complete a password reset
@ -1820,6 +1818,15 @@ const Account = Backbone.Model.extend(
createCadReminder() {
return this._fxaClient.createCadReminder(this.get('sessionToken'));
},
/**
* Sends a push notification to verify a login request.
*
* @returns {Promise} resolves with response when complete.
*/
sendPushLoginRequest() {
return this._fxaClient.sendPushLoginRequest(this.get('sessionToken'));
},
},
{
ALLOWED_KEYS: ALLOWED_KEYS,

Просмотреть файл

@ -0,0 +1,14 @@
<div id="main-content" class="card pair-auth push-auth-complete">
<header>
<h1 id="push-auth-complete-header">{{#t}}Sign-in confirmed{{/t}}</h1>
</header>
<section>
<div class="error"></div>
<div class="graphic graphic-checkbox" role="img" aria-label="{{#t}}Successfully connected{{/t}}"></div>
<p class="verification-message">{{#t}}Please close this page and continue on the other device.{{/t}}</p>
</section>
</div>

Просмотреть файл

@ -0,0 +1,25 @@
<div id="main-content" class="card push-confirm-login">
<header>
<h1 id="fxa-push-confirm-login-header">{{#t}}New sign-in to Firefox{{/t}}</h1>
</header>
<section>
<div class="error"></div>
<div class="success"></div>
<form novalidate>
<p class="verification-message">{{#t}}For added security, please confirm this sign-in to begin syncing with this device:{{/t}}</p>
<div class="push-confirm-login-device">
{{{ unsafeDeviceBeingPairedHTML }}}
</div>
<div class="button-row">
<button id="submit-btn" type="submit">{{#t}}Confirm sign-in{{/t}}</button>
</div>
<p>{{#unsafeTranslate}}If you suspect that someone is trying to gain access to your account, <a id="change-password" href="/settings/change_password">please change your password.</a>{{/unsafeTranslate}}</p>
</form>
</section>
</div>

Просмотреть файл

@ -0,0 +1,21 @@
<div id="main-content" class="card push-send-login">
<header>
<h1 id="fxa-push-send-login-header">{{#t}}Authorize this sign-in{{/t}}</span></h1>
</header>
<section>
<div class="error"></div>
<div class="success"></div>
<form novalidate>
<div class="graphic graphic-send-tab-complete" role="img" aria-label="{{#t}}Send login push notification{{/t}}"></div>
<p class="verification-message">{{#t}}Check your connected Firefox devices for the sign-in confirmation tab we've sent.{{/t}}</p>
<div class="links">
<a id="resend" href="#" data-flow-event="link.create-account">{{#t}}Resend tab{{/t}}</a>
<a id="send-email" class="delayed-fadein" href="#">{{#t}}Tab not arriving? Send an email instead{{/t}}</a>
</div>
</form>
</section>
</div>

Просмотреть файл

@ -0,0 +1,22 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import FormView from '../form';
import Template from '../../templates/push/completed.mustache';
class CompletedPushLoginView extends FormView {
template = Template;
beforeRender() {
const account = this.getSignedInAccount();
// If no user is logged in redirect to the login page and set the `redirectTo` property
// to current url. After a user has logged in, they will be redirected back to this page.
if (account && account.isDefault()) {
this.relier.set('redirectTo', this.window.location.href);
return this.navigate('/');
}
}
}
export default CompletedPushLoginView;

Просмотреть файл

@ -0,0 +1,62 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { assign } from 'underscore';
import FormView from '../form';
import preventDefaultThen from '../decorators/prevent_default_then';
import Template from '../../templates/push/confirm_login.mustache';
import DeviceBeingPairedTemplate from '../../templates/partial/device-being-paired.mustache';
import Url from '../../lib/url';
class ConfirmPushLoginView extends FormView {
template = Template;
events = assign(this.events, {
'click #change-password': preventDefaultThen('changePassword'),
});
initialize(options = {}) {
const params = Url.searchParams(this.window.location.search);
const ua = options.ua || JSON.parse(params.ua);
const location = params.location ? JSON.parse(params.location) : {};
const ip = options.ip || params.ip;
this.code = options.code || params.code;
this.deviceContext = {
family: ua.uaBrowser,
OS: ua.uaOS,
ipAddress: ip,
...location,
};
}
setInitialContext(context) {
context.set({
unsafeDeviceBeingPairedHTML: this.renderTemplate(
DeviceBeingPairedTemplate,
this.deviceContext
),
});
}
changePassword() {}
submit() {
// TODO: Unfortunately, we need to use `/recovery_email/verify_code`
// endpoint to verify our tokenVerificationCode because that code
// is linked directly to a specific session.
const account = this.getSignedInAccount();
return account
.verifySignUp(this.code)
.then(() => {
return this.navigate('/push/completed');
})
.catch((err) => {
this.displayError(err);
});
}
}
export default ConfirmPushLoginView;

Просмотреть файл

@ -0,0 +1,77 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { assign } from 'underscore';
import FormView from '../form';
import preventDefaultThen from '../decorators/prevent_default_then';
import Template from '../../templates/push/send_login.mustache';
import SessionVerificationPollMixin from '../mixins/session-verification-poll-mixin';
import Cocktail from '../../lib/cocktail';
import FlowEventsMixin from '../mixins/flow-events-mixin';
const proto = FormView.prototype;
class SendPushLoginView extends FormView {
template = Template;
events = assign(this.events, {
'click #resend': preventDefaultThen('resend'),
'click #send-email': preventDefaultThen('useEmailCode'),
});
beforeRender() {
const account = this.getSignedInAccount();
return account
.sendPushLoginRequest()
.then(() => this.invokeBrokerMethod('beforeSignIn', account));
}
afterVisible() {
const account = this.getSignedInAccount();
return proto.afterVisible
.call(this)
.then(() => this.broker.persistVerificationData(account))
.then(() =>
this.invokeBrokerMethod('beforeSignUpConfirmationPoll', account)
)
.then(() => {
return this.waitForSessionVerification(account, () => {
this.logViewEvent('verification.success');
this.notifier.trigger('verification.success');
return this.invokeBrokerMethod(
'afterCompleteSignInWithCode',
account
);
});
});
}
resend() {
const account = this.getSignedInAccount();
return account
.sendPushLoginRequest()
.then(() => {
this.displaySuccess('Notification sent');
})
.catch(() => {
this.displayError('Something went wrong');
});
}
useEmailCode() {
const account = this.getSignedInAccount();
return account.verifySessionResendCode().then(() => {
return this.navigate('/signin_token_code');
});
}
}
Cocktail.mixin(
SendPushLoginView,
FlowEventsMixin,
SessionVerificationPollMixin
);
export default SendPushLoginView;

Просмотреть файл

@ -16,6 +16,8 @@ import SessionVerificationPollMixin from './mixins/session-verification-poll-mix
const CODE_INPUT_SELECTOR = 'input.otp-code';
const proto = FormView.prototype;
const View = FormView.extend({
className: 'sign-in-token-code',
template: Template,
@ -37,9 +39,18 @@ const View = FormView.extend({
// is deleted. If the account no longer exists, redirects the user to
// sign up, if the account exists, then notifies them their account
// has been blocked.
this.waitForSessionVerification(this.getAccount(), () => {
// don't do anything on verification, that's taken care of in the submit handler.
});
const account = this.getSignedInAccount();
return proto.afterVisible
.call(this)
.then(() => this.broker.persistVerificationData(account))
.then(() =>
this.invokeBrokerMethod('beforeSignUpConfirmationPoll', account)
)
.then(() => {
this.waitForSessionVerification(this.getAccount(), () => {
// don't do anything on verification, that's taken care of in the submit handler.
});
});
},
setInitialContext(context) {

Просмотреть файл

@ -239,3 +239,7 @@
font-family: monospace;
font-weight: bold;
}
.push-confirm-login-device {
padding: 15px 0 45px 0;
}

Просмотреть файл

@ -138,3 +138,8 @@
.graphic-pair-failure {
background-image: image-url('graphic_hearts_broken.svg');
}
.graphic-send-tab-complete {
background-image: image-url('send_tab_complete.svg');
}

Просмотреть файл

@ -0,0 +1,76 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { assert } from 'chai';
import $ from 'jquery';
import Account from 'models/account';
import Backbone from 'backbone';
import Metrics from 'lib/metrics';
import Relier from 'models/reliers/relier';
import View from 'views/push/completed';
import _ from 'underscore';
import sinon from 'sinon';
describe('views/push/completed', () => {
let account;
let model;
let relier;
let view;
let notifier;
let metrics;
beforeEach(() => {
account = new Account({
email: 'a@a.com',
uid: 'uid',
});
relier = new Relier({});
model = new Backbone.Model({
account,
});
notifier = _.extend({}, Backbone.Events);
metrics = new Metrics({ notifier });
view = new View({
model,
relier,
notifier,
metrics,
});
sinon.stub(view, 'getSignedInAccount').callsFake(() => account);
return view.render().then(() => $('#container').html(view.$el));
});
afterEach(() => {
view.remove();
view.destroy();
view = null;
});
describe('render', () => {
it('renders the view', () => {
assert.lengthOf(view.$('#push-auth-complete-header'), 1);
assert.include(
view.$('.verification-message').text(),
'Please close this page'
);
});
describe('without an account', () => {
beforeEach(() => {
account = new Account({});
sinon.spy(view, 'navigate');
return view.render();
});
it('redirects to the email first page', () => {
assert.isTrue(view.navigate.calledWith('/'));
});
});
});
});

Просмотреть файл

@ -0,0 +1,123 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import _ from 'underscore';
import { assert } from 'chai';
import Account from 'models/account';
import Backbone from 'backbone';
import BaseBroker from 'models/auth_brokers/base';
import Metrics from 'lib/metrics';
import Relier from 'models/reliers/relier';
import sinon from 'sinon';
import User from 'models/user';
import View from 'views/push/confirm_login';
import WindowMock from '../../../mocks/window';
import $ from 'jquery';
describe('views/push/confirm_login', () => {
let account;
let broker;
let metrics;
let model;
let notifier;
let relier;
let user;
let view;
let windowMock;
const ua = {
uaBrowser: 'Firefox',
uaOS: 'OSX',
};
beforeEach(() => {
windowMock = new WindowMock();
relier = new Relier({
window: windowMock,
});
broker = new BaseBroker({
relier: relier,
window: windowMock,
});
account = new Account({
email: 'a@a.com',
uid: 'uid',
});
model = new Backbone.Model({
account,
});
notifier = _.extend({}, Backbone.Events);
metrics = new Metrics({ notifier });
user = new User();
view = new View({
broker,
metrics,
model,
notifier,
relier,
user,
window: windowMock,
ua,
ip: '123.123.123.123',
code: 'validCode',
});
sinon.stub(view, 'getSignedInAccount').callsFake(() => account);
return view.render().then(() => $('#container').html(view.$el));
});
afterEach(() => {
metrics.destroy();
view.remove();
view.destroy();
view = metrics = null;
});
describe('render', () => {
it('renders the view', () => {
assert.lengthOf(view.$('#fxa-push-confirm-login-header'), 1);
assert.include(
view.$('.verification-message').text(),
'please confirm this sign-in'
);
assert.lengthOf(view.$('#submit-btn'), 1);
assert.lengthOf(view.$('#change-password'), 1);
assert.include(view.$('.push-confirm-login-device').text(), 'Firefox');
assert.include(
view.$('.push-confirm-login-device').text(),
'123.123.123.123'
);
assert.include(
view.$('.push-confirm-login-device').text(),
'Location unknown'
);
});
});
describe('submit', () => {
describe('success', () => {
beforeEach(() => {
sinon.stub(account, 'verifySignUp').callsFake(() => Promise.resolve());
sinon.spy(view, 'navigate');
return view.submit();
});
it('calls correct methods', () => {
assert.isTrue(
account.verifySignUp.calledWith('validCode'),
'verify with correct code'
);
assert.isTrue(view.navigate.calledOnceWith('/push/completed'));
});
});
});
});

Просмотреть файл

@ -0,0 +1,155 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import _ from 'underscore';
import { assert } from 'chai';
import Account from 'models/account';
import Backbone from 'backbone';
import BaseBroker from 'models/auth_brokers/base';
import Metrics from 'lib/metrics';
import Relier from 'models/reliers/relier';
import sinon from 'sinon';
import User from 'models/user';
import View from 'views/push/send_login';
import WindowMock from '../../../mocks/window';
import SentryMetrics from 'lib/sentry';
import SessionVerificationPoll from 'models/polls/session-verification';
describe('views/push/send_login', () => {
let account;
let broker;
let metrics;
let model;
let notifier;
let relier;
let user;
let view;
let windowMock;
let sentryMetrics;
let sessionVerificationPoll;
beforeEach(async () => {
windowMock = new WindowMock();
relier = new Relier({
window: windowMock,
});
broker = new BaseBroker({
relier: relier,
window: windowMock,
});
account = new Account({
email: 'a@a.com',
uid: 'uid',
});
model = new Backbone.Model({
account: account,
lastPage: 'signin',
password: 'password',
});
notifier = _.extend({}, Backbone.Events);
sentryMetrics = new SentryMetrics();
metrics = new Metrics({ notifier, sentryMetrics });
user = new User();
sessionVerificationPoll = new SessionVerificationPoll(
{},
{
account,
pollIntervalInMS: 2,
window: windowMock,
}
);
view = new View({
broker,
canGoBack: true,
metrics,
model,
notifier,
relier,
user,
viewName: 'send-login',
window: windowMock,
sessionVerificationPoll,
});
sinon.stub(view, 'getSignedInAccount').callsFake(() => account);
sinon
.stub(account, 'sendPushLoginRequest')
.callsFake(() => Promise.resolve());
sinon
.stub(view, '_handleSessionVerificationPollErrors')
.callsFake(() => {});
sinon.stub(sessionVerificationPoll, 'start').callsFake(() => {});
return view.render();
});
afterEach(function () {
metrics.destroy();
view.remove();
view.destroy();
view = metrics = null;
});
describe('render', () => {
it('renders the view', () => {
assert.lengthOf(view.$('#fxa-push-send-login-header'), 1);
assert.include(
view.$('.verification-message').text(),
'Check your connected Firefox devices'
);
assert.lengthOf(view.$('#resend'), 1);
assert.lengthOf(view.$('#send-email'), 1);
});
it('sends push notification to account', () => {
assert.isTrue(account.sendPushLoginRequest.calledOnce);
});
});
describe('afterVisible', () => {
beforeEach(async () => {
sinon.spy(broker, 'persistVerificationData');
sinon.spy(view, 'waitForSessionVerification');
sinon.spy(view, 'invokeBrokerMethod');
return view.afterVisible().then(() => {
// simulate account being verified
return sessionVerificationPoll.trigger('verified');
});
});
it('starts polling for session to be verified', () => {
assert.isTrue(broker.persistVerificationData.calledOnceWith(account));
assert.isTrue(view.waitForSessionVerification.calledOnce);
assert.isTrue(view.invokeBrokerMethod.calledTwice);
const args = view.invokeBrokerMethod.args;
assert.equal(args[0][0], 'beforeSignUpConfirmationPoll');
assert.equal(args[1][0], 'afterCompleteSignInWithCode');
});
});
describe('resend', () => {
describe('success', () => {
beforeEach(() => {
sinon.spy(view, 'displaySuccess');
return view.render().then(() => {
account.sendPushLoginRequest.resetHistory();
return view.resend();
});
});
it('calls correct methods', () => {
assert.isTrue(account.sendPushLoginRequest.calledOnce);
assert.isTrue(view.displaySuccess.calledOnce);
});
});
});
});

Просмотреть файл

@ -107,13 +107,13 @@ describe('views/sign_in_token_code', () => {
});
describe('afterVisible', () => {
it('starts polling in case the email bounces', () => {
it('starts polling in case the email bounces', async () => {
const account = { uid: 'uid' };
sinon.stub(view, 'waitForSessionVerification');
sinon.stub(view, 'getAccount').returns(account);
view.afterVisible();
await view.afterVisible();
assert.isTrue(view.waitForSessionVerification.calledOnceWith(account));
});

Просмотреть файл

@ -237,6 +237,9 @@ require('./spec/views/post_verify/password/force_password_change');
require('./spec/views/post_verify/secondary_email/add_secondary_email');
require('./spec/views/post_verify/secondary_email/confirm_secondary_email');
require('./spec/views/progress_indicator');
require('./spec/views/push/confirm_login');
require('./spec/views/push/send_login');
require('./spec/views/push/completed');
require('./spec/views/ready');
require('./spec/views/report_sign_in');
require('./spec/views/reset_password');

Просмотреть файл

@ -49,6 +49,9 @@ module.exports = function () {
'post_verify/secondary_email/confirm_secondary_email',
'post_verify/secondary_email/verified_secondary_email',
'primary_email_verified',
'push/completed',
'push/confirm_login',
'push/send_login',
'report_signin',
'reset_password',
'reset_password_confirmed',