FEATURE: login by a link from email

Co-authored-by: tgxworld <tgx@discourse.org>
This commit is contained in:
Erick Guan 2017-04-20 17:17:24 +02:00 committed by Guo Xiang Tan
parent f9280617d0
commit 03b3e57a44
17 changed files with 640 additions and 101 deletions

View File

@ -20,48 +20,54 @@ export default Ember.Controller.extend(ModalFunctionality, {
}, },
actions: { actions: {
submit() {
if (this.get('submitDisabled')) return false;
this.set('disabled', true);
ajax('/session/forgot_password', {
data: { login: this.get('accountEmailOrUsername').trim() },
type: 'POST'
}).then(data => {
const escaped = escapeExpression(this.get('accountEmailOrUsername'));
const isEmail = this.get('accountEmailOrUsername').match(/@/);
let key = 'forgot_password.complete_' + (isEmail ? 'email' : 'username');
let extraClass;
if (data.user_found === true) {
key += '_found';
this.set('accountEmailOrUsername', '');
this.set('offerHelp', I18n.t(key, {email: escaped, username: escaped}));
} else {
if (data.user_found === false) {
key += '_not_found';
extraClass = 'error';
}
this.flash(I18n.t(key, {email: escaped, username: escaped}), extraClass);
}
}).catch(e => {
this.flash(extractError(e), 'error');
}).finally(() => {
setTimeout(() => this.set('disabled', false), 1000);
});
return false;
},
ok() { ok() {
this.send('closeModal'); this.send('closeModal');
}, },
help() { help() {
this.setProperties({ offerHelp: I18n.t('forgot_password.help'), helpSeen: true }); this.setProperties({ offerHelp: I18n.t('forgot_password.help'), helpSeen: true });
} },
}
resetPassword() {
return this._submit('/session/forgot_password', 'forgot_password.complete');
},
emailLogin() {
return this._submit('/u/email-login', 'email_login.complete');
}
},
_submit(route, translationKey) {
if (this.get('submitDisabled')) return false;
this.set('disabled', true);
ajax(route, {
data: { login: this.get('accountEmailOrUsername').trim() },
type: 'POST'
}).then(data => {
const escaped = escapeExpression(this.get('accountEmailOrUsername'));
const isEmail = this.get('accountEmailOrUsername').match(/@/);
let key = `${translationKey}_${isEmail ? 'email' : 'username'}`;
let extraClass;
if (data.user_found === true) {
key += '_found';
this.set('accountEmailOrUsername', '');
this.set('offerHelp', I18n.t(key, { email: escaped, username: escaped }));
} else {
if (data.user_found === false) {
key += '_not_found';
extraClass = 'error';
}
this.flash(I18n.t(key, { email: escaped, username: escaped }), extraClass);
}
}).catch(e => {
this.flash(extractError(e), 'error');
}).finally(() => {
this.set('disabled', false);
});
return false;
},
}); });

View File

@ -9,10 +9,16 @@
{{/d-modal-body}} {{/d-modal-body}}
<div class="modal-footer"> <div class="modal-footer">
{{#unless offerHelp}} {{#unless offerHelp}}
{{d-button action="submit" {{d-button action="resetPassword"
label="forgot_password.reset" label="forgot_password.reset"
disabled=submitDisabled disabled=submitDisabled
class="btn-primary"}} class="btn-primary"}}
{{#if siteSettings.enable_local_logins_via_email}}
{{d-button action="emailLogin"
label="email_login.label"
disabled=submitDisabled
class="email-login"}}
{{/if}}
{{else}} {{else}}
{{d-button class="btn-large btn-primary" {{d-button class="btn-large btn-primary"
label="forgot_password.button_ok" label="forgot_password.button_ok"

View File

@ -7,9 +7,10 @@ class SessionController < ApplicationController
render body: nil, status: 500 render body: nil, status: 500
end end
before_action :check_local_login_allowed, only: %i(create forgot_password) before_action :check_local_login_allowed, only: %i(create forgot_password email_login)
before_action :rate_limit_login, only: %i(create email_login)
skip_before_action :redirect_to_login_if_required skip_before_action :redirect_to_login_if_required
skip_before_action :preload_json, :check_xhr, only: ['sso', 'sso_login', 'become', 'sso_provider', 'destroy'] skip_before_action :preload_json, :check_xhr, only: %i(sso sso_login become sso_provider destroy email_login)
ACTIVATE_USER_KEY = "activate_user" ACTIVATE_USER_KEY = "activate_user"
@ -187,9 +188,6 @@ class SessionController < ApplicationController
end end
def create def create
RateLimiter.new(nil, "login-hr-#{request.remote_ip}", SiteSetting.max_logins_per_ip_per_hour, 1.hour).performed!
RateLimiter.new(nil, "login-min-#{request.remote_ip}", SiteSetting.max_logins_per_ip_per_minute, 1.minute).performed!
params.require(:login) params.require(:login)
params.require(:password) params.require(:password)
@ -208,7 +206,7 @@ class SessionController < ApplicationController
# If the site requires user approval and the user is not approved yet # If the site requires user approval and the user is not approved yet
if login_not_approved_for?(user) if login_not_approved_for?(user)
login_not_approved render json: login_not_approved
return return
end end
@ -220,20 +218,31 @@ class SessionController < ApplicationController
return return
end end
if user.suspended? if payload = login_error_check(user)
failed_to_login(user) render json: payload
return else
(user.active && user.email_confirmed?) ? login(user) : not_activated(user)
end end
end
if ScreenedIpAddress.should_block?(request.remote_ip) def email_login
return not_allowed_from_ip_address(user) raise Discourse::NotFound if !SiteSetting.enable_local_logins_via_email
if EmailToken.valid_token_format?(params[:token]) && (user = EmailToken.confirm(params[:token]))
if login_not_approved_for?(user)
@error = login_not_approved[:error]
return render layout: 'no_ember'
elsif payload = login_error_check(user)
@error = payload[:error]
return render layout: 'no_ember'
else
log_on_user(user)
redirect_to path("/")
end
else
@error = I18n.t('email_login.invalid_token')
return render layout: 'no_ember'
end end
if ScreenedIpAddress.block_admin_login?(user, request.remote_ip)
return admin_not_allowed_from_ip_address(user)
end
(user.active && user.email_confirmed?) ? login(user) : not_activated(user)
end end
def forgot_password def forgot_password
@ -291,6 +300,18 @@ class SessionController < ApplicationController
private private
def login_error_check(user)
return failed_to_login(user) if user.suspended?
if ScreenedIpAddress.should_block?(request.remote_ip)
return not_allowed_from_ip_address(user)
end
if ScreenedIpAddress.block_admin_login?(user, request.remote_ip)
return admin_not_allowed_from_ip_address(user)
end
end
def login_not_approved_for?(user) def login_not_approved_for?(user)
SiteSetting.must_approve_users? && !user.approved? && !user.admin? SiteSetting.must_approve_users? && !user.approved? && !user.admin?
end end
@ -300,7 +321,7 @@ class SessionController < ApplicationController
end end
def login_not_approved def login_not_approved
render json: { error: I18n.t("login.not_approved") } { error: I18n.t("login.not_approved") }
end end
def not_activated(user) def not_activated(user)
@ -314,19 +335,21 @@ class SessionController < ApplicationController
end end
def not_allowed_from_ip_address(user) def not_allowed_from_ip_address(user)
render json: { error: I18n.t("login.not_allowed_from_ip_address", username: user.username) } { error: I18n.t("login.not_allowed_from_ip_address", username: user.username) }
end end
def admin_not_allowed_from_ip_address(user) def admin_not_allowed_from_ip_address(user)
render json: { error: I18n.t("login.admin_not_allowed_from_ip_address", username: user.username) } { error: I18n.t("login.admin_not_allowed_from_ip_address", username: user.username) }
end end
def failed_to_login(user) def failed_to_login(user)
message = user.suspend_reason ? "login.suspended_with_reason" : "login.suspended" message = user.suspend_reason ? "login.suspended_with_reason" : "login.suspended"
render json: { {
error: I18n.t(message, date: I18n.l(user.suspended_till, format: :date_only), error: I18n.t(message,
reason: Rack::Utils.escape_html(user.suspend_reason)), date: I18n.l(user.suspended_till, format: :date_only),
reason: Rack::Utils.escape_html(user.suspend_reason)
),
reason: 'suspended' reason: 'suspended'
} }
end end
@ -342,6 +365,22 @@ class SessionController < ApplicationController
end end
end end
def rate_limit_login
RateLimiter.new(
nil,
"login-hr-#{request.remote_ip}",
SiteSetting.max_logins_per_ip_per_hour,
1.hour
).performed!
RateLimiter.new(
nil,
"login-min-#{request.remote_ip}",
SiteSetting.max_logins_per_ip_per_minute,
1.minute
).performed!
end
def render_sso_error(status:, text:) def render_sso_error(status:, text:)
@sso_error = text @sso_error = text
render status: status, layout: 'no_ember' render status: status, layout: 'no_ember'

View File

@ -18,7 +18,7 @@ class UsersController < ApplicationController
skip_before_action :check_xhr, only: [ skip_before_action :check_xhr, only: [
:show, :badges, :password_reset, :update, :account_created, :show, :badges, :password_reset, :update, :account_created,
:activate_account, :perform_account_activation, :user_preferences_redirect, :avatar, :activate_account, :perform_account_activation, :user_preferences_redirect, :avatar,
:my_redirect, :toggle_anon, :admin_login, :confirm_admin :my_redirect, :toggle_anon, :admin_login, :confirm_admin, :email_login
] ]
before_action :respond_to_suspicious_request, only: [:create] before_action :respond_to_suspicious_request, only: [:create]
@ -37,6 +37,7 @@ class UsersController < ApplicationController
:update_activation_email, :update_activation_email,
:password_reset, :password_reset,
:confirm_email_token, :confirm_email_token,
:email_login,
:admin_login, :admin_login,
:confirm_admin] :confirm_admin]
@ -563,6 +564,7 @@ class UsersController < ApplicationController
elsif params[:token].present? elsif params[:token].present?
if EmailToken.valid_token_format?(params[:token]) if EmailToken.valid_token_format?(params[:token])
@user = EmailToken.confirm(params[:token]) @user = EmailToken.confirm(params[:token])
if @user&.admin? if @user&.admin?
log_on_user(@user) log_on_user(@user)
return redirect_to path("/") return redirect_to path("/")
@ -580,6 +582,40 @@ class UsersController < ApplicationController
render layout: false render layout: false
end end
def email_login
raise Discourse::NotFound if !SiteSetting.enable_local_logins_via_email
return redirect_to path("/") if current_user
expires_now
params.require(:login)
RateLimiter.new(nil, "email-login-hour-#{request.remote_ip}", 6, 1.hour).performed!
RateLimiter.new(nil, "email-login-min-#{request.remote_ip}", 3, 1.minute).performed!
user = User.human_users.find_by_username_or_email(params[:login])
user_presence = user.present? && !user.staged
if user
RateLimiter.new(nil, "email-login-hour-#{user.id}", 6, 1.hour).performed!
RateLimiter.new(nil, "email-login-min-#{user.id}", 3, 1.minute).performed!
if user_presence
email_token = user.email_tokens.create!(email: user.email)
Jobs.enqueue(:critical_user_email,
type: :email_login,
user_id: user.id,
email_token: email_token.token
)
end
end
json = { result: "ok" }
json[:user_found] = user_presence unless SiteSetting.hide_email_address_taken
render json: json
rescue RateLimiter::LimitExceeded
render_json_error(I18n.t("rate_limiter.slow_down"))
end
def toggle_anon def toggle_anon
user = AnonymousShadowCreator.get_master(current_user) || user = AnonymousShadowCreator.get_master(current_user) ||
AnonymousShadowCreator.get(current_user) AnonymousShadowCreator.get(current_user)

View File

@ -12,10 +12,11 @@ class UserNotifications < ActionMailer::Base
include Email::BuildEmailHelper include Email::BuildEmailHelper
def signup(user, opts = {}) def signup(user, opts = {})
build_email(user.email, build_user_email_token_by_template(
template: "user_notifications.signup", "user_notifications.signup",
locale: user_locale(user), user,
email_token: opts[:email_token]) opts[:email_token]
)
end end
def signup_after_approval(user, opts = {}) def signup_after_approval(user, opts = {})
@ -33,38 +34,51 @@ class UserNotifications < ActionMailer::Base
end end
def confirm_old_email(user, opts = {}) def confirm_old_email(user, opts = {})
build_email(user.email, build_user_email_token_by_template(
template: "user_notifications.confirm_old_email", "user_notifications.confirm_old_email",
locale: user_locale(user), user,
email_token: opts[:email_token]) opts[:email_token]
)
end end
def confirm_new_email(user, opts = {}) def confirm_new_email(user, opts = {})
build_email(user.email, build_user_email_token_by_template(
template: "user_notifications.confirm_new_email", "user_notifications.confirm_new_email",
locale: user_locale(user), user,
email_token: opts[:email_token]) opts[:email_token]
)
end end
def forgot_password(user, opts = {}) def forgot_password(user, opts = {})
build_email(user.email, build_user_email_token_by_template(
template: user.has_password? ? "user_notifications.forgot_password" : "user_notifications.set_password", user.has_password? ? "user_notifications.forgot_password" : "user_notifications.set_password",
locale: user_locale(user), user,
email_token: opts[:email_token]) opts[:email_token]
)
end
def email_login(user, opts = {})
build_user_email_token_by_template(
"user_notifications.email_login",
user,
opts[:email_token]
)
end end
def admin_login(user, opts = {}) def admin_login(user, opts = {})
build_email(user.email, build_user_email_token_by_template(
template: "user_notifications.admin_login", "user_notifications.admin_login",
locale: user_locale(user), user,
email_token: opts[:email_token]) opts[:email_token]
)
end end
def account_created(user, opts = {}) def account_created(user, opts = {})
build_email(user.email, build_user_email_token_by_template(
template: "user_notifications.account_created", "user_notifications.account_created",
locale: user_locale(user), user,
email_token: opts[:email_token]) opts[:email_token]
)
end end
def account_silenced(user, opts = nil) def account_silenced(user, opts = nil)
@ -532,6 +546,15 @@ class UserNotifications < ActionMailer::Base
private private
def build_user_email_token_by_template(template, user, email_token)
build_email(
user.email,
template: template,
locale: user_locale(user),
email_token: email_token
)
end
def build_summary_for(user) def build_summary_for(user)
@site_name = SiteSetting.email_prefix.presence || SiteSetting.title # used by I18n @site_name = SiteSetting.email_prefix.presence || SiteSetting.title # used by I18n
@user = user @user = user

View File

@ -0,0 +1,17 @@
<%if @error%>
<div class='alert alert-error'>
<%= @error %>
</div>
<%end%>
<% content_for :title do %><%=t "email_login.title" %><% end %>
<%- content_for(:no_ember_head) do %>
<meta name="referrer" content="no-referrer">
<%= preload_script "ember_jquery" %>
<%= render_google_universal_analytics_code %>
<%- end %>
<%- content_for(:head) do %>
<meta name="referrer" content="no-referrer">
<%- end %>

View File

@ -1083,6 +1083,16 @@ en:
help: "Email not arriving? Be sure to check your spam folder first.<p>Not sure which email address you used? Enter an email address and well let you know if it exists here.</p><p>If you no longer have access to the email address on your account, please contact <a href='/about'>our helpful staff.</a></p>" help: "Email not arriving? Be sure to check your spam folder first.<p>Not sure which email address you used? Enter an email address and well let you know if it exists here.</p><p>If you no longer have access to the email address on your account, please contact <a href='/about'>our helpful staff.</a></p>"
button_ok: "OK" button_ok: "OK"
button_help: "Help" button_help: "Help"
email_login:
label: "Login With Email"
complete_username: "If an account matches the username <b>%{username}</b>, you should receive an email with a magic login link shortly."
complete_email: "If an account matches <b>%{email}</b>, you should receive an email with a magic login link shortly."
complete_username_found: "We found an account that matches the username <b>%{username}</b>, you should receive an email with a magic login link shortly."
complete_email_found: "We found an account that matches <b>%{email}</b>, you should receive an email with a magic login link shortly."
complete_username_not_found: "No account matches the username <b>%{username}</b>"
complete_email_not_found: "No account matches <b>%{email}</b>"
login: login:
title: "Log In" title: "Log In"
username: "User" username: "User"

View File

@ -648,6 +648,10 @@ en:
success: "You successfully changed your password and are now logged in." success: "You successfully changed your password and are now logged in."
success_unapproved: "You successfully changed your password." success_unapproved: "You successfully changed your password."
email_login:
invalid_token: "Sorry, that email login link is too old. Select the Log In button and use 'I forgot my password' to get a new link."
title: "Email login"
change_email: change_email:
confirmed: "Your email has been updated." confirmed: "Your email has been updated."
please_continue: "Continue to %{site_name}" please_continue: "Continue to %{site_name}"
@ -1149,6 +1153,7 @@ en:
sso_allows_all_return_paths: "Do not restrict the domain for return_paths provided by SSO (by default return path must be on current site)" sso_allows_all_return_paths: "Do not restrict the domain for return_paths provided by SSO (by default return path must be on current site)"
enable_local_logins: "Enable local username and password login based accounts. (Note: this must be enabled for invites to work)" enable_local_logins: "Enable local username and password login based accounts. (Note: this must be enabled for invites to work)"
enable_local_logins_via_email: "Email user logins via email."
allow_new_registrations: "Allow new user registrations. Uncheck this to prevent anyone from creating a new account." allow_new_registrations: "Allow new user registrations. Uncheck this to prevent anyone from creating a new account."
enable_signup_cta: "Show a notice to returning anonymous users prompting them to sign up for an account." enable_signup_cta: "Show a notice to returning anonymous users prompting them to sign up for an account."
enable_yahoo_logins: "Enable Yahoo authentication" enable_yahoo_logins: "Enable Yahoo authentication"
@ -1640,6 +1645,7 @@ en:
staged_users_disabled: "You must first enable 'staged users' before enabling this setting." staged_users_disabled: "You must first enable 'staged users' before enabling this setting."
reply_by_email_disabled: "You must first enable 'reply by email' before enabling this setting." reply_by_email_disabled: "You must first enable 'reply by email' before enabling this setting."
sso_url_is_empty: "You must set a 'sso url' before enabling this setting." sso_url_is_empty: "You must set a 'sso url' before enabling this setting."
enable_local_logins_disabled: "You must first enable 'enable local logins' before enabling this setting."
search: search:
within_post: "#%{post_number} by %{username}" within_post: "#%{post_number} by %{username}"
@ -2772,6 +2778,17 @@ en:
Click the following link to choose a new password: Click the following link to choose a new password:
%{base_url}/u/password-reset/%{email_token} %{base_url}/u/password-reset/%{email_token}
email_login:
title: "Email login link"
subject_template: "[%{email_prefix}] Email login link"
text_body_template: |
Somebody asked to login your account on [%{site_name}](%{base_url}).
If it was not you, you can safely ignore this email.
Click the following link to login:
%{base_url}/session/email-login/%{email_token}
set_password: set_password:
title: "Set Password" title: "Set Password"
subject_template: "[%{email_prefix}] Set Password" subject_template: "[%{email_prefix}] Set Password"

View File

@ -301,6 +301,7 @@ Discourse::Application.routes.draw do
get "session/sso_provider" => "session#sso_provider" get "session/sso_provider" => "session#sso_provider"
get "session/current" => "session#current" get "session/current" => "session#current"
get "session/csrf" => "session#csrf" get "session/csrf" => "session#csrf"
get "session/email-login/:token" => "session#email_login"
get "composer_messages" => "composer_messages#index" get "composer_messages" => "composer_messages#index"
post "composer/parse_html" => "composer#parse_html" post "composer/parse_html" => "composer#parse_html"
@ -330,6 +331,7 @@ Discourse::Application.routes.draw do
put "#{root_path}/update-activation-email" => "users#update_activation_email" put "#{root_path}/update-activation-email" => "users#update_activation_email"
get "#{root_path}/hp" => "users#get_honeypot_value" get "#{root_path}/hp" => "users#get_honeypot_value"
post "#{root_path}/email-login" => "users#email_login"
get "#{root_path}/admin-login" => "users#admin_login" get "#{root_path}/admin-login" => "users#admin_login"
put "#{root_path}/admin-login" => "users#admin_login" put "#{root_path}/admin-login" => "users#admin_login"
get "#{root_path}/admin-login/:token" => "users#admin_login" get "#{root_path}/admin-login/:token" => "users#admin_login"

View File

@ -240,6 +240,10 @@ login:
enable_local_logins: enable_local_logins:
client: true client: true
default: true default: true
enable_local_logins_via_email:
client: true
default: false
validator: "EnableLocalLoginsViaEmailValidator"
allow_new_registrations: allow_new_registrations:
client: true client: true
default: true default: true
@ -331,7 +335,6 @@ login:
default: 1440 default: 1440
min: 1 min: 1
max: 175200 max: 175200
users: users:
min_username_length: min_username_length:
client: true client: true

View File

@ -0,0 +1,14 @@
class EnableLocalLoginsViaEmailValidator
def initialize(opts = {})
@opts = opts
end
def valid_value?(val)
return true if val == 'f'
SiteSetting.enable_local_logins
end
def error_message
I18n.t('site_settings.errors.enable_local_logins_disabled')
end
end

View File

@ -0,0 +1,47 @@
require 'rails_helper'
RSpec.describe EnableLocalLoginsViaEmailValidator do
subject { described_class.new }
describe '#valid_value?' do
describe "when 'enable_local_logins' is false" do
before do
SiteSetting.enable_local_logins = false
end
describe 'when val is false' do
it 'should be valid' do
expect(subject.valid_value?('f')).to eq(true)
end
end
describe 'when value is true' do
it 'should not be valid' do
expect(subject.valid_value?('t')).to eq(false)
expect(subject.error_message).to eq(I18n.t(
'site_settings.errors.enable_local_logins_disabled'
))
end
end
end
describe "when 'enable_local_logins' is true" do
before do
SiteSetting.enable_local_logins = true
end
describe 'when val is false' do
it 'should be valid' do
expect(subject.valid_value?('f')).to eq(true)
end
end
describe 'when value is true' do
it 'should be valid' do
expect(subject.valid_value?('t')).to eq(true)
end
end
end
end
end

View File

@ -8,7 +8,7 @@ describe SessionController do
end end
end end
describe 'become' do describe '#become' do
let!(:user) { Fabricate(:user) } let!(:user) { Fabricate(:user) }
it "does not work when not in development mode" do it "does not work when not in development mode" do
@ -26,7 +26,7 @@ describe SessionController do
end end
end end
describe '.sso_login' do describe '#sso_login' do
before do before do
@sso_url = "http://somesite.com/discourse_sso" @sso_url = "http://somesite.com/discourse_sso"
@ -410,7 +410,7 @@ describe SessionController do
end end
end end
describe '.sso_provider' do describe '#sso_provider' do
before do before do
SiteSetting.enable_sso_provider = true SiteSetting.enable_sso_provider = true
SiteSetting.enable_sso = false SiteSetting.enable_sso = false
@ -470,7 +470,7 @@ describe SessionController do
end end
end end
describe '.create' do describe '#create' do
let(:user) { Fabricate(:user) } let(:user) { Fabricate(:user) }
@ -515,7 +515,9 @@ describe SessionController do
login: user.username, password: 'sssss' login: user.username, password: 'sssss'
}, format: :json }, format: :json
expect(::JSON.parse(response.body)['error']).to be_present expect(::JSON.parse(response.body)['error']).to eq(
I18n.t("login.incorrect_username_email_or_password")
)
end end
end end
@ -526,7 +528,9 @@ describe SessionController do
login: user.username, password: ('s' * (User.max_password_length + 1)) login: user.username, password: ('s' * (User.max_password_length + 1))
}, format: :json }, format: :json
expect(::JSON.parse(response.body)['error']).to be_present expect(::JSON.parse(response.body)['error']).to eq(
I18n.t("login.incorrect_username_email_or_password")
)
end end
end end
@ -536,14 +540,15 @@ describe SessionController do
user.suspended_at = Time.now user.suspended_at = Time.now
user.save! user.save!
StaffActionLogger.new(user).log_user_suspend(user, "<strike>banned</strike>") StaffActionLogger.new(user).log_user_suspend(user, "<strike>banned</strike>")
post :create, params: { post :create, params: {
login: user.username, password: 'myawesomepassword' login: user.username, password: 'myawesomepassword'
}, format: :json }, format: :json
error = ::JSON.parse(response.body)['error'] expect(JSON.parse(response.body)['error']).to eq(I18n.t('login.suspended_with_reason',
expect(error).to be_present date: I18n.l(user.suspended_till, format: :date_only),
expect(error).to match(/banned/) reason: Rack::Utils.escape_html(user.suspend_reason)
expect(error).not_to match(/<strike>/) ))
end end
end end
@ -881,7 +886,7 @@ describe SessionController do
end end
end end
describe '.current' do describe '#current' do
context "when not logged in" do context "when not logged in" do
it "retuns 404" do it "retuns 404" do
get :current, format: :json get :current, format: :json

View File

@ -79,6 +79,28 @@ describe UserNotifications do
end end
describe '.email_login' do
let(:email_token) { user.email_tokens.create!(email: user.email).token }
subject { UserNotifications.email_login(user, email_token: email_token) }
it "generates the right email" do
expect(subject.to).to eq([user.email])
expect(subject.from).to eq([SiteSetting.notification_email])
expect(subject.subject).to eq(I18n.t(
'user_notifications.email_login.subject_template',
email_prefix: SiteSetting.title
))
expect(subject.body.to_s).to match(I18n.t(
'user_notifications.email_login.text_body_template',
site_name: SiteSetting.title,
base_url: Discourse.base_url,
email_token: email_token
))
end
end
describe '.digest' do describe '.digest' do
subject { UserNotifications.digest(user) } subject { UserNotifications.digest(user) }

View File

@ -0,0 +1,141 @@
require 'rails_helper'
RSpec.describe SessionController do
let(:email_token) { Fabricate(:email_token) }
let(:user) { email_token.user }
describe '#email_login' do
before do
SiteSetting.enable_local_logins_via_email = true
end
context 'missing token' do
it 'returns the right response' do
get "/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"
expect(response).to be_success
expect(CGI.unescapeHTML(response.body)).to match(
I18n.t('email_login.invalid_token')
)
end
context 'when token has expired' do
it 'should return the right response' do
email_token.update!(created_at: 999.years.ago)
get "/session/email-login/#{email_token.token}"
expect(response).to be_success
expect(CGI.unescapeHTML(response.body)).to match(
I18n.t('email_login.invalid_token')
)
end
end
end
context 'valid token' do
it 'returns success' do
get "/session/email-login/#{email_token.token}"
expect(response).to redirect_to("/")
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}"
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}"
expect(response.status).to eq(500)
end
it "doesn't log in the user when not approved" do
SiteSetting.must_approve_users = true
get "/session/email-login/#{email_token.token}"
expect(response.status).to eq(200)
expect(CGI.unescapeHTML(response.body)).to include(
I18n.t("login.not_approved")
)
end
context "when admin IP address is not valid" do
before do
Fabricate(:screened_ip_address,
ip_address: "111.111.11.11",
action_type: ScreenedIpAddress.actions[:allow_admin]
)
SiteSetting.use_admin_ip_whitelist = true
user.update!(admin: true)
end
it 'returns the right response' do
get "/session/email-login/#{email_token.token}"
expect(response.status).to eq(200)
expect(CGI.unescapeHTML(response.body)).to include(
I18n.t("login.admin_not_allowed_from_ip_address", username: user.username)
)
end
end
context "when IP address is blocked" do
let(:permitted_ip_address) { '111.234.23.11' }
before do
Fabricate(:screened_ip_address,
ip_address: permitted_ip_address,
action_type: ScreenedIpAddress.actions[:block]
)
end
it 'returns the right response' do
ActionDispatch::Request.any_instance.stubs(:remote_ip).returns(permitted_ip_address)
get "/session/email-login/#{email_token.token}"
expect(response.status).to eq(200)
expect(CGI.unescapeHTML(response.body)).to include(
I18n.t("login.not_allowed_from_ip_address", username: user.username)
)
end
end
it "fails when user is suspended" do
user.update!(
suspended_till: 2.days.from_now,
suspended_at: Time.zone.now
)
get "/session/email-login/#{email_token.token}"
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)
))
end
end
end
end

View File

@ -340,4 +340,65 @@ RSpec.describe UsersController do
expect(response).to redirect_to("/u/#{user.username_lower}/preferences") expect(response).to redirect_to("/u/#{user.username_lower}/preferences")
end end
end end
describe '#email_login' do
before do
SiteSetting.queue_jobs = true
SiteSetting.enable_local_logins_via_email = true
end
it "enqueues the right email" do
post "/u/email-login.json", params: { login: user.email }
expect(response).to be_success
expect(JSON.parse(response.body)['user_found']).to eq(true)
job_args = Jobs::CriticalUserEmail.jobs.last["args"].first
expect(job_args["user_id"]).to eq(user.id)
expect(job_args["type"]).to eq("email_login")
expect(job_args["email_token"]).to eq(user.email_tokens.last.token)
end
describe 'when enable_local_logins_via_email is disabled' do
before do
SiteSetting.enable_local_logins_via_email = false
end
it 'should return the right response' do
post "/u/email-login.json", params: { login: user.email }
expect(response.status).to eq(404)
end
end
describe 'when username or email is not valid' do
it 'should not enqueue the email to login' do
post "/u/email-login.json", params: { login: '@random' }
expect(response).to be_success
expect(JSON.parse(response.body)['user_found']).to eq(false)
expect(Jobs::CriticalUserEmail.jobs).to eq([])
end
end
describe 'when hide_email_address_taken is true' do
it 'should return the right response' do
SiteSetting.hide_email_address_taken = true
post "/u/email-login.json", params: { login: user.email }
expect(response).to be_success
expect(JSON.parse(response.body).has_key?('user_found')).to eq(false)
end
end
describe "when user is already logged in" do
it 'should redirect to the root path' do
sign_in(user)
post "/u/email-login.json", params: { login: user.email }
expect(response).to redirect_to("/")
end
end
end
end end

View File

@ -0,0 +1,90 @@
import { acceptance } from "helpers/qunit-helpers";
let userFound = false;
acceptance("Forgot password", {
settings: {
enable_local_logins_via_email: true
},
beforeEach() {
const response = object => {
return [
200,
{ "Content-Type": "application/json" },
object
];
};
server.post('/u/email-login', () => { // eslint-disable-line no-undef
return response({ "user_found": userFound });
});
}
});
QUnit.test("logging in via email", assert => {
visit("/");
click("header .login-button");
andThen(() => {
assert.ok(exists('.login-modal'), "it shows the login modal");
});
click('#forgot-password-link');
fillIn("#username-or-email", 'someuser');
click('.email-login');
andThen(() => {
assert.equal(
find(".alert-error").html(),
I18n.t('email_login.complete_username_not_found', { username: 'someuser' }),
'it should display the right error message'
);
});
fillIn("#username-or-email", 'someuser@gmail.com');
click('.email-login');
andThen(() => {
assert.equal(
find(".alert-error").html(),
I18n.t('email_login.complete_email_not_found', { email: 'someuser@gmail.com' }),
'it should display the right error message'
);
});
fillIn("#username-or-email", 'someuser');
andThen(() => {
userFound = true;
});
click('.email-login');
andThen(() => {
assert.equal(
find(".modal-body").html().trim(),
I18n.t('email_login.complete_username_found', { username: 'someuser' }),
'it should display the right message'
);
});
visit("/");
click("header .login-button");
andThen(() => {
assert.ok(exists('.login-modal'), "it shows the login modal");
});
click('#forgot-password-link');
fillIn("#username-or-email", 'someuser@gmail.com');
click('.email-login');
andThen(() => {
assert.equal(
find(".modal-body").html().trim(),
I18n.t('email_login.complete_email_found', { email: 'someuser@gmail.com' }),
'it should display the right message'
);
});
});