1
0
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:
Andrew Dassonville 2023-06-21 08:18:46 -07:00 committed by GitHub
parent 4124f7bdc8
commit 4dc34fc7a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 195 additions and 68 deletions

View File

@ -46,6 +46,7 @@ export class AutofillTabCommand {
onlyEmptyFields: false, onlyEmptyFields: false,
onlyVisibleFields: false, onlyVisibleFields: false,
fillNewPassword: true, fillNewPassword: true,
allowTotpAutofill: true,
}); });
} }

View File

@ -21,6 +21,7 @@ export interface AutoFillOptions {
fillNewPassword?: boolean; fillNewPassword?: boolean;
skipLastUsed?: boolean; skipLastUsed?: boolean;
allowUntrustedIframe?: boolean; allowUntrustedIframe?: boolean;
allowTotpAutofill?: boolean;
} }
export interface FormData { export interface FormData {

View File

@ -20,6 +20,17 @@ export class AutoFillConstants {
"benutzer id", "benutzer id",
]; ];
static readonly TotpFieldNames: string[] = [
"totp",
"2fa",
"mfa",
"totpcode",
"2facode",
"mfacode",
"twofactor",
"twofactorcode",
];
static readonly PasswordFieldIgnoreList: string[] = [ static readonly PasswordFieldIgnoreList: string[] = [
"onetimepassword", "onetimepassword",
"captcha", "captcha",

View File

@ -32,6 +32,7 @@ export interface GenerateFillScriptOptions {
onlyEmptyFields: boolean; onlyEmptyFields: boolean;
onlyVisibleFields: boolean; onlyVisibleFields: boolean;
fillNewPassword: boolean; fillNewPassword: boolean;
allowTotpAutofill: boolean;
cipher: CipherView; cipher: CipherView;
tabUrl: string; tabUrl: string;
defaultUriMatch: UriMatchType; defaultUriMatch: UriMatchType;
@ -127,17 +128,19 @@ export default class AutofillService implements AutofillServiceInterface {
const defaultUriMatch = (await this.stateService.getDefaultUriMatch()) ?? UriMatchType.Domain; const defaultUriMatch = (await this.stateService.getDefaultUriMatch()) ?? UriMatchType.Domain;
let didAutofill = false; let didAutofill = false;
options.pageDetails.forEach((pd) => { await Promise.all(
options.pageDetails.map(async (pd) => {
// make sure we're still on correct tab // make sure we're still on correct tab
if (pd.tab.id !== tab.id || pd.tab.url !== tab.url) { if (pd.tab.id !== tab.id || pd.tab.url !== tab.url) {
return; return;
} }
const fillScript = this.generateFillScript(pd.details, { const fillScript = await this.generateFillScript(pd.details, {
skipUsernameOnlyFill: options.skipUsernameOnlyFill || false, skipUsernameOnlyFill: options.skipUsernameOnlyFill || false,
onlyEmptyFields: options.onlyEmptyFields || false, onlyEmptyFields: options.onlyEmptyFields || false,
onlyVisibleFields: options.onlyVisibleFields || false, onlyVisibleFields: options.onlyVisibleFields || false,
fillNewPassword: options.fillNewPassword || false, fillNewPassword: options.fillNewPassword || false,
allowTotpAutofill: options.allowTotpAutofill || false,
cipher: options.cipher, cipher: options.cipher,
tabUrl: tab.url, tabUrl: tab.url,
defaultUriMatch: defaultUriMatch, defaultUriMatch: defaultUriMatch,
@ -189,7 +192,8 @@ export default class AutofillService implements AutofillServiceInterface {
} }
return null; return null;
}); });
}); })
);
if (didAutofill) { if (didAutofill) {
this.eventCollectionService.collect(EventType.Cipher_ClientAutofilled, options.cipher.id); this.eventCollectionService.collect(EventType.Cipher_ClientAutofilled, options.cipher.id);
@ -244,6 +248,7 @@ export default class AutofillService implements AutofillServiceInterface {
onlyVisibleFields: !fromCommand, onlyVisibleFields: !fromCommand,
fillNewPassword: fromCommand, fillNewPassword: fromCommand,
allowUntrustedIframe: fromCommand, allowUntrustedIframe: fromCommand,
allowTotpAutofill: fromCommand,
}); });
// Update last used index as autofill has succeed // Update last used index as autofill has succeed
@ -280,10 +285,10 @@ export default class AutofillService implements AutofillServiceInterface {
return tab; return tab;
} }
private generateFillScript( private async generateFillScript(
pageDetails: AutofillPageDetails, pageDetails: AutofillPageDetails,
options: GenerateFillScriptOptions options: GenerateFillScriptOptions
): AutofillScript { ): Promise<AutofillScript> {
if (!pageDetails || !options.cipher) { if (!pageDetails || !options.cipher) {
return null; return null;
} }
@ -333,7 +338,12 @@ export default class AutofillService implements AutofillServiceInterface {
switch (options.cipher.type) { switch (options.cipher.type) {
case CipherType.Login: case CipherType.Login:
fillScript = this.generateLoginFillScript(fillScript, pageDetails, filledFields, options); fillScript = await this.generateLoginFillScript(
fillScript,
pageDetails,
filledFields,
options
);
break; break;
case CipherType.Card: case CipherType.Card:
fillScript = this.generateCardFillScript(fillScript, pageDetails, filledFields, options); fillScript = this.generateCardFillScript(fillScript, pageDetails, filledFields, options);
@ -353,20 +363,22 @@ export default class AutofillService implements AutofillServiceInterface {
return fillScript; return fillScript;
} }
private generateLoginFillScript( private async generateLoginFillScript(
fillScript: AutofillScript, fillScript: AutofillScript,
pageDetails: AutofillPageDetails, pageDetails: AutofillPageDetails,
filledFields: { [id: string]: AutofillField }, filledFields: { [id: string]: AutofillField },
options: GenerateFillScriptOptions options: GenerateFillScriptOptions
): AutofillScript { ): Promise<AutofillScript> {
if (!options.cipher.login) { if (!options.cipher.login) {
return null; return null;
} }
const passwords: AutofillField[] = []; const passwords: AutofillField[] = [];
const usernames: AutofillField[] = []; const usernames: AutofillField[] = [];
const totps: AutofillField[] = [];
let pf: AutofillField = null; let pf: AutofillField = null;
let username: AutofillField = null; let username: AutofillField = null;
let totp: AutofillField = null;
const login = options.cipher.login; const login = options.cipher.login;
fillScript.savedUrls = fillScript.savedUrls =
login?.uris?.filter((u) => u.match != UriMatchType.Never).map((u) => u.uri) ?? []; 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); 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); 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 (!passwordFields.length && !options.skipUsernameOnlyFill) { if (totp) {
totps.push(totp);
}
}
}
if (!passwordFields.length) {
// No password fields on this page. Let's try to just fuzzy fill the username. // No password fields on this page. Let's try to just fuzzy fill the username.
pageDetails.fields.forEach((f) => { pageDetails.fields.forEach((f) => {
if ( if (
!options.skipUsernameOnlyFill &&
f.viewable && f.viewable &&
(f.type === "text" || f.type === "email" || f.type === "tel") && (f.type === "text" || f.type === "email" || f.type === "tel") &&
AutofillService.fieldIsFuzzyMatch(f, AutoFillConstants.UsernameFieldNames) AutofillService.fieldIsFuzzyMatch(f, AutoFillConstants.UsernameFieldNames)
) { ) {
usernames.push(f); 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); 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); fillScript = AutofillService.setFillScriptForFocus(filledFields, fillScript);
return fillScript; return fillScript;
} }
@ -1258,6 +1321,42 @@ export default class AutofillService implements AutofillServiceInterface {
return usernameField; 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 { private findMatchingFieldIndex(field: AutofillField, names: string[]): number {
for (let i = 0; i < names.length; i++) { for (let i = 0; i < names.length; i++) {
if (names[i].indexOf("=") > -1) { if (names[i].indexOf("=") > -1) {
@ -1267,6 +1366,12 @@ export default class AutofillService implements AutofillServiceInterface {
if (this.fieldPropertyIsPrefixMatch(field, "htmlName", names[i], "name")) { if (this.fieldPropertyIsPrefixMatch(field, "htmlName", names[i], "name")) {
return i; 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")) { if (this.fieldPropertyIsPrefixMatch(field, "label-tag", names[i], "label")) {
return i; return i;
} }
@ -1284,6 +1389,12 @@ export default class AutofillService implements AutofillServiceInterface {
if (this.fieldPropertyIsMatch(field, "htmlName", names[i])) { if (this.fieldPropertyIsMatch(field, "htmlName", names[i])) {
return 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])) { if (this.fieldPropertyIsMatch(field, "label-tag", names[i])) {
return i; return i;
} }

View File

@ -224,6 +224,7 @@ export default class RuntimeBackground {
cipher: this.main.loginToAutoFill, cipher: this.main.loginToAutoFill,
pageDetails: this.pageDetailsToAutoFill, pageDetails: this.pageDetailsToAutoFill,
fillNewPassword: true, fillNewPassword: true,
allowTotpAutofill: true,
}); });
if (totpCode != null) { if (totpCode != null) {

View File

@ -180,6 +180,7 @@ export class CurrentTabComponent implements OnInit, OnDestroy {
pageDetails: this.pageDetails, pageDetails: this.pageDetails,
doc: window.document, doc: window.document,
fillNewPassword: true, fillNewPassword: true,
allowTotpAutofill: true,
}); });
if (this.totpCode != null) { if (this.totpCode != null) {
this.platformUtilsService.copyToClipboard(this.totpCode, { window: window }); this.platformUtilsService.copyToClipboard(this.totpCode, { window: window });

View File

@ -288,6 +288,7 @@ export class ViewComponent extends BaseViewComponent {
pageDetails: this.pageDetails, pageDetails: this.pageDetails,
doc: window.document, doc: window.document,
fillNewPassword: true, fillNewPassword: true,
allowTotpAutofill: true,
}); });
if (this.totpCode != null) { if (this.totpCode != null) {
this.platformUtilsService.copyToClipboard(this.totpCode, { window: window }); this.platformUtilsService.copyToClipboard(this.totpCode, { window: window });