Merge pull request from GHSA-hv9p-jfm4-gpr9

Merge pull request from GHSA-hv9p-jfm4-gpr9

  • SECURITY: Add confirmation screen when logging in via email link

  • SECURITY: Add confirmation screen when logging in via user-api OTP

  • FIX: Correct translation key in session controller specs

  • FIX: Use .email-login class for page

diff --git a/app/assets/javascripts/discourse/controllers/email-login.js.es6 b/app/assets/javascripts/discourse/controllers/email-login.js.es6
new file mode 100644
index 0000000..5a025ce
--- /dev/null
+++ b/app/assets/javascripts/discourse/controllers/email-login.js.es6
@@ -0,0 +1,29 @@
+import { SECOND_FACTOR_METHODS } from "discourse/models/user";
+import { ajax } from "discourse/lib/ajax";
+import DiscourseURL from "discourse/lib/url";
+import { popupAjaxError } from "discourse/lib/ajax-error";
+
+export default Ember.Controller.extend({
+  secondFactorMethod: SECOND_FACTOR_METHODS.TOTP,
+  lockImageUrl: Discourse.getURL("/images/lock.svg"),
+  actions: {
+    finishLogin() {
+      ajax({
+        url: `/session/email-login/${this.model.token}`,
+        type: "POST",
+        data: {
+          second_factor_token: this.secondFactorToken,
+          second_factor_method: this.secondFactorMethod
+        }
+      })
+        .then(result => {
+          if (result.success) {
+            DiscourseURL.redirectTo("/");
+          } else {
+            this.set("model.error", result.error);
+          }
+        })
+        .catch(popupAjaxError);
+    }
+  }
+});
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 fe84822..bfec70d 100644
--- a/app/assets/javascripts/discourse/routes/app-route-map.js.es6
+++ b/app/assets/javascripts/discourse/routes/app-route-map.js.es6
@@ -177,6 +177,7 @@ export default function() {
   });
   this.route("signup", { path: "/signup" });
   this.route("login", { path: "/login" });
+  this.route("email-login", { path: "/session/email-login/:token" });
   this.route("login-preferences");
   this.route("forgot-password", { path: "/password-reset" });
   this.route("faq", { path: "/faq" });
diff --git a/app/assets/javascripts/discourse/routes/email-login.js.es6 b/app/assets/javascripts/discourse/routes/email-login.js.es6
new file mode 100644
index 0000000..617de05
--- /dev/null
+++ b/app/assets/javascripts/discourse/routes/email-login.js.es6
@@ -0,0 +1,11 @@
+import { ajax } from "discourse/lib/ajax";
+
+export default Discourse.Route.extend({
+  titleToken() {
+    return I18n.t("login.title");
+  },
+
+  model(params) {
+    return ajax(`/session/email-login/${params.token}`);
+  }
+});
diff --git a/app/assets/javascripts/discourse/templates/email-login.hbs b/app/assets/javascripts/discourse/templates/email-login.hbs
new file mode 100644
index 0000000..1556a21
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/email-login.hbs
@@ -0,0 +1,33 @@
+<div class="container email-login clearfix">
+  <div class="pull-left col-image">
+    <img src={{lockImageUrl}} class="password-reset-img">
+  </div>
+
+  <div class="pull-left col-form">
+    <form>
+      {{#if model.error}}
+        <div class='alert alert-error'>
+          {{model.error}}
+        </div>
+      {{/if}}
+
+      {{#if model.can_login}}
+        {{#if model.second_factor_required}}
+          {{#second-factor-form
+            secondFactorMethod=secondFactorMethod
+            secondFactorToken=secondFactorToken
+            backupEnabled=model.backup_codes_enabled
+            isLogin=true}}
+            {{second-factor-input value=secondFactorToken secondFactorMethod=secondFactorMethod backupEnabled=backupEnabled}}
+          {{/second-factor-form}}
+        {{else}}
+          <h2>{{i18n "email_login.confirm_title" site_name=siteSettings.title}}</h2>
+          <p>{{i18n "email_login.logging_in_as" email=model.token_email}}</p>
+        {{/if}}
+
+        {{d-button label="email_login.confirm_button" action=(action "finishLogin") class="btn-primary"}}
+      {{/if}}
+    </form>
+  </div>
+</div>
+
diff --git a/app/assets/stylesheets/desktop/login.scss b/app/assets/stylesheets/desktop/login.scss
index 86a46bf..122d95e 100644
--- a/app/assets/stylesheets/desktop/login.scss
+++ b/app/assets/stylesheets/desktop/login.scss
@@ -267,6 +267,7 @@
 }
 
 .password-reset,
+.email-login,
 .invites-show {
   .col-form {
     padding-left: 20px;
@@ -282,7 +283,8 @@
   }
 }
 
-.password-reset {
+.password-reset,
+.email-login {
   .col-form {
     padding-top: 40px;
   }
diff --git a/app/assets/stylesheets/mobile/login.scss b/app/assets/stylesheets/mobile/login.scss
index 4334699..ef6b808 100644
--- a/app/assets/stylesheets/mobile/login.scss
+++ b/app/assets/stylesheets/mobile/login.scss
@@ -182,6 +182,7 @@
 }
 
 .password-reset,
+.email-login,
 .invites-show {
   margin-top: 30px;
   .col-image {
diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb
index 3ea48ad..b48ee8a 100644
--- a/app/controllers/session_controller.rb
+++ b/app/controllers/session_controller.rb
@@ -11,10 +11,10 @@ class SessionController < ApplicationController
     render body: nil, status: 500
   end
 
-  before_action :check_local_login_allowed, only: %i(create forgot_password email_login)
+  before_action :check_local_login_allowed, only: %i(create forgot_password email_login email_login_info)
   before_action :rate_limit_login, only: %i(create email_login)
   skip_before_action :redirect_to_login_if_required
-  skip_before_action :preload_json, :check_xhr, only: %i(sso sso_login sso_provider destroy email_login one_time_password)
+  skip_before_action :preload_json, :check_xhr, only: %i(sso sso_login sso_provider destroy one_time_password)
 
   ACTIVATE_USER_KEY = "activate_user"
 
@@ -305,49 +305,79 @@ class SessionController < ApplicationController
     end
   end
 
+  def email_login_info
+    raise Discourse::NotFound if !SiteSetting.enable_local_logins_via_email
+
+    token = params[:token]
+    matched_token = EmailToken.confirmable(token)
+
+    if matched_token
+      response = {
+        can_login: true,
+        token: token,
+        token_email: matched_token.email
+      }
+
+      if matched_token.user&.totp_enabled?
+        response.merge!(
+          second_factor_required: true,
+          backup_codes_enabled: matched_token.user&.backup_codes_enabled?
+        )
+      end
+
+      render json: response
+    else
+      render json: {
+        can_login: false,
+        error: I18n.t('email_login.invalid_token')
+      }
+    end
+  end
+
   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
+    matched_token = EmailToken.confirmable(token)
 
-    if valid_token && user&.totp_enabled?
+    if matched_token&.user&.totp_enabled?
       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_second_factor(second_factor_token, second_factor_method)
+        return render json: { error: I18n.t('login.invalid_second_factor_code') }
+      elsif !matched_token.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'
+        return render json: { error: I18n.t('login.invalid_second_factor_code') }
       end
     end
 
     if user = EmailToken.confirm(token)
       if login_not_approved_for?(user)
-        @error = login_not_approved[:error]
+        return render json: login_not_approved
       elsif payload = login_error_check(user)
-        @error = payload[:error]
+        return render json: payload
       else
         log_on_user(user)
-        return redirect_to path("/")
+        return render json: success_json
       end
-    else
-      @error = I18n.t('email_login.invalid_token')
     end
 

[... diff too long, it was truncated ...]

GitHub sha: b8340c6c

Revert "Merge pull request from GHSA-hv9p-jfm4-gpr9"