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 00000000000..5a025ce7a6e
--- /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 fe84822b078..bfec70df96a 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 00000000000..617de051cd5
--- /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 00000000000..1556a212a27
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/email-login.hbs
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
diff --git a/app/assets/stylesheets/desktop/login.scss b/app/assets/stylesheets/desktop/login.scss
index 86a46bf4c24..122d95e5007 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 4334699b714..ef6b8080651 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 3ea48ad1d87..617130453aa 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,40 +305,63 @@ 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
- render layout: 'no_ember'
+ return render json: { error: I18n.t('email_login.invalid_token') }
end
def one_time_password
diff --git a/app/views/session/email_login.html.erb b/app/views/session/email_login.html.erb
deleted file mode 100644
index 1929995e018..00000000000
--- a/app/views/session/email_login.html.erb
+++ /dev/null
@@ -1,45 +0,0 @@
-<%if @error%>
-
- <%= @error %>
-
-<%end%>
-
-<%if @second_factor_required%>
-
-<%end%>
-
-
-
-<% content_for :title do %><%=t "email_login.title" %><% end %>
-
-<%- content_for(:no_ember_head) do %>
-
- <%= preload_script "ember_jquery" %>
- <%= render_google_universal_analytics_code %>
-<%- end %>
-
-<%- content_for(:head) do %>
-
-<%- end %>
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 6471cf66e0a..9b5b3feba8f 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -1387,6 +1387,9 @@ en:
complete_email_found: "We found an account that matches %{email}, you should receive an email with a login link shortly."
complete_username_not_found: "No account matches the username %{username}"
complete_email_not_found: "No account matches %{email}"
+ confirm_title: Continue to %{site_name}
+ logging_in_as: Logging in as %{email}
+ confirm_button: Finish Login
login:
title: "Log In"
diff --git a/config/routes.rb b/config/routes.rb
index c996544c2e0..f9e57a66500 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -337,7 +337,7 @@ Discourse::Application.routes.draw do
get "session/sso_provider" => "session#sso_provider"
get "session/current" => "session#current"
get "session/csrf" => "session#csrf"
- get "session/email-login/:token" => "session#email_login"
+ get "session/email-login/:token" => "session#email_login_info"
post "session/email-login/:token" => "session#email_login"
get "session/otp/:token" => "session#one_time_password", constraints: { token: /[0-9a-f]+/ }
get "composer_messages" => "composer_messages#index"
diff --git a/spec/requests/session_controller_spec.rb b/spec/requests/session_controller_spec.rb
index a7b71a39c4d..e329b5e77e1 100644
--- a/spec/requests/session_controller_spec.rb
+++ b/spec/requests/session_controller_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe SessionController do
end
end
- describe '#email_login' do
+ describe '#email_login_info' do
before do
SiteSetting.enable_local_logins_via_email = true
end
@@ -26,13 +26,66 @@ RSpec.describe SessionController do
end
end
+ context 'valid token' do
+ it 'returns information' do
+ get "/session/email-login/#{email_token.token}.json"
+
+ expect(JSON.parse(response.body)["can_login"]).to eq(true)
+ expect(JSON.parse(response.body)["second_factor_required"]).to eq(nil)
+
+ # Does not log in the user
+ expect(session[:current_user_id]).to be_nil
+ end
+
+ it 'fails when local logins via email is disabled' do
+ SiteSetting.enable_local_logins_via_email = false
+
+ get "/session/email-login/#{email_token.token}.json"
+
+ expect(response.status).to eq(404)
+ end
+
+ it 'fails when local logins is disabled' do
+ SiteSetting.enable_local_logins = false
+
+ get "/session/email-login/#{email_token.token}.json"
+
+ expect(response.status).to eq(500)
+ end
+
+ context 'user has 2-factor logins' do
+ let!(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) }
+ let!(:user_second_factor_backup) { Fabricate(:user_second_factor_backup, user: user) }
+
+ it "includes that information in the response" do
+ get "/session/email-login/#{email_token.token}.json"
+
+ expect(JSON.parse(response.body)["can_login"]).to eq(true)
+ expect(JSON.parse(response.body)["second_factor_required"]).to eq(true)
+ expect(JSON.parse(response.body)["backup_codes_enabled"]).to eq(true)
+ end
+ end
+ end
+ end
+
+ describe '#email_login' do
+ before do
+ SiteSetting.enable_local_logins_via_email = true
+ end
+
+ context 'missing token' do
+ it 'returns the right response' do
+ post "/session/email-login"
+ expect(response.status).to eq(404)
+ end
+ end
+
context 'invalid token' do
it 'returns the right response' do
- get "/session/email-login/adasdad"
+ post "/session/email-login/adasdad.json"
expect(response.status).to eq(200)
-
- expect(CGI.unescapeHTML(response.body)).to match(
+ expect(JSON.parse(response.body)["error"]).to eq(
I18n.t('email_login.invalid_token')
)
end
@@ -41,11 +94,11 @@ RSpec.describe SessionController do
it 'should return the right response' do
email_token.update!(created_at: 999.years.ago)
- get "/session/email-login/#{email_token.token}"
+ post "/session/email-login/#{email_token.token}.json"
expect(response.status).to eq(200)
- expect(CGI.unescapeHTML(response.body)).to match(
+ expect(JSON.parse(response.body)["error"]).to eq(
I18n.t('email_login.invalid_token')
)
end
@@ -54,37 +107,39 @@ RSpec.describe SessionController do
context 'valid token' do
it 'returns success' do
- get "/session/email-login/#{email_token.token}"
+ post "/session/email-login/#{email_token.token}.json"
- expect(response).to redirect_to("/")
+ expect(JSON.parse(response.body)["success"]).to eq("OK")
+ expect(session[:current_user_id]).to eq(user.id)
end
it 'fails when local logins via email is disabled' do
SiteSetting.enable_local_logins_via_email = false
- get "/session/email-login/#{email_token.token}"
+ post "/session/email-login/#{email_token.token}.json"
expect(response.status).to eq(404)
+ expect(session[:current_user_id]).to eq(nil)
end
it 'fails when local logins is disabled' do
SiteSetting.enable_local_logins = false
- get "/session/email-login/#{email_token.token}"
+ post "/session/email-login/#{email_token.token}.json"
expect(response.status).to eq(500)
+ expect(session[:current_user_id]).to eq(nil)
end
it "doesn't log in the user when not approved" do
SiteSetting.must_approve_users = true
- get "/session/email-login/#{email_token.token}"
+ post "/session/email-login/#{email_token.token}.json"
expect(response.status).to eq(200)
- expect(CGI.unescapeHTML(response.body)).to include(
- I18n.t("login.not_approved")
- )
+ expect(JSON.parse(response.body)["error"]).to eq(I18n.t("login.not_approved"))
+ expect(session[:current_user_id]).to eq(nil)
end
context "when admin IP address is not valid" do
@@ -99,13 +154,14 @@ RSpec.describe SessionController do
end
it 'returns the right response' do
- get "/session/email-login/#{email_token.token}"
+ post "/session/email-login/#{email_token.token}.json"
expect(response.status).to eq(200)
- expect(CGI.unescapeHTML(response.body)).to include(
+ expect(JSON.parse(response.body)["error"]).to eq(
I18n.t("login.admin_not_allowed_from_ip_address", username: user.username)
)
+ expect(session[:current_user_id]).to eq(nil)
end
end
@@ -122,13 +178,14 @@ RSpec.describe SessionController do
it 'returns the right response' do
ActionDispatch::Request.any_instance.stubs(:remote_ip).returns(permitted_ip_address)
- get "/session/email-login/#{email_token.token}"
+ post "/session/email-login/#{email_token.token}.json"
expect(response.status).to eq(200)
- expect(CGI.unescapeHTML(response.body)).to include(
+ expect(JSON.parse(response.body)["error"]).to eq(
I18n.t("login.not_allowed_from_ip_address", username: user.username)
)
+ expect(session[:current_user_id]).to eq(nil)
end
end
@@ -138,63 +195,48 @@ RSpec.describe SessionController do
suspended_at: Time.zone.now
)
- get "/session/email-login/#{email_token.token}"
+ post "/session/email-login/#{email_token.token}.json"
expect(response.status).to eq(200)
- expect(CGI.unescapeHTML(response.body)).to include(I18n.t("login.suspended",
- date: I18n.l(user.suspended_till, format: :date_only)
+ expect(JSON.parse(response.body)["error"]).to eq(
+ I18n.t("login.suspended", date: I18n.l(user.suspended_till, format: :date_only)
))
+ expect(session[:current_user_id]).to eq(nil)
end
context 'user has 2-factor logins' do
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
- get "/session/email-login/#{email_token.token}"
-
- expect(response.status).to eq(200)
-
- response_body = CGI.unescapeHTML(response.body)
-
- expect(response_body).to include(I18n.t(
- "login.second_factor_title"
- ))
-
- expect(response_body).to_not include(I18n.t(
- "login.invalid_second_factor_code"
- ))
- end
- end
-
describe 'errors on incorrect 2-factor' do
context 'when using totp method' do
it 'does not log in with incorrect two factor' do
- post "/session/email-login/#{email_token.token}", params: {
+ post "/session/email-login/#{email_token.token}.json", params: {
second_factor_token: "0000",
second_factor_method: UserSecondFactor.methods[:totp]
}
expect(response.status).to eq(200)
- expect(CGI.unescapeHTML(response.body)).to include(I18n.t(
- "login.invalid_second_factor_code"
- ))
+ expect(JSON.parse(response.body)["error"]).to eq(
+ I18n.t("login.invalid_second_factor_code")
+ )
+ expect(session[:current_user_id]).to eq(nil)
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: {
+ post "/session/email-login/#{email_token.token}.json", 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"
- ))
+ expect(JSON.parse(response.body)["error"]).to eq(
+ I18n.t("login.invalid_second_factor_code")
+ )
+ expect(session[:current_user_id]).to eq(nil)
end
end
end
@@ -202,22 +244,24 @@ RSpec.describe SessionController do
describe 'allows successful 2-factor' do
context 'when using totp method' do
it 'logs in correctly' do
- post "/session/email-login/#{email_token.token}", params: {
+ post "/session/email-login/#{email_token.token}.json", params: {
second_factor_token: ROTP::TOTP.new(user_second_factor.data).now,
second_factor_method: UserSecondFactor.methods[:totp]
}
- expect(response).to redirect_to("/")
+ expect(JSON.parse(response.body)["success"]).to eq("OK")
+ expect(session[:current_user_id]).to eq(user.id)
end
end
context 'when using backup code method' do
it 'logs in correctly' do
- post "/session/email-login/#{email_token.token}", params: {
+ post "/session/email-login/#{email_token.token}.json", params: {
second_factor_token: "iAmValidBackupCode",
second_factor_method: UserSecondFactor.methods[:backup_codes]
}
- expect(response).to redirect_to("/")
+ expect(JSON.parse(response.body)["success"]).to eq("OK")
+ expect(session[:current_user_id]).to eq(user.id)
end
end
end