mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-24 12:06:15 +01:00
[PM-2609] Allow auto-filling TOTP codes (#2142)
* Begin implementing TOTP autofill * Add support for Cloudflare * Fix linting errors * Add GitHub support * Automatically check for autocomplete="one-time-code" * Fix TOTP-filling for Steam * Make auto-fill on page load work for TOTP * [PM-2609] Introduce logic to handle skipping autofill of TOTP on page load * [PM-2609] Ensuring other forms of user initiated autofill can autofill the TOTP value for a vault item --------- Co-authored-by: Daniel James Smith <djsmith@web.de> Co-authored-by: Cesar Gonzalez <cgonzalez@bitwarden.com> Co-authored-by: Cesar Gonzalez <cesar.a.gonzalezcs@gmail.com>
This commit is contained in:
parent
4124f7bdc8
commit
4dc34fc7a8
@ -46,6 +46,7 @@ export class AutofillTabCommand {
|
||||
onlyEmptyFields: false,
|
||||
onlyVisibleFields: false,
|
||||
fillNewPassword: true,
|
||||
allowTotpAutofill: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -21,6 +21,7 @@ export interface AutoFillOptions {
|
||||
fillNewPassword?: boolean;
|
||||
skipLastUsed?: boolean;
|
||||
allowUntrustedIframe?: boolean;
|
||||
allowTotpAutofill?: boolean;
|
||||
}
|
||||
|
||||
export interface FormData {
|
||||
|
@ -20,6 +20,17 @@ export class AutoFillConstants {
|
||||
"benutzer id",
|
||||
];
|
||||
|
||||
static readonly TotpFieldNames: string[] = [
|
||||
"totp",
|
||||
"2fa",
|
||||
"mfa",
|
||||
"totpcode",
|
||||
"2facode",
|
||||
"mfacode",
|
||||
"twofactor",
|
||||
"twofactorcode",
|
||||
];
|
||||
|
||||
static readonly PasswordFieldIgnoreList: string[] = [
|
||||
"onetimepassword",
|
||||
"captcha",
|
||||
|
@ -32,6 +32,7 @@ export interface GenerateFillScriptOptions {
|
||||
onlyEmptyFields: boolean;
|
||||
onlyVisibleFields: boolean;
|
||||
fillNewPassword: boolean;
|
||||
allowTotpAutofill: boolean;
|
||||
cipher: CipherView;
|
||||
tabUrl: string;
|
||||
defaultUriMatch: UriMatchType;
|
||||
@ -127,69 +128,72 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
const defaultUriMatch = (await this.stateService.getDefaultUriMatch()) ?? UriMatchType.Domain;
|
||||
|
||||
let didAutofill = false;
|
||||
options.pageDetails.forEach((pd) => {
|
||||
// make sure we're still on correct tab
|
||||
if (pd.tab.id !== tab.id || pd.tab.url !== tab.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fillScript = this.generateFillScript(pd.details, {
|
||||
skipUsernameOnlyFill: options.skipUsernameOnlyFill || false,
|
||||
onlyEmptyFields: options.onlyEmptyFields || false,
|
||||
onlyVisibleFields: options.onlyVisibleFields || false,
|
||||
fillNewPassword: options.fillNewPassword || false,
|
||||
cipher: options.cipher,
|
||||
tabUrl: tab.url,
|
||||
defaultUriMatch: defaultUriMatch,
|
||||
});
|
||||
|
||||
if (!fillScript || !fillScript.script || !fillScript.script.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
fillScript.untrustedIframe &&
|
||||
options.allowUntrustedIframe != undefined &&
|
||||
!options.allowUntrustedIframe
|
||||
) {
|
||||
this.logService.info("Auto-fill on page load was blocked due to an untrusted iframe.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Add a small delay between operations
|
||||
fillScript.properties.delay_between_operations = 20;
|
||||
|
||||
didAutofill = true;
|
||||
if (!options.skipLastUsed) {
|
||||
this.cipherService.updateLastUsedDate(options.cipher.id);
|
||||
}
|
||||
|
||||
BrowserApi.tabSendMessage(
|
||||
tab,
|
||||
{
|
||||
command: "fillForm",
|
||||
fillScript: fillScript,
|
||||
url: tab.url,
|
||||
},
|
||||
{ frameId: pd.frameId }
|
||||
);
|
||||
|
||||
if (
|
||||
options.cipher.type !== CipherType.Login ||
|
||||
totpPromise ||
|
||||
!options.cipher.login.totp ||
|
||||
(!canAccessPremium && !options.cipher.organizationUseTotp)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
totpPromise = this.stateService.getDisableAutoTotpCopy().then((disabled) => {
|
||||
if (!disabled) {
|
||||
return this.totpService.getCode(options.cipher.login.totp);
|
||||
await Promise.all(
|
||||
options.pageDetails.map(async (pd) => {
|
||||
// make sure we're still on correct tab
|
||||
if (pd.tab.id !== tab.id || pd.tab.url !== tab.url) {
|
||||
return;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
const fillScript = await this.generateFillScript(pd.details, {
|
||||
skipUsernameOnlyFill: options.skipUsernameOnlyFill || false,
|
||||
onlyEmptyFields: options.onlyEmptyFields || false,
|
||||
onlyVisibleFields: options.onlyVisibleFields || false,
|
||||
fillNewPassword: options.fillNewPassword || false,
|
||||
allowTotpAutofill: options.allowTotpAutofill || false,
|
||||
cipher: options.cipher,
|
||||
tabUrl: tab.url,
|
||||
defaultUriMatch: defaultUriMatch,
|
||||
});
|
||||
|
||||
if (!fillScript || !fillScript.script || !fillScript.script.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
fillScript.untrustedIframe &&
|
||||
options.allowUntrustedIframe != undefined &&
|
||||
!options.allowUntrustedIframe
|
||||
) {
|
||||
this.logService.info("Auto-fill on page load was blocked due to an untrusted iframe.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Add a small delay between operations
|
||||
fillScript.properties.delay_between_operations = 20;
|
||||
|
||||
didAutofill = true;
|
||||
if (!options.skipLastUsed) {
|
||||
this.cipherService.updateLastUsedDate(options.cipher.id);
|
||||
}
|
||||
|
||||
BrowserApi.tabSendMessage(
|
||||
tab,
|
||||
{
|
||||
command: "fillForm",
|
||||
fillScript: fillScript,
|
||||
url: tab.url,
|
||||
},
|
||||
{ frameId: pd.frameId }
|
||||
);
|
||||
|
||||
if (
|
||||
options.cipher.type !== CipherType.Login ||
|
||||
totpPromise ||
|
||||
!options.cipher.login.totp ||
|
||||
(!canAccessPremium && !options.cipher.organizationUseTotp)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
totpPromise = this.stateService.getDisableAutoTotpCopy().then((disabled) => {
|
||||
if (!disabled) {
|
||||
return this.totpService.getCode(options.cipher.login.totp);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
if (didAutofill) {
|
||||
this.eventCollectionService.collect(EventType.Cipher_ClientAutofilled, options.cipher.id);
|
||||
@ -244,6 +248,7 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
onlyVisibleFields: !fromCommand,
|
||||
fillNewPassword: fromCommand,
|
||||
allowUntrustedIframe: fromCommand,
|
||||
allowTotpAutofill: fromCommand,
|
||||
});
|
||||
|
||||
// Update last used index as autofill has succeed
|
||||
@ -280,10 +285,10 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
return tab;
|
||||
}
|
||||
|
||||
private generateFillScript(
|
||||
private async generateFillScript(
|
||||
pageDetails: AutofillPageDetails,
|
||||
options: GenerateFillScriptOptions
|
||||
): AutofillScript {
|
||||
): Promise<AutofillScript> {
|
||||
if (!pageDetails || !options.cipher) {
|
||||
return null;
|
||||
}
|
||||
@ -333,7 +338,12 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
|
||||
switch (options.cipher.type) {
|
||||
case CipherType.Login:
|
||||
fillScript = this.generateLoginFillScript(fillScript, pageDetails, filledFields, options);
|
||||
fillScript = await this.generateLoginFillScript(
|
||||
fillScript,
|
||||
pageDetails,
|
||||
filledFields,
|
||||
options
|
||||
);
|
||||
break;
|
||||
case CipherType.Card:
|
||||
fillScript = this.generateCardFillScript(fillScript, pageDetails, filledFields, options);
|
||||
@ -353,20 +363,22 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
return fillScript;
|
||||
}
|
||||
|
||||
private generateLoginFillScript(
|
||||
private async generateLoginFillScript(
|
||||
fillScript: AutofillScript,
|
||||
pageDetails: AutofillPageDetails,
|
||||
filledFields: { [id: string]: AutofillField },
|
||||
options: GenerateFillScriptOptions
|
||||
): AutofillScript {
|
||||
): Promise<AutofillScript> {
|
||||
if (!options.cipher.login) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const passwords: AutofillField[] = [];
|
||||
const usernames: AutofillField[] = [];
|
||||
const totps: AutofillField[] = [];
|
||||
let pf: AutofillField = null;
|
||||
let username: AutofillField = null;
|
||||
let totp: AutofillField = null;
|
||||
const login = options.cipher.login;
|
||||
fillScript.savedUrls =
|
||||
login?.uris?.filter((u) => u.match != UriMatchType.Never).map((u) => u.uri) ?? [];
|
||||
@ -420,6 +432,19 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
usernames.push(username);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.allowTotpAutofill && login.totp) {
|
||||
totp = this.findTotpField(pageDetails, pf, false, false, false);
|
||||
|
||||
if (!totp && !options.onlyVisibleFields) {
|
||||
// not able to find any viewable totp fields. maybe there are some "hidden" ones?
|
||||
totp = this.findTotpField(pageDetails, pf, true, true, true);
|
||||
}
|
||||
|
||||
if (totp) {
|
||||
totps.push(totp);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -442,18 +467,42 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
usernames.push(username);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.allowTotpAutofill && login.totp && pf.elementNumber > 0) {
|
||||
totp = this.findTotpField(pageDetails, pf, false, false, true);
|
||||
|
||||
if (!totp && !options.onlyVisibleFields) {
|
||||
// not able to find any viewable username fields. maybe there are some "hidden" ones?
|
||||
totp = this.findTotpField(pageDetails, pf, true, true, true);
|
||||
}
|
||||
|
||||
if (totp) {
|
||||
totps.push(totp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!passwordFields.length && !options.skipUsernameOnlyFill) {
|
||||
if (!passwordFields.length) {
|
||||
// No password fields on this page. Let's try to just fuzzy fill the username.
|
||||
pageDetails.fields.forEach((f) => {
|
||||
if (
|
||||
!options.skipUsernameOnlyFill &&
|
||||
f.viewable &&
|
||||
(f.type === "text" || f.type === "email" || f.type === "tel") &&
|
||||
AutofillService.fieldIsFuzzyMatch(f, AutoFillConstants.UsernameFieldNames)
|
||||
) {
|
||||
usernames.push(f);
|
||||
}
|
||||
|
||||
if (
|
||||
options.allowTotpAutofill &&
|
||||
f.viewable &&
|
||||
(f.type === "text" || f.type === "number") &&
|
||||
(AutofillService.fieldIsFuzzyMatch(f, AutoFillConstants.TotpFieldNames) ||
|
||||
f.autoCompleteType === "one-time-code")
|
||||
) {
|
||||
totps.push(f);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -477,6 +526,20 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
AutofillService.fillByOpid(fillScript, p, login.password);
|
||||
});
|
||||
|
||||
if (options.allowTotpAutofill) {
|
||||
await Promise.all(
|
||||
totps.map(async (t) => {
|
||||
if (Object.prototype.hasOwnProperty.call(filledFields, t.opid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
filledFields[t.opid] = t;
|
||||
const totpValue = await this.totpService.getCode(login.totp);
|
||||
AutofillService.fillByOpid(fillScript, t, totpValue);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fillScript = AutofillService.setFillScriptForFocus(filledFields, fillScript);
|
||||
return fillScript;
|
||||
}
|
||||
@ -1258,6 +1321,42 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
return usernameField;
|
||||
}
|
||||
|
||||
private findTotpField(
|
||||
pageDetails: AutofillPageDetails,
|
||||
passwordField: AutofillField,
|
||||
canBeHidden: boolean,
|
||||
canBeReadOnly: boolean,
|
||||
withoutForm: boolean
|
||||
) {
|
||||
let totpField: AutofillField = null;
|
||||
for (let i = 0; i < pageDetails.fields.length; i++) {
|
||||
const f = pageDetails.fields[i];
|
||||
if (AutofillService.forCustomFieldsOnly(f)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
!f.disabled &&
|
||||
(canBeReadOnly || !f.readonly) &&
|
||||
(withoutForm || f.form === passwordField.form) &&
|
||||
(canBeHidden || f.viewable) &&
|
||||
(f.type === "text" || f.type === "number")
|
||||
) {
|
||||
totpField = f;
|
||||
|
||||
if (
|
||||
this.findMatchingFieldIndex(f, AutoFillConstants.TotpFieldNames) > -1 ||
|
||||
f.autoCompleteType === "one-time-code"
|
||||
) {
|
||||
// We found an exact match. No need to keep looking.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return totpField;
|
||||
}
|
||||
|
||||
private findMatchingFieldIndex(field: AutofillField, names: string[]): number {
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
if (names[i].indexOf("=") > -1) {
|
||||
@ -1267,6 +1366,12 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
if (this.fieldPropertyIsPrefixMatch(field, "htmlName", names[i], "name")) {
|
||||
return i;
|
||||
}
|
||||
if (this.fieldPropertyIsPrefixMatch(field, "label-left", names[i], "label")) {
|
||||
return i;
|
||||
}
|
||||
if (this.fieldPropertyIsPrefixMatch(field, "label-right", names[i], "label")) {
|
||||
return i;
|
||||
}
|
||||
if (this.fieldPropertyIsPrefixMatch(field, "label-tag", names[i], "label")) {
|
||||
return i;
|
||||
}
|
||||
@ -1284,6 +1389,12 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
if (this.fieldPropertyIsMatch(field, "htmlName", names[i])) {
|
||||
return i;
|
||||
}
|
||||
if (this.fieldPropertyIsMatch(field, "label-left", names[i])) {
|
||||
return i;
|
||||
}
|
||||
if (this.fieldPropertyIsMatch(field, "label-right", names[i])) {
|
||||
return i;
|
||||
}
|
||||
if (this.fieldPropertyIsMatch(field, "label-tag", names[i])) {
|
||||
return i;
|
||||
}
|
||||
|
@ -224,6 +224,7 @@ export default class RuntimeBackground {
|
||||
cipher: this.main.loginToAutoFill,
|
||||
pageDetails: this.pageDetailsToAutoFill,
|
||||
fillNewPassword: true,
|
||||
allowTotpAutofill: true,
|
||||
});
|
||||
|
||||
if (totpCode != null) {
|
||||
|
@ -180,6 +180,7 @@ export class CurrentTabComponent implements OnInit, OnDestroy {
|
||||
pageDetails: this.pageDetails,
|
||||
doc: window.document,
|
||||
fillNewPassword: true,
|
||||
allowTotpAutofill: true,
|
||||
});
|
||||
if (this.totpCode != null) {
|
||||
this.platformUtilsService.copyToClipboard(this.totpCode, { window: window });
|
||||
|
@ -288,6 +288,7 @@ export class ViewComponent extends BaseViewComponent {
|
||||
pageDetails: this.pageDetails,
|
||||
doc: window.document,
|
||||
fillNewPassword: true,
|
||||
allowTotpAutofill: true,
|
||||
});
|
||||
if (this.totpCode != null) {
|
||||
this.platformUtilsService.copyToClipboard(this.totpCode, { window: window });
|
||||
|
Loading…
Reference in New Issue
Block a user