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,
|
onlyEmptyFields: false,
|
||||||
onlyVisibleFields: false,
|
onlyVisibleFields: false,
|
||||||
fillNewPassword: true,
|
fillNewPassword: true,
|
||||||
|
allowTotpAutofill: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
@ -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",
|
||||||
|
@ -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,69 +128,72 @@ 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(
|
||||||
// make sure we're still on correct tab
|
options.pageDetails.map(async (pd) => {
|
||||||
if (pd.tab.id !== tab.id || pd.tab.url !== tab.url) {
|
// make sure we're still on correct tab
|
||||||
return;
|
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);
|
|
||||||
}
|
}
|
||||||
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) {
|
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 (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.
|
// 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;
|
||||||
}
|
}
|
||||||
|
@ -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) {
|
||||||
|
@ -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 });
|
||||||
|
@ -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 });
|
||||||
|
Loading…
Reference in New Issue
Block a user