mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
Block passwords that are in the top 5000 most common passwords. Site setting block_common_passwords can disable this feature.
This commit is contained in:
@@ -15,6 +15,7 @@ Discourse.CreateAccountController = Discourse.Controller.extend(Discourse.ModalF
|
|||||||
accountChallenge: 0,
|
accountChallenge: 0,
|
||||||
formSubmitted: false,
|
formSubmitted: false,
|
||||||
rejectedEmails: Em.A([]),
|
rejectedEmails: Em.A([]),
|
||||||
|
rejectedPasswords: Em.A([]),
|
||||||
prefilledUsername: null,
|
prefilledUsername: null,
|
||||||
|
|
||||||
submitDisabled: function() {
|
submitDisabled: function() {
|
||||||
@@ -280,12 +281,19 @@ Discourse.CreateAccountController = Discourse.Controller.extend(Discourse.ModalF
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.get('rejectedPasswords').contains(password)) {
|
||||||
|
return Discourse.InputValidation.create({
|
||||||
|
failed: true,
|
||||||
|
reason: I18n.t('user.password.common')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Looks good!
|
// Looks good!
|
||||||
return Discourse.InputValidation.create({
|
return Discourse.InputValidation.create({
|
||||||
ok: true,
|
ok: true,
|
||||||
reason: I18n.t('user.password.ok')
|
reason: I18n.t('user.password.ok')
|
||||||
});
|
});
|
||||||
}.property('accountPassword'),
|
}.property('accountPassword', 'rejectedPasswords.@each'),
|
||||||
|
|
||||||
fetchConfirmationValue: function() {
|
fetchConfirmationValue: function() {
|
||||||
var createAccountController = this;
|
var createAccountController = this;
|
||||||
@@ -297,7 +305,7 @@ Discourse.CreateAccountController = Discourse.Controller.extend(Discourse.ModalF
|
|||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
createAccount: function() {
|
createAccount: function() {
|
||||||
var createAccountController = this;
|
var self = this;
|
||||||
this.set('formSubmitted', true);
|
this.set('formSubmitted', true);
|
||||||
var name = this.get('accountName');
|
var name = this.get('accountName');
|
||||||
var email = this.get('accountEmail');
|
var email = this.get('accountEmail');
|
||||||
@@ -307,21 +315,24 @@ Discourse.CreateAccountController = Discourse.Controller.extend(Discourse.ModalF
|
|||||||
var challenge = this.get('accountChallenge');
|
var challenge = this.get('accountChallenge');
|
||||||
return Discourse.User.createAccount(name, email, password, username, passwordConfirm, challenge).then(function(result) {
|
return Discourse.User.createAccount(name, email, password, username, passwordConfirm, challenge).then(function(result) {
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
createAccountController.flash(result.message);
|
self.flash(result.message);
|
||||||
createAccountController.set('complete', true);
|
self.set('complete', true);
|
||||||
} else {
|
} else {
|
||||||
createAccountController.flash(result.message || I18n.t('create_account.failed'), 'error');
|
self.flash(result.message || I18n.t('create_account.failed'), 'error');
|
||||||
if (result.errors && result.errors.email && result.values) {
|
if (result.errors && result.errors.email && result.errors.email.length > 0 && result.values) {
|
||||||
createAccountController.get('rejectedEmails').pushObject(result.values.email);
|
self.get('rejectedEmails').pushObject(result.values.email);
|
||||||
}
|
}
|
||||||
createAccountController.set('formSubmitted', false);
|
if (result.errors && result.errors.password && result.errors.password.length > 0) {
|
||||||
|
self.get('rejectedPasswords').pushObject(password);
|
||||||
|
}
|
||||||
|
self.set('formSubmitted', false);
|
||||||
}
|
}
|
||||||
if (result.active) {
|
if (result.active) {
|
||||||
return window.location.reload();
|
return window.location.reload();
|
||||||
}
|
}
|
||||||
}, function() {
|
}, function() {
|
||||||
createAccountController.set('formSubmitted', false);
|
self.set('formSubmitted', false);
|
||||||
return createAccountController.flash(I18n.t('create_account.failed'), 'error');
|
return self.flash(I18n.t('create_account.failed'), 'error');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Discourse.CreateAccountView = Discourse.ModalBodyView.extend({
|
|||||||
Em.run.schedule('afterRender', function() {
|
Em.run.schedule('afterRender', function() {
|
||||||
$("input[type='text'], input[type='password']").keydown(function(e) {
|
$("input[type='text'], input[type='password']").keydown(function(e) {
|
||||||
if (createAccountController.get('submitDisabled') === false && e.keyCode === 13) {
|
if (createAccountController.get('submitDisabled') === false && e.keyCode === 13) {
|
||||||
createAccountController.createAccount();
|
createAccountController.send('createAccount');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -356,6 +356,7 @@ en:
|
|||||||
password:
|
password:
|
||||||
title: "Password"
|
title: "Password"
|
||||||
too_short: "Your password is too short."
|
too_short: "Your password is too short."
|
||||||
|
common: "That password is too common."
|
||||||
ok: "Your password looks good."
|
ok: "Your password looks good."
|
||||||
instructions: "Must be at least %{count} characters."
|
instructions: "Must be at least %{count} characters."
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,8 @@ en:
|
|||||||
cant_send_pm: "Sorry, you cannot send a private message to that user."
|
cant_send_pm: "Sorry, you cannot send a private message to that user."
|
||||||
user:
|
user:
|
||||||
attributes:
|
attributes:
|
||||||
|
password:
|
||||||
|
common: "is one of the top 5000 most common passwords. Please use a more secure password."
|
||||||
ip_address:
|
ip_address:
|
||||||
signup_not_allowed: "Signup is not allowed from this account."
|
signup_not_allowed: "Signup is not allowed from this account."
|
||||||
|
|
||||||
@@ -606,6 +608,7 @@ en:
|
|||||||
login_required: "Require authentication to read posts"
|
login_required: "Require authentication to read posts"
|
||||||
|
|
||||||
min_password_length: "Minimum password length."
|
min_password_length: "Minimum password length."
|
||||||
|
block_common_passwords: "Don't allow passwords that are in the top 5000 most common passwords."
|
||||||
enable_local_logins: "Enable traditional, local username and password authentication"
|
enable_local_logins: "Enable traditional, local username and password authentication"
|
||||||
enable_local_account_create: "Enable creating new local accounts"
|
enable_local_account_create: "Enable creating new local accounts"
|
||||||
enable_google_logins: "Enable Google authentication"
|
enable_google_logins: "Enable Google authentication"
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ users:
|
|||||||
min_password_length:
|
min_password_length:
|
||||||
client: true
|
client: true
|
||||||
default: 8
|
default: 8
|
||||||
|
block_common_passwords: true
|
||||||
enable_google_logins:
|
enable_google_logins:
|
||||||
client: true
|
client: true
|
||||||
default: true
|
default: true
|
||||||
|
|||||||
10000
lib/common_passwords/10k-common-passwords.txt
Executable file
10000
lib/common_passwords/10k-common-passwords.txt
Executable file
File diff suppressed because it is too large
Load Diff
50
lib/common_passwords/common_passwords.rb
Normal file
50
lib/common_passwords/common_passwords.rb
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# CommonPasswords will check a given password against a list of the most commonly used passwords.
|
||||||
|
# The list comes from https://xato.net/passwords/more-top-worst-passwords/#.UrR1AHmpxs4
|
||||||
|
# The list is stored in Redis at a key that is shared by all sites in a multisite config.
|
||||||
|
#
|
||||||
|
# If the password file is changed, you need to add a migration that deletes the list from redis
|
||||||
|
# so it gets re-populated:
|
||||||
|
#
|
||||||
|
# $redis.without_namespace.del CommonPasswords::LIST_KEY
|
||||||
|
|
||||||
|
class CommonPasswords
|
||||||
|
|
||||||
|
PASSWORD_FILE = File.join(Rails.root, 'lib', 'common_passwords', '10k-common-passwords.txt')
|
||||||
|
LIST_KEY = 'discourse-common-passwords'
|
||||||
|
|
||||||
|
@mutex = Mutex.new
|
||||||
|
|
||||||
|
def self.common_password?(password)
|
||||||
|
return false unless password.present?
|
||||||
|
password_list.include?(password)
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
class RedisPasswordList
|
||||||
|
def include?(password)
|
||||||
|
CommonPasswords.redis.sismember CommonPasswords::LIST_KEY, password
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.password_list
|
||||||
|
@mutex.synchronize do
|
||||||
|
load_passwords unless redis.exists(LIST_KEY)
|
||||||
|
end
|
||||||
|
RedisPasswordList.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.redis
|
||||||
|
$redis.without_namespace
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.load_passwords
|
||||||
|
passwords = File.readlines(PASSWORD_FILE)
|
||||||
|
redis.sadd LIST_KEY, passwords[0,5000].map!(&:chomp)
|
||||||
|
rescue Errno::ENOENT
|
||||||
|
# tolerate this so we don't block signups
|
||||||
|
Rails.logger.error "Common passwords file #{PASSWORD_FILE} is not found! Common password checking is skipped."
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
@@ -24,6 +24,11 @@ class DiscourseRedis
|
|||||||
@redis = DiscourseRedis.raw_connection(@config)
|
@redis = DiscourseRedis.raw_connection(@config)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def without_namespace
|
||||||
|
# Only use this if you want to store and fetch data that's shared between sites
|
||||||
|
@redis
|
||||||
|
end
|
||||||
|
|
||||||
def url
|
def url
|
||||||
self.class.url(@config)
|
self.class.url(@config)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
require_dependency "common_passwords/common_passwords"
|
||||||
|
|
||||||
class PasswordValidator < ActiveModel::EachValidator
|
class PasswordValidator < ActiveModel::EachValidator
|
||||||
|
|
||||||
def validate_each(record, attribute, value)
|
def validate_each(record, attribute, value)
|
||||||
@@ -6,6 +8,8 @@ class PasswordValidator < ActiveModel::EachValidator
|
|||||||
record.errors.add(attribute, :blank)
|
record.errors.add(attribute, :blank)
|
||||||
elsif value.length < SiteSetting.min_password_length
|
elsif value.length < SiteSetting.min_password_length
|
||||||
record.errors.add(attribute, :too_short, count: SiteSetting.min_password_length)
|
record.errors.add(attribute, :too_short, count: SiteSetting.min_password_length)
|
||||||
|
elsif SiteSetting.block_common_passwords && CommonPasswords.common_password?(value)
|
||||||
|
record.errors.add(attribute, :common)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
67
spec/components/common_passwords/common_passwords_spec.rb
Normal file
67
spec/components/common_passwords/common_passwords_spec.rb
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
require "spec_helper"
|
||||||
|
require_dependency "common_passwords/common_passwords"
|
||||||
|
|
||||||
|
describe CommonPasswords do
|
||||||
|
|
||||||
|
it "the passwords file should exist" do
|
||||||
|
File.exists?(described_class::PASSWORD_FILE).should eq(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#common_password?" do
|
||||||
|
before { described_class.stubs(:redis).returns(stub_everything) }
|
||||||
|
|
||||||
|
subject { described_class.common_password? @password }
|
||||||
|
|
||||||
|
it "returns false if password isn't in the common passwords list" do
|
||||||
|
described_class.stubs(:password_list).returns(stub_everything(:include? => false))
|
||||||
|
@password = 'uncommonPassword'
|
||||||
|
subject.should eq(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false if password is nil" do
|
||||||
|
described_class.expects(:password_list).never
|
||||||
|
@password = nil
|
||||||
|
subject.should eq(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false if password is blank" do
|
||||||
|
described_class.expects(:password_list).never
|
||||||
|
@password = ""
|
||||||
|
subject.should eq(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns true if password is in the common passwords list" do
|
||||||
|
described_class.stubs(:password_list).returns(stub_everything(:include? => true))
|
||||||
|
@password = "password"
|
||||||
|
subject.should eq(true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#password_list' do
|
||||||
|
it "loads the passwords file if redis doesn't have it" do
|
||||||
|
mock_redis = mock("redis")
|
||||||
|
mock_redis.stubs(:exists).returns(false)
|
||||||
|
described_class.stubs(:redis).returns(mock_redis)
|
||||||
|
described_class.expects(:load_passwords).returns([])
|
||||||
|
list = described_class.password_list
|
||||||
|
list.should respond_to(:include?)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't load the passwords file if redis has it" do
|
||||||
|
mock_redis = mock("redis")
|
||||||
|
mock_redis.stubs(:exists).returns(true)
|
||||||
|
described_class.stubs(:redis).returns(mock_redis)
|
||||||
|
described_class.expects(:load_passwords).never
|
||||||
|
list = described_class.password_list
|
||||||
|
list.should respond_to(:include?)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "missing password file" do
|
||||||
|
it "tolerates it" do
|
||||||
|
described_class.stubs(:redis).returns(stub_everything(sismember: false))
|
||||||
|
File.stubs(:readlines).with(described_class::PASSWORD_FILE).raises(Errno::ENOENT)
|
||||||
|
described_class.common_password?("password").should eq(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
require 'spec_helper'
|
require 'spec_helper'
|
||||||
|
require_dependency "common_passwords/common_passwords"
|
||||||
|
|
||||||
describe PasswordValidator do
|
describe PasswordValidator do
|
||||||
|
|
||||||
@@ -8,6 +9,11 @@ describe PasswordValidator do
|
|||||||
context "password required" do
|
context "password required" do
|
||||||
let(:record) { u = Fabricate.build(:user, password: @password); u.password_required!; u }
|
let(:record) { u = Fabricate.build(:user, password: @password); u.password_required!; u }
|
||||||
|
|
||||||
|
context "password is not common" do
|
||||||
|
before do
|
||||||
|
CommonPasswords.any_instance.stubs(:common_password?).returns(false)
|
||||||
|
end
|
||||||
|
|
||||||
context "min password length is 8" do
|
context "min password length is 8" do
|
||||||
before { SiteSetting.stubs(:min_password_length).returns(8) }
|
before { SiteSetting.stubs(:min_password_length).returns(8) }
|
||||||
|
|
||||||
@@ -47,6 +53,27 @@ describe PasswordValidator do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context "password is commonly used" do
|
||||||
|
before do
|
||||||
|
CommonPasswords.any_instance.stubs(:common_password?).returns(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "adds an error when block_common_passwords is enabled" do
|
||||||
|
SiteSetting.stubs(:block_common_passwords).returns(true)
|
||||||
|
@password = "password"
|
||||||
|
validate
|
||||||
|
record.errors[:password].should be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't add an error when block_common_passwords is disabled" do
|
||||||
|
SiteSetting.stubs(:block_common_passwords).returns(false)
|
||||||
|
@password = "password"
|
||||||
|
validate
|
||||||
|
record.errors[:password].should_not be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context "password not required" do
|
context "password not required" do
|
||||||
let(:record) { Fabricate.build(:user, password: @password) }
|
let(:record) { Fabricate.build(:user, password: @password) }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user