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 @@ +
+
+ +
+ +
+
+ {{#if model.error}} +
+ {{model.error}} +
+ {{/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}} +

{{i18n "email_login.confirm_title" site_name=siteSettings.title}}

+

{{i18n "email_login.logging_in_as" email=model.token_email}}

+ {{/if}} + + {{d-button label="email_login.confirm_button" action=(action "finishLogin") class="btn-primary"}} + {{/if}} +
+
+
+ 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%> -
-
- <%= form_tag(method: "post") do%> -

<%=t "login.second_factor_title" %>

- <%= label_tag(:second_factor_token, t("login.second_factor_description")) %> -
<%= render 'common/second_factor_text_field' %>
- <%= 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 %> - - <%= 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