From ec3e6a81a432c5549da2bf21b6bfeb642726a7e6 Mon Sep 17 00:00:00 2001 From: Maja Komel Date: Thu, 28 Jun 2018 10:12:32 +0200 Subject: [PATCH] FEATURE: Second factor backup --- .../discourse/components/backup-codes.js.es6 | 55 +++++++ .../components/second-factor-form.js.es6 | 37 +++++ .../components/second-factor-input.js.es6 | 23 +++ .../discourse/controllers/login.js.es6 | 9 +- .../controllers/password-reset.js.es6 | 8 +- .../preferences/second-factor-backup.js.es6 | 109 +++++++++++++ .../preferences/second-factor.js.es6 | 2 +- .../javascripts/discourse/models/user.js.es6 | 17 +- .../discourse/routes/app-route-map.js.es6 | 1 + .../preferences-second-factor-backup.js.es6 | 19 +++ .../templates/components/backup-codes.hbs | 15 ++ .../components/second-factor-form.hbs | 24 +-- .../components/second-factor-input.hbs | 7 +- .../templates/mobile/modal/login.hbs | 6 +- .../discourse/templates/modal/login.hbs | 6 +- .../discourse/templates/password-reset.hbs | 8 +- .../preferences-second-factor-backup.hbs | 61 +++++++ .../templates/preferences/account.hbs | 16 ++ app/assets/stylesheets/common/base/user.scss | 62 +++++++ app/assets/stylesheets/desktop/login.scss | 3 + app/assets/stylesheets/mobile/login.scss | 3 + app/controllers/admin/users_controller.rb | 6 +- app/controllers/session_controller.rb | 12 +- app/controllers/users_controller.rb | 62 +++++-- app/controllers/users_email_controller.rb | 3 +- app/models/concerns/second_factor_manager.rb | 85 +++++++++- app/models/user.rb | 2 +- app/models/user_second_factor.rb | 7 + app/serializers/user_serializer.rb | 11 +- .../_second_factor_backup_input.html.erb | 2 + .../_second_factor_form_script.html.erb | 18 +++ .../common/_second_factor_text_field.html.erb | 1 + app/views/session/email_login.html.erb | 19 ++- app/views/users/admin_login.html.erb | 24 ++- app/views/users_email/confirm.html.erb | 35 ++-- config/locales/client.en.yml | 17 ++ config/locales/server.en.yml | 7 +- config/routes.rb | 3 + lib/admin_user_index_query.rb | 2 +- .../concern/second_factor_manager_spec.rb | 102 ++++++++++-- .../user_second_factor_fabricator.rb | 10 +- spec/models/user_second_factor_spec.rb | 1 + spec/requests/admin/users_controller_spec.rb | 8 +- spec/requests/session_controller_spec.rb | 142 ++++++++++++---- spec/requests/users_controller_spec.rb | 153 ++++++++++++++++-- spec/requests/users_email_controller_spec.rb | 8 +- spec/services/user_merger_spec.rb | 2 +- .../acceptance/password-reset-test.js.es6 | 6 +- .../acceptance/preferences-test.js.es6 | 23 +++ .../acceptance/sign-in-test.js.es6 | 36 +++++ .../helpers/create-pretender.js.es6 | 3 +- 51 files changed, 1148 insertions(+), 153 deletions(-) create mode 100644 app/assets/javascripts/discourse/components/backup-codes.js.es6 create mode 100644 app/assets/javascripts/discourse/components/second-factor-form.js.es6 create mode 100644 app/assets/javascripts/discourse/components/second-factor-input.js.es6 create mode 100644 app/assets/javascripts/discourse/controllers/preferences/second-factor-backup.js.es6 create mode 100644 app/assets/javascripts/discourse/routes/preferences-second-factor-backup.js.es6 create mode 100644 app/assets/javascripts/discourse/templates/components/backup-codes.hbs create mode 100644 app/assets/javascripts/discourse/templates/preferences-second-factor-backup.hbs create mode 100644 app/views/common/_second_factor_backup_input.html.erb create mode 100644 app/views/common/_second_factor_form_script.html.erb diff --git a/app/assets/javascripts/discourse/components/backup-codes.js.es6 b/app/assets/javascripts/discourse/components/backup-codes.js.es6 new file mode 100644 index 00000000000..11457eb7369 --- /dev/null +++ b/app/assets/javascripts/discourse/components/backup-codes.js.es6 @@ -0,0 +1,55 @@ +import computed from "ember-addons/ember-computed-decorators"; + +// https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding +function b64EncodeUnicode(str) { + return btoa( + encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function toSolidBytes( + match, + p1 + ) { + return String.fromCharCode("0x" + p1); + }) + ); +} + +export default Ember.Component.extend({ + classNames: ["backup-codes"], + backupCodes: null, + + click(event) { + if (event.target.id === "backupCodes") { + this._selectAllBackupCodes(); + } + }, + + didRender() { + this._super(); + + const $backupCodes = this.$("#backupCodes"); + if ($backupCodes.length) { + $backupCodes.height($backupCodes[0].scrollHeight); + } + }, + + @computed("formattedBackupCodes") base64BackupCode: b64EncodeUnicode, + + @computed("backupCodes") + formattedBackupCodes(backupCodes) { + if (!backupCodes) return null; + + return backupCodes.join("\n").trim(); + }, + + actions: { + copyToClipboard() { + this._selectAllBackupCodes(); + this.get("copyBackupCode")(document.execCommand("copy")); + } + }, + + _selectAllBackupCodes() { + const $textArea = this.$("#backupCodes"); + $textArea[0].focus(); + $textArea[0].setSelectionRange(0, this.get("formattedBackupCodes").length); + } +}); diff --git a/app/assets/javascripts/discourse/components/second-factor-form.js.es6 b/app/assets/javascripts/discourse/components/second-factor-form.js.es6 new file mode 100644 index 00000000000..f930a4cb39b --- /dev/null +++ b/app/assets/javascripts/discourse/components/second-factor-form.js.es6 @@ -0,0 +1,37 @@ +import computed from "ember-addons/ember-computed-decorators"; +import { SECOND_FACTOR_METHODS } from "discourse/models/user"; + +export default Ember.Component.extend({ + @computed("secondFactorMethod") + secondFactorTitle(secondFactorMethod) { + return secondFactorMethod === SECOND_FACTOR_METHODS.TOTP + ? I18n.t("login.second_factor_title") + : I18n.t("login.second_factor_backup_title"); + }, + + @computed("secondFactorMethod") + secondFactorDescription(secondFactorMethod) { + return secondFactorMethod === SECOND_FACTOR_METHODS.TOTP + ? I18n.t("login.second_factor_description") + : I18n.t("login.second_factor_backup_description"); + }, + + @computed("secondFactorMethod") + linkText(secondFactorMethod) { + return secondFactorMethod === SECOND_FACTOR_METHODS.TOTP + ? "login.second_factor_backup" + : "login.second_factor"; + }, + + actions: { + toggleSecondFactorMethod() { + const secondFactorMethod = this.get("secondFactorMethod"); + this.set("loginSecondFactor", ""); + if (secondFactorMethod === SECOND_FACTOR_METHODS.TOTP) { + this.set("secondFactorMethod", SECOND_FACTOR_METHODS.BACKUP_CODE); + } else { + this.set("secondFactorMethod", SECOND_FACTOR_METHODS.TOTP); + } + } + } +}); diff --git a/app/assets/javascripts/discourse/components/second-factor-input.js.es6 b/app/assets/javascripts/discourse/components/second-factor-input.js.es6 new file mode 100644 index 00000000000..00e27039a41 --- /dev/null +++ b/app/assets/javascripts/discourse/components/second-factor-input.js.es6 @@ -0,0 +1,23 @@ +import computed from "ember-addons/ember-computed-decorators"; +import { SECOND_FACTOR_METHODS } from "discourse/models/user"; + +export default Ember.Component.extend({ + @computed("secondFactorMethod") + type(secondFactorMethod) { + if (secondFactorMethod === SECOND_FACTOR_METHODS.TOTP) return "tel"; + if (secondFactorMethod === SECOND_FACTOR_METHODS.BACKUP_CODE) return "text"; + }, + + @computed("secondFactorMethod") + pattern(secondFactorMethod) { + if (secondFactorMethod === SECOND_FACTOR_METHODS.TOTP) return "[0-9]{6}"; + if (secondFactorMethod === SECOND_FACTOR_METHODS.BACKUP_CODE) + return "[a-z0-9]{16}"; + }, + + @computed("secondFactorMethod") + maxlength(secondFactorMethod) { + if (secondFactorMethod === SECOND_FACTOR_METHODS.TOTP) return "6"; + if (secondFactorMethod === SECOND_FACTOR_METHODS.BACKUP_CODE) return "16"; + } +}); diff --git a/app/assets/javascripts/discourse/controllers/login.js.es6 b/app/assets/javascripts/discourse/controllers/login.js.es6 index 747425b7870..2a52c8f7c03 100644 --- a/app/assets/javascripts/discourse/controllers/login.js.es6 +++ b/app/assets/javascripts/discourse/controllers/login.js.es6 @@ -7,6 +7,7 @@ import { escape } from "pretty-text/sanitizer"; import { escapeExpression } from "discourse/lib/utilities"; import { extractError } from "discourse/lib/ajax-error"; import computed from "ember-addons/ember-computed-decorators"; +import { SECOND_FACTOR_METHODS } from "discourse/models/user"; // This is happening outside of the app via popup const AuthErrors = [ @@ -31,6 +32,7 @@ export default Ember.Controller.extend(ModalFunctionality, { canLoginLocal: setting("enable_local_logins"), canLoginLocalWithEmail: setting("enable_local_logins_via_email"), loginRequired: Em.computed.alias("application.loginRequired"), + secondFactorMethod: SECOND_FACTOR_METHODS.TOTP, resetForm: function() { this.setProperties({ @@ -103,14 +105,14 @@ export default Ember.Controller.extend(ModalFunctionality, { data: { login: this.get("loginName"), password: this.get("loginPassword"), - second_factor_token: this.get("loginSecondFactor") + second_factor_token: this.get("loginSecondFactor"), + second_factor_method: this.get("secondFactorMethod") } }).then( function(result) { // Successful login if (result && result.error) { self.set("loggingIn", false); - if ( result.reason === "invalid_second_factor" && !self.get("secondFactorRequired") @@ -118,7 +120,8 @@ export default Ember.Controller.extend(ModalFunctionality, { $("#modal-alert").hide(); self.setProperties({ secondFactorRequired: true, - showLoginButtons: false + showLoginButtons: false, + backupEnabled: result.backup_enabled }); $("#credentials").hide(); diff --git a/app/assets/javascripts/discourse/controllers/password-reset.js.es6 b/app/assets/javascripts/discourse/controllers/password-reset.js.es6 index 2b8a7f28d54..38ca5664308 100644 --- a/app/assets/javascripts/discourse/controllers/password-reset.js.es6 +++ b/app/assets/javascripts/discourse/controllers/password-reset.js.es6 @@ -4,11 +4,14 @@ import DiscourseURL from "discourse/lib/url"; import { ajax } from "discourse/lib/ajax"; import PasswordValidation from "discourse/mixins/password-validation"; import { userPath } from "discourse/lib/url"; +import { SECOND_FACTOR_METHODS } from "discourse/models/user"; export default Ember.Controller.extend(PasswordValidation, { isDeveloper: Ember.computed.alias("model.is_developer"), admin: Ember.computed.alias("model.admin"), secondFactorRequired: Ember.computed.alias("model.second_factor_required"), + backupEnabled: Ember.computed.alias("model.second_factor_backup_enabled"), + secondFactorMethod: SECOND_FACTOR_METHODS.TOTP, passwordRequired: true, errorMessage: null, successMessage: null, @@ -36,7 +39,8 @@ export default Ember.Controller.extend(PasswordValidation, { type: "PUT", data: { password: this.get("accountPassword"), - second_factor_token: this.get("secondFactor") + second_factor_token: this.get("secondFactor"), + second_factor_method: this.get("secondFactorMethod") } }) .then(result => { @@ -50,7 +54,7 @@ export default Ember.Controller.extend(PasswordValidation, { DiscourseURL.redirectTo(result.redirect_to || "/"); } } else { - if (result.errors && result.errors.user_second_factor) { + if (result.errors && result.errors.user_second_factors) { this.setProperties({ secondFactorRequired: true, password: null, diff --git a/app/assets/javascripts/discourse/controllers/preferences/second-factor-backup.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/second-factor-backup.js.es6 new file mode 100644 index 00000000000..08bab005baa --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/preferences/second-factor-backup.js.es6 @@ -0,0 +1,109 @@ +import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as DiscourseURL, userPath } from "discourse/lib/url"; +import { popupAjaxError } from "discourse/lib/ajax-error"; + +export default Ember.Controller.extend({ + loading: false, + errorMessage: null, + successMessage: null, + backupEnabled: Ember.computed.alias("model.second_factor_backup_enabled"), + backupCodes: null, + + @computed("secondFactorToken") + isValidSecondFactorToken(secondFactorToken) { + return secondFactorToken && secondFactorToken.length === 6; + }, + + @computed("isValidSecondFactorToken", "backupEnabled", "loading") + isDisabledGenerateBackupCodeBtn(isValid, backupEnabled, loading) { + return !isValid || loading; + }, + + @computed("isValidSecondFactorToken", "backupEnabled", "loading") + isDisabledDisableBackupCodeBtn(isValid, backupEnabled, loading) { + return !isValid || !backupEnabled || loading; + }, + + @computed("backupEnabled") + generateBackupCodeBtnLabel(backupEnabled) { + return backupEnabled + ? "user.second_factor_backup.regenerate" + : "user.second_factor_backup.enable"; + }, + + actions: { + copyBackupCode(successful) { + if (successful) { + this.set( + "successMessage", + I18n.t("user.second_factor_backup.copied_to_clipboard") + ); + } else { + this.set( + "errorMessage", + I18n.t("user.second_factor_backup.copy_to_clipboard_error") + ); + } + + this._hideCopyMessage(); + }, + + disableSecondFactorBackup() { + this.set("backupCodes", []); + + if (!this.get("secondFactorToken")) return; + + this.set("loading", true); + + this.get("content") + .toggleSecondFactor(this.get("secondFactorToken"), false, 2) + .then(response => { + if (response.error) { + this.set("errorMessage", response.error); + return; + } + + this.set("errorMessage", null); + + const usernameLower = this.get("content").username.toLowerCase(); + DiscourseURL.redirectTo(userPath(`${usernameLower}/preferences`)); + }) + .catch(popupAjaxError) + .finally(() => this.set("loading", false)); + }, + + generateSecondFactorCodes() { + if (!this.get("secondFactorToken")) return; + const model = this.get("model"); + this.set("loading", true); + this.get("content") + .generateSecondFactorCodes(this.get("secondFactorToken")) + .then(response => { + if (response.error) { + this.set("errorMessage", response.error); + return; + } + + this.setProperties({ + errorMessage: null, + backupCodes: response.backup_codes + }); + model.set("second_factor_backup_enabled", true); + }) + .catch(popupAjaxError) + .finally(() => { + this.setProperties({ + loading: false, + secondFactorToken: null + }); + }); + } + }, + + _hideCopyMessage() { + Ember.run.later( + () => this.setProperties({ successMessage: null, errorMessage: null }), + 2000 + ); + } +}); diff --git a/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 index 580467095a4..809f80278d1 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 @@ -43,7 +43,7 @@ export default Ember.Controller.extend({ this.set("loading", true); this.get("content") - .toggleSecondFactor(this.get("secondFactorToken"), enable) + .toggleSecondFactor(this.get("secondFactorToken"), enable, 1) .then(response => { if (response.error) { this.set("errorMessage", response.error); diff --git a/app/assets/javascripts/discourse/models/user.js.es6 b/app/assets/javascripts/discourse/models/user.js.es6 index b75d44bf701..964b37be865 100644 --- a/app/assets/javascripts/discourse/models/user.js.es6 +++ b/app/assets/javascripts/discourse/models/user.js.es6 @@ -19,6 +19,8 @@ import PreloadStore from "preload-store"; import { defaultHomepage } from "discourse/lib/utilities"; import { userPath } from "discourse/lib/url"; +export const SECOND_FACTOR_METHODS = { TOTP: 1, BACKUP_CODE: 2 }; + const isForever = dt => moment().diff(dt, "years") < -500; const User = RestModel.extend({ @@ -352,9 +354,20 @@ const User = RestModel.extend({ }); }, - toggleSecondFactor(token, enable) { + toggleSecondFactor(token, enable, method) { return ajax("/u/second_factor.json", { - data: { second_factor_token: token, enable }, + data: { + second_factor_token: token, + second_factor_method: method, + enable + }, + type: "PUT" + }); + }, + + generateSecondFactorCodes(token) { + return ajax("/u/second_factors_backup.json", { + data: { second_factor_token: token }, type: "PUT" }); }, diff --git a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 index cc44d4ecbc5..63c5a2a12e3 100644 --- a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 +++ b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 @@ -156,6 +156,7 @@ export default function() { this.route("username"); this.route("email"); this.route("second-factor"); + this.route("second-factor-backup"); this.route("about", { path: "/about-me" }); }); diff --git a/app/assets/javascripts/discourse/routes/preferences-second-factor-backup.js.es6 b/app/assets/javascripts/discourse/routes/preferences-second-factor-backup.js.es6 new file mode 100644 index 00000000000..7bd190227ed --- /dev/null +++ b/app/assets/javascripts/discourse/routes/preferences-second-factor-backup.js.es6 @@ -0,0 +1,19 @@ +import RestrictedUserRoute from "discourse/routes/restricted-user"; + +export default RestrictedUserRoute.extend({ + model() { + return this.modelFor('user'); + }, + + renderTemplate() { + return this.render({ into: 'user' }); + }, + + setupController(controller, model) { + controller.setProperties({ model, newUsername: model.get('username') }); + }, + + deactivate() { + this.controller.setProperties({ backupCodes: null }); + } +}); diff --git a/app/assets/javascripts/discourse/templates/components/backup-codes.hbs b/app/assets/javascripts/discourse/templates/components/backup-codes.hbs new file mode 100644 index 00000000000..3eba35b6801 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/backup-codes.hbs @@ -0,0 +1,15 @@ +
+ + + {{d-button + action="copyToClipboard" + class="backup-codes-copy-btn" + icon="copy"}} + + + {{d-icon "download"}} + +
diff --git a/app/assets/javascripts/discourse/templates/components/second-factor-form.hbs b/app/assets/javascripts/discourse/templates/components/second-factor-form.hbs index c6e6e6d4355..c463fd5e6c9 100644 --- a/app/assets/javascripts/discourse/templates/components/second-factor-form.hbs +++ b/app/assets/javascripts/discourse/templates/components/second-factor-form.hbs @@ -1,13 +1,13 @@ - - {{#second-factor-form}} - {{second-factor-input value=loginSecondFactor inputId='login-second-factor'}} + {{#second-factor-form secondFactorMethod=secondFactorMethod loginSecondFactor=loginSecondFactor backupEnabled=backupEnabled}} + {{second-factor-input value=loginSecondFactor inputId='login-second-factor' secondFactorMethod=secondFactorMethod}} {{/second-factor-form}} {{/if}} - + {{/d-modal-body}} - {{#second-factor-form}} - {{second-factor-input value=loginSecondFactor inputId='login-second-factor'}} + {{#second-factor-form secondFactorMethod=secondFactorMethod loginSecondFactor=loginSecondFactor backupEnabled=backupEnabled}} + {{second-factor-input value=loginSecondFactor inputId='login-second-factor' secondFactorMethod=secondFactorMethod}} {{/second-factor-form}} {{/if}} - + {{#if showLoginButtons}} {{login-buttons canLoginLocalWithEmail=canLoginLocalWithEmail diff --git a/app/assets/javascripts/discourse/templates/password-reset.hbs b/app/assets/javascripts/discourse/templates/password-reset.hbs index 01d1db9df3a..660c370a94b 100644 --- a/app/assets/javascripts/discourse/templates/password-reset.hbs +++ b/app/assets/javascripts/discourse/templates/password-reset.hbs @@ -17,11 +17,9 @@ {{else}}
{{#if secondFactorRequired}} -

{{i18n 'login.second_factor_title'}}

-

{{i18n 'login.second_factor_description'}}

-
- {{input value=secondFactor id="second-factor" autofocus="autofocus"}} -
+ {{#second-factor-form secondFactorMethod=secondFactorMethod backupEnabled=backupEnabled}} + {{text-field value=secondFactor id="second-factor" autocorrect="off" autocapitalize="off" autofocus="autofocus" secondFactorMethod=secondFactorMethod}} + {{/second-factor-form}} {{d-button action="submit" class='btn-primary' label='submit'}} {{else}}

{{i18n 'user.change_password.choose'}}

diff --git a/app/assets/javascripts/discourse/templates/preferences-second-factor-backup.hbs b/app/assets/javascripts/discourse/templates/preferences-second-factor-backup.hbs new file mode 100644 index 00000000000..a81f2c2b9c8 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/preferences-second-factor-backup.hbs @@ -0,0 +1,61 @@ +
+ +

{{i18n "user.second_factor_backup.title"}}

+ + {{#if successMessage}} +
+ {{successMessage}} +
+ {{/if}} + + {{#if errorMessage}} +
+ {{errorMessage}} +
+ {{/if}} + +
+ {{second-factor-input + value=secondFactorToken + maxlength=6 + inputId="second-factor-token"}} + +
+ {{d-button + action="generateSecondFactorCodes" + class="btn btn-primary" + disabled=isDisabledGenerateBackupCodeBtn + label=generateBackupCodeBtnLabel}} + {{#if backupEnabled}} + {{d-button + action="disableSecondFactorBackup" + class="btn btn-danger" + disabled=isDisabledDisableBackupCodeBtn + label="user.second_factor_backup.disable"}} + {{/if}} +
+
+ +
+ {{i18n "user.second_factor.disable_description"}} +
+ + {{#conditional-loading-section isLoading=loading}} + {{#if backupCodes}} +

{{i18n "user.second_factor_backup.codes.title"}}

+ +

+ {{i18n "user.second_factor_backup.codes.description"}} +

+ + {{backup-codes + copyBackupCode=(action "copyBackupCode") + backupCodes=backupCodes}} + {{/if}} + {{/conditional-loading-section}} + + {{#link-to "preferences.account" model.username}} + {{i18n "go_back"}} + {{/link-to}} + +
diff --git a/app/assets/javascripts/discourse/templates/preferences/account.hbs b/app/assets/javascripts/discourse/templates/preferences/account.hbs index 2a7f3a0d192..1d684b0f500 100644 --- a/app/assets/javascripts/discourse/templates/preferences/account.hbs +++ b/app/assets/javascripts/discourse/templates/preferences/account.hbs @@ -80,6 +80,22 @@ {{/link-to}} {{/if}} + +
+ {{#if model.second_factor_enabled}} + {{#if model.second_factor_backup_enabled}} + {{i18n 'user.second_factor_backup.manage'}} + {{else}} + {{i18n 'user.second_factor_backup.enable_long'}} + {{/if}} + + {{#if isCurrentUser}} + {{#link-to "preferences.second-factor-backup" class="btn btn-small btn-icon pad-left no-text"}} + {{d-icon "pencil"}} + {{/link-to}} + {{/if}} + {{/if}} +
{{/if}} diff --git a/app/assets/stylesheets/common/base/user.scss b/app/assets/stylesheets/common/base/user.scss index 5213b373e8b..13438d40596 100644 --- a/app/assets/stylesheets/common/base/user.scss +++ b/app/assets/stylesheets/common/base/user.scss @@ -555,6 +555,68 @@ .tag-notifications .tag-controls { margin-top: 24px; } + + &.second-factor-backup-preferences { + padding-left: 0; + + .second-factor-token-input { + margin-right: 10px; + } + + .second-factor-form { + display: flex; + align-items: center; + } + + .form-horizontal .instructions { + margin-left: 0; + } + + .backup-codes { + margin: 2em 0; + + .wrapper { + display: inline-block; + position: relative; + padding: 10px; + border-radius: 3px; + border: 1px solid $primary-low; + } + + .backup-codes-area { + resize: none; + padding: 0; + height: auto; + text-align: center; + width: 250px; + background: white; + border: 0; + cursor: auto; + overflow: hidden; + outline: none; + font-family: monospace; + + &:focus { + box-shadow: none; + border-color: #e9e9e9; + } + } + + .backup-codes-copy-btn, + .backup-codes-download-btn { + right: 5px; + position: absolute; + } + + .backup-codes-copy-btn { + top: 5px; + } + + .backup-codes-download-btn { + top: 40px; + } + } + } } .paginated-topics-list { diff --git a/app/assets/stylesheets/desktop/login.scss b/app/assets/stylesheets/desktop/login.scss index 341053187f8..5355f8800fc 100644 --- a/app/assets/stylesheets/desktop/login.scss +++ b/app/assets/stylesheets/desktop/login.scss @@ -54,6 +54,9 @@ flex-direction: column; } } + #second-factor { + display: none; + } } // styles used on the diff --git a/app/assets/stylesheets/mobile/login.scss b/app/assets/stylesheets/mobile/login.scss index f9470a4cf34..2c7429eed0d 100644 --- a/app/assets/stylesheets/mobile/login.scss +++ b/app/assets/stylesheets/mobile/login.scss @@ -130,6 +130,9 @@ padding: 4px 0; } } + #second-factor { + display: none; + } } // styles for the diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 70573a2e918..909126891de 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -386,10 +386,10 @@ class Admin::UsersController < Admin::AdminController def disable_second_factor guardian.ensure_can_disable_second_factor!(@user) - user_second_factor = @user.user_second_factor - raise Discourse::InvalidParameters unless user_second_factor + user_second_factor = @user.user_second_factors + raise Discourse::InvalidParameters unless !user_second_factor.empty? - user_second_factor.destroy! + user_second_factor.destroy_all StaffActionLogger.new(current_user).log_disable_second_factor_auth(@user) Jobs.enqueue( diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb index 2d217ce8b16..4d6e9b9e62e 100644 --- a/app/controllers/session_controller.rb +++ b/app/controllers/session_controller.rb @@ -246,10 +246,11 @@ class SessionController < ApplicationController if payload = login_error_check(user) render json: payload else - if user.totp_enabled? && !user.authenticate_totp(params[:second_factor_token]) + if user.totp_enabled? && !user.authenticate_second_factor(params[:second_factor_token], params[:second_factor_method].to_i) return render json: failed_json.merge( error: I18n.t("login.invalid_second_factor_code"), - reason: "invalid_second_factor" + reason: "invalid_second_factor", + backup_enabled: user.backup_codes_enabled? ) end @@ -260,17 +261,18 @@ class SessionController < ApplicationController def email_login raise Discourse::NotFound if !SiteSetting.enable_local_logins_via_email second_factor_token = params[:second_factor_token] + second_factor_method = params[:second_factor_method].to_i token = params[:token] valid_token = !!EmailToken.valid_token_format?(token) user = EmailToken.confirmable(token)&.user if valid_token && user&.totp_enabled? - RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed! - if !second_factor_token.present? @second_factor_required = true + @backup_codes_enabled = true if user&.backup_codes_enabled? return render layout: 'no_ember' - elsif !user.authenticate_totp(second_factor_token) + elsif !user.authenticate_second_factor(second_factor_token, second_factor_method) + RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed! @error = I18n.t('login.invalid_second_factor_code') return render layout: 'no_ember' end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 3c97ce94d77..cf047ce31ee 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -12,7 +12,7 @@ class UsersController < ApplicationController requires_login only: [ :username, :update, :user_preferences_redirect, :upload_user_image, :pick_avatar, :destroy_user_image, :destroy, :check_emails, :topic_tracking_state, - :preferences, :create_second_factor, :update_second_factor + :preferences, :create_second_factor, :update_second_factor, :create_second_factor_backup ] skip_before_action :check_xhr, only: [ @@ -454,7 +454,7 @@ class UsersController < ApplicationController totp_enabled = @user&.totp_enabled? - if !totp_enabled || @user.authenticate_totp(params[:second_factor_token]) + if !totp_enabled || @user.authenticate_second_factor(params[:second_factor_token], params[:second_factor_method].to_i) secure_session["second-factor-#{token}"] = "true" end @@ -467,7 +467,7 @@ class UsersController < ApplicationController if !valid_second_factor RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed! - @user.errors.add(:user_second_factor, :invalid) + @user.errors.add(:user_second_factors, :invalid) @error = I18n.t('login.invalid_second_factor_code') elsif @invalid_password @user.errors.add(:password, :invalid) @@ -494,7 +494,8 @@ class UsersController < ApplicationController MultiJson.dump( is_developer: UsernameCheckerService.is_developer?(@user.email), admin: @user.admin?, - second_factor_required: !valid_second_factor + second_factor_required: !valid_second_factor, + backup_enabled: @user.backup_codes_enabled? ) ) end @@ -524,7 +525,8 @@ class UsersController < ApplicationController render json: { is_developer: UsernameCheckerService.is_developer?(@user.email), admin: @user.admin?, - second_factor_required: !valid_second_factor + second_factor_required: !valid_second_factor, + backup_enabled: @user.backup_codes_enabled? } end end @@ -575,16 +577,19 @@ class UsersController < ApplicationController email_token_user = EmailToken.confirmable(token)&.user totp_enabled = email_token_user&.totp_enabled? + backup_enabled = email_token_user&.backup_codes_enabled? second_factor_token = params[:second_factor_token] + second_factor_method = params[:second_factor_method].to_i confirm_email = false confirm_email = if totp_enabled @second_factor_required = true + @backup_codes_enabled = true @message = I18n.t("login.second_factor_title") if second_factor_token.present? - if email_token_user.authenticate_totp(second_factor_token) + if email_token_user.authenticate_second_factor(second_factor_token, second_factor_method) true else @error = I18n.t("login.invalid_second_factor_code") @@ -956,19 +961,43 @@ class UsersController < ApplicationController ) render json: success_json.merge( - key: current_user.user_second_factor.data.scan(/.{4}/).join(" "), + key: current_user.user_second_factors.totp.data.scan(/.{4}/).join(" "), qr: qrcode_svg ) end + def create_second_factor_backup + raise Discourse::NotFound if SiteSetting.enable_sso || !SiteSetting.enable_local_logins + + unless current_user.authenticate_totp(params[:second_factor_token]) + return render json: failed_json.merge( + error: I18n.t("login.invalid_second_factor_code") + ) + end + + backup_codes = current_user.generate_backup_codes + + render json: success_json.merge( + backup_codes: backup_codes + ) + end + def update_second_factor params.require(:second_factor_token) + params.require(:second_factor_method) + + second_factor_method = params[:second_factor_method].to_i [request.remote_ip, current_user.id].each do |key| RateLimiter.new(nil, "second-factor-min-#{key}", 3, 1.minute).performed! end - user_second_factor = current_user.user_second_factor + if second_factor_method == UserSecondFactor.methods[:totp] + user_second_factor = current_user.user_second_factors.totp + elsif second_factor_method == UserSecondFactor.methods[:backup_codes] + user_second_factor = current_user.user_second_factors.backup_codes + end + raise Discourse::InvalidParameters unless user_second_factor unless current_user.authenticate_totp(params[:second_factor_token]) @@ -980,13 +1009,18 @@ class UsersController < ApplicationController if params[:enable] == "true" user_second_factor.update!(enabled: true) else - user_second_factor.destroy! + # when disabling totp, backup is disabled too + if second_factor_method == UserSecondFactor.methods[:totp] + current_user.user_second_factors.destroy_all - Jobs.enqueue( - :critical_user_email, - type: :account_second_factor_disabled, - user_id: current_user.id - ) + Jobs.enqueue( + :critical_user_email, + type: :account_second_factor_disabled, + user_id: current_user.id + ) + elsif second_factor_method == UserSecondFactor.methods[:backup_codes] + current_user.user_second_factors.where(method: UserSecondFactor.methods[:backup_codes]).destroy_all + end end render json: success_json diff --git a/app/controllers/users_email_controller.rb b/app/controllers/users_email_controller.rb index 2fd289edb07..3caadf17b43 100644 --- a/app/controllers/users_email_controller.rb +++ b/app/controllers/users_email_controller.rb @@ -43,9 +43,10 @@ class UsersEmailController < ApplicationController end if change_request&.change_state == EmailChangeRequest.states[:authorizing_new] && - user.totp_enabled? && !user.authenticate_totp(params[:second_factor_token]) + user.totp_enabled? && !user.authenticate_second_factor(params[:second_factor_token], params[:second_factor_method].to_i) @update_result = :invalid_second_factor + @backup_codes_enabled = true if user.backup_codes_enabled? if params[:second_factor_token].present? RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed! diff --git a/app/models/concerns/second_factor_manager.rb b/app/models/concerns/second_factor_manager.rb index 9dd00b61582..5ed61241b61 100644 --- a/app/models/concerns/second_factor_manager.rb +++ b/app/models/concerns/second_factor_manager.rb @@ -3,12 +3,13 @@ module SecondFactorManager def totp self.create_totp - ROTP::TOTP.new(self.user_second_factor.data, issuer: SiteSetting.title) + ROTP::TOTP.new(self.user_second_factors.totp.data, issuer: SiteSetting.title) end def create_totp(opts = {}) - if !self.user_second_factor - self.create_user_second_factor!({ + if !self.user_second_factors.totp + UserSecondFactor.create!({ + user_id: self.id, method: UserSecondFactor.methods[:totp], data: ROTP::Base32.random_base32 }.merge(opts)) @@ -23,18 +24,88 @@ module SecondFactorManager totp = self.totp last_used = 0 - if self.user_second_factor.last_used - last_used = self.user_second_factor.last_used.to_i + if self.user_second_factors.totp.last_used + last_used = self.user_second_factors.totp.last_used.to_i end authenticated = !token.blank? && totp.verify_with_drift_and_prior(token, 30, last_used) - self.user_second_factor.update!(last_used: DateTime.now) if authenticated + self.user_second_factors.totp.update!(last_used: DateTime.now) if authenticated !!authenticated end def totp_enabled? - !!(self&.user_second_factor&.enabled?) && + !!(self&.user_second_factors&.totp&.enabled?) && !SiteSetting.enable_sso && SiteSetting.enable_local_logins end + + def backup_codes_enabled? + !!(self&.user_second_factors&.backup_codes&.present?) && + !SiteSetting.enable_sso && + SiteSetting.enable_local_logins + end + + def authenticate_second_factor(token, second_factor_method) + if second_factor_method == UserSecondFactor.methods[:totp] + authenticate_totp(token) + elsif second_factor_method == UserSecondFactor.methods[:backup_codes] + authenticate_backup_code(token) + end + end + + def generate_backup_codes + codes = [] + 10.times do + codes << SecureRandom.hex(8) + end + + codes_json = codes.map do |code| + salt = SecureRandom.hex(16) + { salt: salt, + code_hash: hash_backup_code(code, salt) + } + end + + if self.user_second_factors.backup_codes.empty? + create_backup_codes(codes_json) + else + self.user_second_factors.where(method: UserSecondFactor.methods[:backup_codes]).destroy_all + create_backup_codes(codes_json) + end + + codes + end + + def create_backup_codes(codes) + codes.each do |code| + UserSecondFactor.create!( + user_id: self.id, + data: code.to_json, + enabled: true, + method: UserSecondFactor.methods[:backup_codes] + ) + end + end + + def authenticate_backup_code(backup_code) + if !backup_code.blank? + codes = self&.user_second_factors&.backup_codes + + codes.each do |code| + stored_code = JSON.parse(code.data)["code_hash"] + stored_salt = JSON.parse(code.data)["salt"] + backup_hash = hash_backup_code(backup_code, stored_salt) + next unless backup_hash == stored_code + + code.update(enabled: false, last_used: DateTime.now) + return true + end + false + end + false + end + + def hash_backup_code(code, salt) + Pbkdf2.hash_password(code, salt, Rails.configuration.pbkdf2_iterations, Rails.configuration.pbkdf2_algorithm) + end end diff --git a/app/models/user.rb b/app/models/user.rb index 40cc4e15ea8..816ed33f7fe 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -67,7 +67,7 @@ class User < ActiveRecord::Base has_one :google_user_info, dependent: :destroy has_one :oauth2_user_info, dependent: :destroy has_one :instagram_user_info, dependent: :destroy - has_one :user_second_factor, dependent: :destroy + has_many :user_second_factors, dependent: :destroy has_one :user_stat, dependent: :destroy has_one :user_profile, dependent: :destroy, inverse_of: :user has_one :single_sign_on_record, dependent: :destroy diff --git a/app/models/user_second_factor.rb b/app/models/user_second_factor.rb index 0ffa9717d52..f70d4edfbec 100644 --- a/app/models/user_second_factor.rb +++ b/app/models/user_second_factor.rb @@ -1,11 +1,18 @@ class UserSecondFactor < ActiveRecord::Base belongs_to :user + scope :backup_codes, -> { where(method: 2, enabled: true) } def self.methods @methods ||= Enum.new( totp: 1, + backup_codes: 2, ) end + + def self.totp + where(method: 1).first + end + end # == Schema Information diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index f7b56ebf4bf..9f7c88be66e 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -73,7 +73,8 @@ class UserSerializer < BasicUserSerializer :primary_group_flair_bg_color, :primary_group_flair_color, :staged, - :second_factor_enabled + :second_factor_enabled, + :second_factor_backup_enabled has_one :invited_by, embed: :object, serializer: BasicUserSerializer has_many :groups, embed: :object, serializer: BasicGroupSerializer @@ -151,6 +152,14 @@ class UserSerializer < BasicUserSerializer object.totp_enabled? end + def include_second_factor_backup_enabled? + object&.id == scope.user&.id + end + + def second_factor_backup_enabled + object.backup_codes_enabled? + end + def can_change_bio !(SiteSetting.enable_sso && SiteSetting.sso_overrides_bio) end diff --git a/app/views/common/_second_factor_backup_input.html.erb b/app/views/common/_second_factor_backup_input.html.erb new file mode 100644 index 00000000000..dfe19616721 --- /dev/null +++ b/app/views/common/_second_factor_backup_input.html.erb @@ -0,0 +1,2 @@ +<%= text_field_tag(:second_factor_token, nil, autofocus: true, pattern: '[a-z0-9]{16}', maxlength: 16, type: 'text') %> +<%= hidden_field_tag 'second_factor_method', '2' %> \ No newline at end of file diff --git a/app/views/common/_second_factor_form_script.html.erb b/app/views/common/_second_factor_form_script.html.erb new file mode 100644 index 00000000000..226136b7be1 --- /dev/null +++ b/app/views/common/_second_factor_form_script.html.erb @@ -0,0 +1,18 @@ +<%= javascript_tag do %> + var useTotp = "<%= t("login.second_factor_toggle.totp") %>"; + var useBackup = "<%= t("login.second_factor_toggle.backup_code") %>"; + var backupForm = document.getElementById("backup-second-factor-form"); + var primaryForm = document.getElementById("primary-second-factor-form"); + document.getElementById("toggle-form").onclick = function(event) { + event.preventDefault(); + if (backupForm.style.display === "none") { + backupForm.style.display = "block"; + primaryForm.style.display = "none"; + document.getElementById("toggle-form").innerHTML = useTotp; + } else { + backupForm.style.display = "none"; + primaryForm.style.display = "block"; + document.getElementById("toggle-form").innerHTML = useBackup; + } + } +<% end %> diff --git a/app/views/common/_second_factor_text_field.html.erb b/app/views/common/_second_factor_text_field.html.erb index ad6d61c3979..6e4a822edda 100644 --- a/app/views/common/_second_factor_text_field.html.erb +++ b/app/views/common/_second_factor_text_field.html.erb @@ -1 +1,2 @@ <%= text_field_tag(:second_factor_token, nil, autofocus: true, pattern: '[0-9]{6}', maxlength: 6, type: 'tel') %> +<%= hidden_field_tag 'second_factor_method', '1' %> diff --git a/app/views/session/email_login.html.erb b/app/views/session/email_login.html.erb index a0f390df785..1929995e018 100644 --- a/app/views/session/email_login.html.erb +++ b/app/views/session/email_login.html.erb @@ -5,8 +5,8 @@ <%end%> <%if @second_factor_required%> -
-
+
+
<%= form_tag(method: "post") do%>

<%=t "login.second_factor_title" %>

<%= label_tag(:second_factor_token, t("login.second_factor_description")) %> @@ -14,9 +14,24 @@ <%= submit_tag(t("submit"), class: "btn btn-large btn-primary") %> <%end%>
+ + <%if @backup_codes_enabled%> + + <%=t "login.second_factor_toggle.backup_code" %> + <%= render 'common/second_factor_form_script' %> + <%end%>
<%end%> + + <% content_for :title do %><%=t "email_login.title" %><% end %> <%- content_for(:no_ember_head) do %> diff --git a/app/views/users/admin_login.html.erb b/app/views/users/admin_login.html.erb index 23049440715..aa29bc0fa84 100644 --- a/app/views/users/admin_login.html.erb +++ b/app/views/users/admin_login.html.erb @@ -8,11 +8,25 @@ <% if @error %>

<%= @error %>

<% end %> <% if @second_factor_required %> - <%=form_tag({}, method: :put) do %> - <%= label_tag(:second_factor_token, t('login.second_factor_description')) %> - <%= render 'common/second_factor_text_field' %>

- <%= submit_tag t('submit')%> - <% end %> +
+ <%=form_tag({}, method: :put) do %> + <%= label_tag(:second_factor_token, t('login.second_factor_description')) %> + <%= render 'common/second_factor_text_field' %>

+ <%= submit_tag t('submit')%> + <% end %> +
+ + <%if @backup_codes_enabled%> + + <%=t "login.second_factor_backup" %> + <%= render 'common/second_factor_form_script' %> + <%end%> <% end %> <% else %> <%=form_tag({}, method: :put) do %> diff --git a/app/views/users_email/confirm.html.erb b/app/views/users_email/confirm.html.erb index 35877cd95d0..0f236a1992f 100644 --- a/app/views/users_email/confirm.html.erb +++ b/app/views/users_email/confirm.html.erb @@ -8,16 +8,33 @@
<%= t('change_email.please_continue', site_name: SiteSetting.title) %> <% elsif @update_result == :invalid_second_factor%> -

<%= t('login.second_factor_title') %>

-
- <%=form_tag({}, method: :put) do %> - <%= label_tag(:second_factor_token, t('login.second_factor_description')) %> - <%= text_field_tag(:second_factor_token, nil, autofocus: true) %>
- <% if @show_invalid_second_factor_error %> -
<%= t('login.invalid_second_factor_code') %>
+
+

<%= t('login.second_factor_title') %>

+
+ <%=form_tag({}, method: :put) do %> + <%= label_tag(:second_factor_token, t('login.second_factor_description')) %> +
<%= render 'common/second_factor_text_field' %>
+ <% if @show_invalid_second_factor_error %> +
<%= t('login.invalid_second_factor_code') %>
+ <% end %> + <%= submit_tag t('submit'), class: "btn btn-primary" %> <% end %> - <%= submit_tag t('submit'), class: "btn btn-primary" %> - <% end %> +
+ + <%if @backup_codes_enabled %> + + <%=t "login.second_factor_backup" %> + <%= render 'common/second_factor_form_script' %> + <%end%> <% else %>
<%=t 'change_email.already_done' %> diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index bfc06f65ac5..02d91d0f29a 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -738,6 +738,19 @@ en: choose_new: "Choose a new password" choose: "Choose a password" + second_factor_backup: + title: "Two Factor backup code management" + regenerate: "Regenerate" + disable: "Disable" + enable: "Enable" + enable_long: "Enable backup codes" + manage: "Manage backup codes" + copied_to_clipboard: "Copied to Clipboard" + copy_to_clipboard_error: "Error copying data to Clipboard" + codes: + title: "Backup codes" + description: "Each line is a different backup code which can only be used once. It's recommended to save this file in a safe place." + second_factor: title: "Two Factor Authentication" disable: "Disable two factor authentication" @@ -1144,6 +1157,10 @@ en: password: "Password" second_factor_title: "Two Factor Authentication" second_factor_description: "Please enter the authentication code from your app:" + second_factor_backup: "Log in using a backup code" + second_factor_backup_title: "Two Factor Backup" + second_factor_backup_description: "Please enter one of your backup codes:" + second_factor: "Log in using Authenticator app" email_placeholder: "email or username" caps_lock_warning: "Caps Lock is on" error: "Unknown error" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 73a10d95380..ed533d02867 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1892,7 +1892,12 @@ en: already_logged_in: "Oops, looks like you are attempting to accept an invitation for another user. If you are not %{current_user}, please log out and try again." second_factor_title: "Two Factor Authentication" second_factor_description: "Please enter the required authentication code from your app:" - invalid_second_factor_code: "Invalid authentication code" + second_factor_backup_description: "Please enter one of your backup codes:" + second_factor_backup_title: "Two Factor Backup" + invalid_second_factor_code: "Invalid authentication code. Each code can only be used once." + second_factor_toggle: + totp: "Log in using Authenticator app" + backup_code: "Log in using a backup code" user: no_accounts_associated: "No accounts associated" diff --git a/config/routes.rb b/config/routes.rb index 0923afeab04..2bee8f0601d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -345,6 +345,8 @@ Discourse::Application.routes.draw do post "#{root_path}/second_factors" => "users#create_second_factor" put "#{root_path}/second_factor" => "users#update_second_factor" + put "#{root_path}/second_factors_backup" => "users#create_second_factor_backup" + put "#{root_path}/update-activation-email" => "users#update_activation_email" get "#{root_path}/hp" => "users#get_honeypot_value" post "#{root_path}/email-login" => "users#email_login" @@ -400,6 +402,7 @@ Discourse::Application.routes.draw do get "#{root_path}/:username/preferences/username" => "users#preferences", constraints: { username: RouteFormat.username } put "#{root_path}/:username/preferences/username" => "users#username", constraints: { username: RouteFormat.username } get "#{root_path}/:username/preferences/second-factor" => "users#preferences", constraints: { username: RouteFormat.username } + get "#{root_path}/:username/preferences/second-factor-backup" => "users#preferences", constraints: { username: RouteFormat.username } delete "#{root_path}/:username/preferences/user_image" => "users#destroy_user_image", constraints: { username: RouteFormat.username } put "#{root_path}/:username/preferences/avatar/pick" => "users#pick_avatar", constraints: { username: RouteFormat.username } get "#{root_path}/:username/staff-info" => "users#staff_info", constraints: { username: RouteFormat.username } diff --git a/lib/admin_user_index_query.rb b/lib/admin_user_index_query.rb index 67cc66626ef..83bc4dc84ae 100644 --- a/lib/admin_user_index_query.rb +++ b/lib/admin_user_index_query.rb @@ -63,7 +63,7 @@ class AdminUserIndexQuery if params[:stats].present? && params[:stats] == false klass.order(order.reject(&:blank?).join(",")) else - klass.includes(:user_stat, :user_second_factor) + klass.includes(:user_stat, :user_second_factors) .order(order.reject(&:blank?).join(",")) end end diff --git a/spec/components/concern/second_factor_manager_spec.rb b/spec/components/concern/second_factor_manager_spec.rb index ccecf68da57..0080f2ff675 100644 --- a/spec/components/concern/second_factor_manager_spec.rb +++ b/spec/components/concern/second_factor_manager_spec.rb @@ -1,10 +1,13 @@ require 'rails_helper' RSpec.describe SecondFactorManager do - let(:user_second_factor) { Fabricate(:user_second_factor) } - let(:user) { user_second_factor.user } + let(:user_second_factor_totp) { Fabricate(:user_second_factor_totp) } + let(:user) { user_second_factor_totp.user } let(:another_user) { Fabricate(:user) } + let(:user_second_factor_backup) { Fabricate(:user_second_factor_backup) } + let(:user_backup) { user_second_factor_backup.user } + describe '#totp' do it 'should return the right data' do totp = nil @@ -14,7 +17,7 @@ RSpec.describe SecondFactorManager do end.to change { UserSecondFactor.count }.by(1) expect(totp.issuer).to eq(SiteSetting.title) - expect(totp.secret).to eq(another_user.reload.user_second_factor.data) + expect(totp.secret).to eq(another_user.reload.user_second_factors.totp.data) end end @@ -37,7 +40,7 @@ RSpec.describe SecondFactorManager do describe '#totp_provisioning_uri' do it 'should return the right uri' do expect(user.totp_provisioning_uri).to eq( - "otpauth://totp/#{SiteSetting.title}:#{user.email}?secret=#{user_second_factor.data}&issuer=#{SiteSetting.title}" + "otpauth://totp/#{SiteSetting.title}:#{user.email}?secret=#{user_second_factor_totp.data}&issuer=#{SiteSetting.title}" ) end end @@ -45,12 +48,12 @@ RSpec.describe SecondFactorManager do describe '#authenticate_totp' do it 'should be able to authenticate a token' do freeze_time do - expect(user.user_second_factor.last_used).to eq(nil) + expect(user.user_second_factors.totp.last_used).to eq(nil) token = user.totp.now expect(user.authenticate_totp(token)).to eq(true) - expect(user.user_second_factor.last_used).to eq(DateTime.now) + expect(user.user_second_factors.totp.last_used).to eq(DateTime.now) expect(user.authenticate_totp(token)).to eq(false) end end @@ -58,14 +61,14 @@ RSpec.describe SecondFactorManager do describe 'when token is blank' do it 'should be false' do expect(user.authenticate_totp(nil)).to eq(false) - expect(user.user_second_factor.last_used).to eq(nil) + expect(user.user_second_factors.totp.last_used).to eq(nil) end end describe 'when token is invalid' do it 'should be false' do expect(user.authenticate_totp('111111')).to eq(false) - expect(user.user_second_factor.last_used).to eq(nil) + expect(user.user_second_factors.totp.last_used).to eq(nil) end end end @@ -79,7 +82,7 @@ RSpec.describe SecondFactorManager do describe "when user's second factor record is disabled" do it 'should return false' do - user.user_second_factor.update!(enabled: false) + user.user_second_factors.totp.update!(enabled: false) expect(user.totp_enabled?).to eq(false) end end @@ -107,4 +110,85 @@ RSpec.describe SecondFactorManager do end end end + + context 'backup codes' do + describe '#generate_backup_codes' do + it 'should generate and store 10 backup codes' do + backup_codes = user.generate_backup_codes + + expect(backup_codes.length).to be 10 + expect(user_backup.user_second_factors.backup_codes).to be_present + expect(user_backup.user_second_factors.backup_codes.pluck(:method).uniq[0]).to eq(UserSecondFactor.methods[:backup_codes]) + expect(user_backup.user_second_factors.backup_codes.pluck(:enabled).uniq[0]).to eq(true) + end + end + + describe '#create_backup_codes' do + it 'should create 10 backup code records' do + raw_codes = Array.new(10) { SecureRandom.hex(8) } + backup_codes = another_user.create_backup_codes(raw_codes) + + expect(another_user.user_second_factors.backup_codes.length).to be 10 + end + end + + describe '#authenticate_backup_code' do + it 'should be able to authenticate a backup code' do + backup_code = "iAmValidBackupCode" + + expect(user_backup.authenticate_backup_code(backup_code)).to eq(true) + expect(user_backup.authenticate_backup_code(backup_code)).to eq(false) + end + + describe 'when code is blank' do + it 'should be false' do + expect(user_backup.authenticate_backup_code(nil)).to eq(false) + end + end + + describe 'when code is invalid' do + it 'should be false' do + expect(user_backup.authenticate_backup_code("notValidBackupCode")).to eq(false) + end + end + end + + describe '#backup_codes_enabled?' do + describe 'when user does not have a second factor backup enabled' do + it 'should return false' do + expect(another_user.backup_codes_enabled?).to eq(false) + end + end + + describe "when user's second factor backup codes have been used" do + it 'should return false' do + user_backup.user_second_factors.backup_codes.update_all(enabled: false) + expect(user_backup.backup_codes_enabled?).to eq(false) + end + end + + describe "when user's second factor code is available" do + it 'should return true' do + expect(user_backup.backup_codes_enabled?).to eq(true) + end + end + + describe 'when SSO is enabled' do + it 'should return false' do + SiteSetting.sso_url = 'http://someurl.com' + SiteSetting.enable_sso = true + + expect(user_backup.backup_codes_enabled?).to eq(false) + end + end + + describe 'when local login is disabled' do + it 'should return false' do + SiteSetting.enable_local_logins = false + + expect(user_backup.backup_codes_enabled?).to eq(false) + end + end + end + end end diff --git a/spec/fabricators/user_second_factor_fabricator.rb b/spec/fabricators/user_second_factor_fabricator.rb index 1bb88567873..2064c4b0336 100644 --- a/spec/fabricators/user_second_factor_fabricator.rb +++ b/spec/fabricators/user_second_factor_fabricator.rb @@ -1,6 +1,14 @@ -Fabricator(:user_second_factor) do +Fabricator(:user_second_factor_totp, from: :user_second_factor) do user data 'rcyryaqage3jexfj' enabled true method UserSecondFactor.methods[:totp] end + +Fabricator(:user_second_factor_backup, from: :user_second_factor) do + user + # backup code: iAmValidBackupCode + data '{"salt":"e84ab3842f173967ca85ca6f5639b7ab","code_hash":"6abfe07527e2f7db45980cf67b9b4bfc7fbeea2685b07dcc3bf49f21349707f3"}' + enabled true + method UserSecondFactor.methods[:backup_codes] +end diff --git a/spec/models/user_second_factor_spec.rb b/spec/models/user_second_factor_spec.rb index 2a61b064222..e76974659fb 100644 --- a/spec/models/user_second_factor_spec.rb +++ b/spec/models/user_second_factor_spec.rb @@ -4,6 +4,7 @@ RSpec.describe UserSecondFactor do describe '.methods' do it 'should retain the right order' do expect(described_class.methods[:totp]).to eq(1) + expect(described_class.methods[:backup_codes]).to eq(2) end end end diff --git a/spec/requests/admin/users_controller_spec.rb b/spec/requests/admin/users_controller_spec.rb index 67cf0ee5b90..2c8cc43f5fa 100644 --- a/spec/requests/admin/users_controller_spec.rb +++ b/spec/requests/admin/users_controller_spec.rb @@ -808,12 +808,14 @@ RSpec.describe Admin::UsersController do describe '#disable_second_factor' do let(:second_factor) { user.create_totp } + let(:second_factor_backup) { user.generate_backup_codes } describe 'as an admin' do before do sign_in(admin) second_factor - expect(user.reload.user_second_factor).to eq(second_factor) + second_factor_backup + expect(user.reload.user_second_factors.totp).to eq(second_factor) end it 'should able to disable the second factor for another user' do @@ -822,7 +824,7 @@ RSpec.describe Admin::UsersController do end.to change { Jobs::CriticalUserEmail.jobs.length }.by(1) expect(response.status).to eq(200) - expect(user.reload.user_second_factor).to eq(nil) + expect(user.reload.user_second_factors).to be_empty job_args = Jobs::CriticalUserEmail.jobs.first["args"].first @@ -838,7 +840,7 @@ RSpec.describe Admin::UsersController do describe 'when user does not have second factor enabled' do it 'should raise the right error' do - user.user_second_factor.destroy! + user.user_second_factors.destroy_all put "/admin/users/#{user.id}/disable_second_factor.json" diff --git a/spec/requests/session_controller_spec.rb b/spec/requests/session_controller_spec.rb index 43e8a4b5684..484c1bfaebd 100644 --- a/spec/requests/session_controller_spec.rb +++ b/spec/requests/session_controller_spec.rb @@ -146,7 +146,8 @@ RSpec.describe SessionController do end context 'user has 2-factor logins' do - let!(:user_second_factor) { Fabricate(:user_second_factor, user: user) } + let!(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) } + let!(:user_second_factor_backup) { Fabricate(:user_second_factor_backup, user: user) } describe 'requires second factor' do it 'should return a second factor prompt' do @@ -167,24 +168,55 @@ RSpec.describe SessionController do end describe 'errors on incorrect 2-factor' do - it 'does not log in with incorrect two factor' do - post "/session/email-login/#{email_token.token}", params: { second_factor_token: "0000" } + context 'when using totp method' do + it 'does not log in with incorrect two factor' do + post "/session/email-login/#{email_token.token}", params: { + second_factor_token: "0000", + second_factor_method: UserSecondFactor.methods[:totp] + } - expect(response.status).to eq(200) + expect(response.status).to eq(200) - expect(CGI.unescapeHTML(response.body)).to include(I18n.t( - "login.invalid_second_factor_code" - )) + expect(CGI.unescapeHTML(response.body)).to include(I18n.t( + "login.invalid_second_factor_code" + )) + end + end + context 'when using backup code method' do + it 'does not log in with incorrect backup code' do + post "/session/email-login/#{email_token.token}", params: { + second_factor_token: "0000", + second_factor_method: UserSecondFactor.methods[:backup_codes] + } + + expect(response.status).to eq(200) + expect(CGI.unescapeHTML(response.body)).to include(I18n.t( + "login.invalid_second_factor_code" + )) + end end end describe 'allows successful 2-factor' do - it 'logs in correctly' do - post "/session/email-login/#{email_token.token}", params: { - second_factor_token: ROTP::TOTP.new(user_second_factor.data).now - } + context 'when using totp method' do + it 'logs in correctly' do + post "/session/email-login/#{email_token.token}", params: { + second_factor_token: ROTP::TOTP.new(user_second_factor.data).now, + second_factor_method: UserSecondFactor.methods[:totp] + } - expect(response).to redirect_to("/") + expect(response).to redirect_to("/") + end + end + context 'when using backup code method' do + it 'logs in correctly' do + post "/session/email-login/#{email_token.token}", params: { + second_factor_token: "iAmValidBackupCode", + second_factor_method: UserSecondFactor.methods[:backup_codes] + } + + expect(response).to redirect_to("/") + end end end end @@ -899,7 +931,8 @@ RSpec.describe SessionController do end context 'when user has 2-factor logins' do - let!(:user_second_factor) { Fabricate(:user_second_factor, user: user) } + let!(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) } + let!(:user_second_factor_backup) { Fabricate(:user_second_factor_backup, user: user) } describe 'when second factor token is missing' do it 'should return the right response' do @@ -916,35 +949,74 @@ RSpec.describe SessionController do end describe 'when second factor token is invalid' do - it 'should return the right response' do - post "/session.json", params: { - login: user.username, - password: 'myawesomepassword', - second_factor_token: '00000000' - } + context 'when using totp method' do + it 'should return the right response' do + post "/session.json", params: { + login: user.username, + password: 'myawesomepassword', + second_factor_token: '00000000', + second_factor_method: UserSecondFactor.methods[:totp] + } - expect(response.status).to eq(200) - expect(JSON.parse(response.body)['error']).to eq(I18n.t( - 'login.invalid_second_factor_code' - )) + expect(response.status).to eq(200) + expect(JSON.parse(response.body)['error']).to eq(I18n.t( + 'login.invalid_second_factor_code' + )) + end + end + context 'when using backup code method' do + it 'should return the right response' do + post "/session.json", params: { + login: user.username, + password: 'myawesomepassword', + second_factor_token: '00000000', + second_factor_method: UserSecondFactor.methods[:backup_codes] + } + + expect(response.status).to eq(200) + expect(JSON.parse(response.body)['error']).to eq(I18n.t( + 'login.invalid_second_factor_code' + )) + end end end describe 'when second factor token is valid' do - it 'should log the user in' do - post "/session.json", params: { - login: user.username, - password: 'myawesomepassword', - second_factor_token: ROTP::TOTP.new(user_second_factor.data).now - } - expect(response.status).to eq(200) - user.reload + context 'when using totp method' do + it 'should log the user in' do + post "/session.json", params: { + login: user.username, + password: 'myawesomepassword', + second_factor_token: ROTP::TOTP.new(user_second_factor.data).now, + second_factor_method: UserSecondFactor.methods[:totp] + } + expect(response.status).to eq(200) + user.reload - expect(session[:current_user_id]).to eq(user.id) - expect(user.user_auth_tokens.count).to eq(1) + expect(session[:current_user_id]).to eq(user.id) + expect(user.user_auth_tokens.count).to eq(1) - expect(UserAuthToken.hash_token(cookies[:_t])) - .to eq(user.user_auth_tokens.first.auth_token) + expect(UserAuthToken.hash_token(cookies[:_t])) + .to eq(user.user_auth_tokens.first.auth_token) + end + end + context 'when using backup code method' do + it 'should log the user in' do + post "/session.json", params: { + login: user.username, + password: 'myawesomepassword', + second_factor_token: 'iAmValidBackupCode', + second_factor_method: UserSecondFactor.methods[:backup_codes] + } + expect(response.status).to eq(200) + user.reload + + expect(session[:current_user_id]).to eq(user.id) + expect(user.user_auth_tokens.count).to eq(1) + + expect(UserAuthToken.hash_token(cookies[:_t])) + .to eq(user.user_auth_tokens.first.auth_token) + end end end end diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb index a99aacba9ac..9cd7f4bb8ba 100644 --- a/spec/requests/users_controller_spec.rb +++ b/spec/requests/users_controller_spec.rb @@ -190,7 +190,7 @@ describe UsersController do ) expect(response.status).to eq(200) - expect(response.body).to include('{"is_developer":false,"admin":false,"second_factor_required":false}') + expect(response.body).to include('{"is_developer":false,"admin":false,"second_factor_required":false,"backup_enabled":false}') expect(session["password-#{token}"]).to be_blank expect(UserAuthToken.where(id: user_auth_token.id).count).to eq(0) @@ -248,16 +248,20 @@ describe UsersController do end context '2 factor authentication required' do - let!(:second_factor) { Fabricate(:user_second_factor, user: user) } + let!(:second_factor) { Fabricate(:user_second_factor_totp, user: user) } it 'does not change with an invalid token' do token = user.email_tokens.create!(email: user.email).token get "/u/password-reset/#{token}" - expect(response.body).to include('{"is_developer":false,"admin":false,"second_factor_required":true}') + expect(response.body).to include('{"is_developer":false,"admin":false,"second_factor_required":true,"backup_enabled":false}') - put "/u/password-reset/#{token}", params: { password: 'hg9ow8yHG32O', second_factor_token: '000000' } + put "/u/password-reset/#{token}", params: { + password: 'hg9ow8yHG32O', + second_factor_token: '000000', + second_factor_method: UserSecondFactor.methods[:totp] + } expect(response.body).to include(I18n.t("login.invalid_second_factor_code")) @@ -273,7 +277,8 @@ describe UsersController do put "/u/password-reset/#{token}", params: { password: 'hg9ow8yHG32O', - second_factor_token: ROTP::TOTP.new(second_factor.data).now + second_factor_token: ROTP::TOTP.new(second_factor.data).now, + second_factor_method: UserSecondFactor.methods[:totp] } user.reload @@ -400,7 +405,7 @@ describe UsersController do end describe 'when 2 factor authentication is enabled' do - let(:second_factor) { Fabricate(:user_second_factor, user: admin) } + let(:second_factor) { Fabricate(:user_second_factor_totp, user: admin) } let(:email_token) { Fabricate(:email_token, user: admin) } it 'does not log in when token required' do @@ -415,7 +420,10 @@ describe UsersController do it 'should display the right error' do second_factor - put "/u/admin-login/#{email_token.token}", params: { second_factor_token: '13213' } + put "/u/admin-login/#{email_token.token}", params: { + second_factor_token: '13213', + second_factor_method: UserSecondFactor.methods[:totp] + } expect(response.status).to eq(200) expect(response.body).to include(I18n.t('login.second_factor_description')); @@ -424,7 +432,10 @@ describe UsersController do end it 'logs in when a valid 2-factor token is given' do - put "/u/admin-login/#{email_token.token}", params: { second_factor_token: ROTP::TOTP.new(second_factor.data).now } + put "/u/admin-login/#{email_token.token}", params: { + second_factor_token: ROTP::TOTP.new(second_factor.data).now, + second_factor_method: UserSecondFactor.methods[:totp] + } expect(response).to redirect_to('/') expect(session[:current_user_id]).to eq(admin.id) @@ -2795,7 +2806,7 @@ describe UsersController do it 'succeeds on correct password' do user.create_totp - user.user_second_factor.update!(data: "abcdefghijklmnop") + user.user_second_factors.totp.update!(data: "abcdefghijklmnop") post "/users/second_factors.json", params: { password: 'myawesomepassword' @@ -2816,12 +2827,13 @@ describe UsersController do end describe '#update_second_factor' do - let(:user_second_factor) { Fabricate(:user_second_factor, user: user) } + let(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) } context 'when not logged in' do it 'should return the right response' do put "/users/second_factor.json", params: { - second_factor_token: ROTP::TOTP.new(user_second_factor.data).now + second_factor_token: ROTP::TOTP.new(user_second_factor.data).now, + second_factor_method: UserSecondFactor.methods[:totp] } expect(response.status).to eq(403) @@ -2849,6 +2861,7 @@ describe UsersController do it 'returns the right response' do put "/users/second_factor.json", params: { second_factor_token: '000000', + second_factor_method: UserSecondFactor.methods[:totp], enable: 'true', } @@ -2865,22 +2878,136 @@ describe UsersController do put "/users/second_factor.json", params: { second_factor_token: ROTP::TOTP.new(user_second_factor.data).now, enable: 'true', + second_factor_method: UserSecondFactor.methods[:totp] } expect(response.status).to eq(200) - expect(user.reload.user_second_factor.enabled).to be true + expect(user.reload.user_second_factors.totp.enabled).to be true end it 'should allow second factor for the user to be disabled' do put "/users/second_factor.json", params: { second_factor_token: ROTP::TOTP.new(user_second_factor.data).now, + second_factor_method: UserSecondFactor.methods[:totp] } expect(response.status).to eq(200) - expect(user.reload.user_second_factor).to eq(nil) + expect(user.reload.user_second_factors.totp).to eq(nil) + end + end + end + + context "when user is updating backup codes" do + context 'when token is missing' do + it 'returns the right response' do + put "/users/second_factor.json", params: { + second_factor_method: UserSecondFactor.methods[:backup_codes], + } + + expect(response.status).to eq(400) + end + end + + context 'when token is invalid' do + it 'returns the right response' do + put "/users/second_factor.json", params: { + second_factor_token: '000000', + second_factor_method: UserSecondFactor.methods[:backup_codes], + } + + expect(response.status).to eq(200) + + expect(JSON.parse(response.body)['error']).to eq(I18n.t( + "login.invalid_second_factor_code" + )) + end + end + + context 'when token is valid' do + it 'should allow second factor backup for the user to be disabled' do + put "/users/second_factor.json", params: { + second_factor_token: ROTP::TOTP.new(user_second_factor.data).now, + second_factor_method: UserSecondFactor.methods[:backup_codes] + } + + expect(response.status).to eq(200) + expect(user.reload.user_second_factors.backup_codes).to be_empty end end end end end + + describe '#create_second_factor_backup' do + let(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) } + + context 'when not logged in' do + it 'should return the right response' do + put "/users/second_factors_backup.json", params: { + second_factor_token: 'wrongtoken' + } + + expect(response.status).to eq(403) + end + end + + context 'when logged in' do + before do + sign_in(user) + end + + describe 'create 2fa request' do + it 'fails on incorrect password' do + put "/users/second_factors_backup.json", params: { + second_factor_token: 'wrongtoken' + } + + expect(response.status).to eq(200) + + expect(JSON.parse(response.body)['error']).to eq(I18n.t( + "login.invalid_second_factor_code") + ) + end + + describe 'when local logins are disabled' do + it 'should return the right response' do + SiteSetting.enable_local_logins = false + + put "/users/second_factors_backup.json", params: { + second_factor_token: ROTP::TOTP.new(user_second_factor.data).now + } + + expect(response.status).to eq(404) + end + end + + describe 'when SSO is enabled' do + it 'should return the right response' do + SiteSetting.sso_url = 'http://someurl.com' + SiteSetting.enable_sso = true + + put "/users/second_factors_backup.json", params: { + second_factor_token: ROTP::TOTP.new(user_second_factor.data).now + } + + expect(response.status).to eq(404) + end + end + + it 'succeeds on correct password' do + user_second_factor + + put "/users/second_factors_backup.json", params: { + second_factor_token: ROTP::TOTP.new(user_second_factor.data).now + } + + expect(response.status).to eq(200) + + response_body = JSON.parse(response.body) + + expect(response_body['backup_codes'].length).to be(10) + end + end + end + end end diff --git a/spec/requests/users_email_controller_spec.rb b/spec/requests/users_email_controller_spec.rb index 5206a22fb44..b76f1c02662 100644 --- a/spec/requests/users_email_controller_spec.rb +++ b/spec/requests/users_email_controller_spec.rb @@ -71,7 +71,7 @@ describe UsersEmailController do end context 'second factor required' do - let!(:second_factor) { Fabricate(:user_second_factor, user: user) } + let!(:second_factor) { Fabricate(:user_second_factor_totp, user: user) } it 'requires a second factor token' do get "/u/authorize-email/#{user.email_tokens.last.token}" @@ -86,7 +86,8 @@ describe UsersEmailController do it 'adds an error on a second factor attempt' do get "/u/authorize-email/#{user.email_tokens.last.token}", params: { - second_factor_token: "000000" + second_factor_token: "000000", + second_factor_method: UserSecondFactor.methods[:totp] } expect(response.status).to eq(200) @@ -95,7 +96,8 @@ describe UsersEmailController do it 'confirms with a correct second token' do get "/u/authorize-email/#{user.email_tokens.last.token}", params: { - second_factor_token: ROTP::TOTP.new(second_factor.data).now + second_factor_token: ROTP::TOTP.new(second_factor.data).now, + second_factor_method: UserSecondFactor.methods[:totp] } expect(response.status).to eq(200) diff --git a/spec/services/user_merger_spec.rb b/spec/services/user_merger_spec.rb index fac984e6fc0..5b1dd3df768 100644 --- a/spec/services/user_merger_spec.rb +++ b/spec/services/user_merger_spec.rb @@ -988,7 +988,7 @@ describe UserMerger do it "deletes auth tokens" do Fabricate(:api_key, user: source_user) Fabricate(:readonly_user_api_key, user: source_user) - Fabricate(:user_second_factor, user: source_user) + Fabricate(:user_second_factor_totp, user: source_user) SiteSetting.verbose_auth_token_logging = true UserAuthToken.generate!(user_id: source_user.id, user_agent: "Firefox", client_ip: "127.0.0.1") diff --git a/test/javascripts/acceptance/password-reset-test.js.es6 b/test/javascripts/acceptance/password-reset-test.js.es6 index fa8f8ab6e76..cb6ebb5c240 100644 --- a/test/javascripts/acceptance/password-reset-test.js.es6 +++ b/test/javascripts/acceptance/password-reset-test.js.es6 @@ -51,7 +51,7 @@ acceptance("Password Reset", { return response({ success: false, message: "invalid token", - errors: { user_second_factor: ["invalid token"] } + errors: { user_second_factors: ["invalid token"] } }); } }); @@ -114,7 +114,7 @@ QUnit.test("Password Reset Page With Second Factor", assert => { assert.ok(exists("#second-factor"), "shows the second factor prompt"); }); - fillIn("#second-factor", "0000"); + fillIn("input#second-factor", "0000"); click(".password-reset form button"); andThen(() => { @@ -128,7 +128,7 @@ QUnit.test("Password Reset Page With Second Factor", assert => { ); }); - fillIn("#second-factor", "123123"); + fillIn("input#second-factor", "123123"); click(".password-reset form button"); andThen(() => { diff --git a/test/javascripts/acceptance/preferences-test.js.es6 b/test/javascripts/acceptance/preferences-test.js.es6 index 962ce8bade9..a6e33e1b3d6 100644 --- a/test/javascripts/acceptance/preferences-test.js.es6 +++ b/test/javascripts/acceptance/preferences-test.js.es6 @@ -18,6 +18,11 @@ acceptance("User Preferences", { server.put("/u/second_factor.json", () => { //eslint-disable-line return response({ error: "invalid token" }); }); + + // prettier-ignore + server.put("/u/second_factors_backup.json", () => { //eslint-disable-line + return response({ backup_codes: ["dsffdsd", "fdfdfdsf", "fddsds"] }); + }); } }); @@ -140,6 +145,24 @@ QUnit.test("second factor", assert => { }); }); +QUnit.test("second factor backup", assert => { + visit("/u/eviltrout/preferences/second-factor-backup"); + + andThen(() => { + assert.ok( + exists("#second-factor-token"), + "it has a authentication token input" + ); + }); + + fillIn("#second-factor-token", "111111"); + click(".second-factor-form .btn-primary"); + + andThen(() => { + assert.ok(exists(".backup-codes-area"), "shows backup codes"); + }); +}); + acceptance("User Preferences when badges are disabled", { loggedIn: true, settings: { diff --git a/test/javascripts/acceptance/sign-in-test.js.es6 b/test/javascripts/acceptance/sign-in-test.js.es6 index 0a075db20f0..94867b83b6a 100644 --- a/test/javascripts/acceptance/sign-in-test.js.es6 +++ b/test/javascripts/acceptance/sign-in-test.js.es6 @@ -180,3 +180,39 @@ QUnit.test("create account", assert => { ); }); }); + +QUnit.test("second factor backup - valid token", assert => { + visit("/"); + click("header .login-button"); + fillIn("#login-account-name", "eviltrout"); + fillIn("#login-account-password", "need-second-factor"); + click(".modal-footer .btn-primary"); + click(".login-modal .toggle-second-factor-method"); + fillIn("#login-second-factor", "123456"); + click(".modal-footer .btn-primary"); + + andThen(() => { + assert.ok( + exists(".modal-footer .btn-primary:disabled"), + "it closes the modal when the code is valid" + ); + }); +}); + +QUnit.test("second factor backup - invalid token", assert => { + visit("/"); + click("header .login-button"); + fillIn("#login-account-name", "eviltrout"); + fillIn("#login-account-password", "need-second-factor"); + click(".modal-footer .btn-primary"); + click(".login-modal .toggle-second-factor-method"); + fillIn("#login-second-factor", "something"); + click(".modal-footer .btn-primary"); + + andThen(() => { + assert.ok( + exists("#modal-alert:visible"), + "it shows an error when the code is invalid" + ); + }); +}); diff --git a/test/javascripts/helpers/create-pretender.js.es6 b/test/javascripts/helpers/create-pretender.js.es6 index 4dc63d39017..5c4cefa95fa 100644 --- a/test/javascripts/helpers/create-pretender.js.es6 +++ b/test/javascripts/helpers/create-pretender.js.es6 @@ -252,13 +252,14 @@ export default function() { } if (data.password === "need-second-factor") { - if (data.second_factor_token) { + if (data.second_factor_token && data.second_factor_token === "123456") { return response({ username: "eviltrout" }); } return response({ error: "Invalid Second Factor", reason: "invalid_second_factor", + backup_enabled: true, sent_to_email: "eviltrout@example.com", current_email: "current@example.com" });