Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add LDAP authentication #4032

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .env.production.sample
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,42 @@ STREAMING_CLUSTER_NUM=1
# If you use Docker, you may want to assign UID/GID manually.
# UID=1000
# GID=1000

# LDAP configuration
# Uncomment next line if you want to use LDAP on your instance
# USE_LDAP=true
#
# Use only LDAP authentication (don't try other means)
# LDAP_ONLY=false
#
# LDAP_HOST=localhost
# LDAP_PORT=389
#
# LDAP attribute to find the user's e-mail. Necessary to create accounts
# for not existing users
# LDAP_MAIL_ATTRIBUTE="mail"
#
# On first login, this LDAP attribute will be used as the nickname for
# the user.
# LDAP_USERNAME_ATTRIBUTE="uid"
#
# Skip e-mail confirmation when creating an account via LDAP.
# LDAP_SKIP_EMAIL_CONFIRMATION=true
#
# ----- Using BIND_DN and BIND_PW
# LDAP_BIND_DN and LDAP_BIND_PW may be used if the mastodon instance
# should be able to connect to LDAP to find and search for users.
#
# LDAP_BIND_DN="cn=mastodon,dc=example,dc=com"
# LDAP_BIND_PW="password"
# LDAP_SEARCH_BASE="dc=example,dc=com"
#
# This is the filter with which to search for the user. %{username} will
# be replaced by the given login.
# LDAP_SEARCH_FILTER="uid=%{username}"
#
# ----- Using template
# This setting doesn't require a mastodon LDAP user. Use a template, and
# mastodon will try to login with the templated dn and password
#
# LDAP_BIND_TEMPLATE="uid=%{username},dc=example,dc=com"
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ gem 'http_accept_language', '~> 2.1'
gem 'httplog', '~> 0.99'
gem 'kaminari', '~> 1.0'
gem 'link_header', '~> 0.0'
gem 'net-ldap', '~> 0.16'
gem 'nokogiri', '~> 1.7'
gem 'oj', '~> 3.0'
gem 'ostatus2', '~> 2.0'
Expand Down
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ GEM
minitest (5.10.2)
msgpack (1.1.0)
multi_json (1.12.1)
net-ldap (0.16.0)
net-scp (1.2.1)
net-ssh (>= 2.6.5)
net-ssh (4.1.0)
Expand Down Expand Up @@ -516,6 +517,7 @@ DEPENDENCIES
link_header (~> 0.0)
lograge (~> 0.5)
microformats2 (~> 3.0)
net-ldap (~> 0.16)
nokogiri (~> 1.7)
oj (~> 3.0)
ostatus2 (~> 2.0)
Expand Down
6 changes: 5 additions & 1 deletion app/controllers/auth/registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ def after_inactive_sign_up_path_for(_resource)
end

def check_enabled_registrations
redirect_to root_path if single_user_mode? || !Setting.open_registrations
if single_user_mode? || !Setting.open_registrations
redirect_to root_path
elsif Rails.configuration.x.use_ldap
redirect_to new_user_session_path
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should probably be it's own method rather then overloading this one.

end
end

private
Expand Down
14 changes: 14 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
# last_emailed_at :datetime
# otp_backup_codes :string is an Array
# filtered_languages :string default([]), not null, is an Array
# ldap_dn :text
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, I agree with Gargron that putting this on the user model doesn't make a lot of sense. How does discourse do it with their pluggable authentication methods?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way I understand how it works, it's not how devise see things:
Different models should correspond to different roles, and not different authentication modes. If we create a LdapUser model, then we will have two models which share the same utility. That means everywhere you check that your user is logged in, you'll have to check if user is logged in or ldap_user is logged in.

An example (given in devise's documentation) of the use to have different models is to make a distinction between a simple user and an admin user, which clearly have different roles.

In our case, an ldap user and an user would have exactly the same role, it's only the way they authenticate that change. It's sort of the same change as the two factor authentications: it lies on the user object and not outside, because once logged in you are that user.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ldap_dn is explicitely stated here, because there is no devise LDAP authenticator that "hides" the thing. If you look in the database schema, you'll see otp / 2FA related informations, which may be null in case it's not relevent to that user.

Here it's the same, except the attribute is stated explicitely in the model

#

class User < ApplicationRecord
Expand Down Expand Up @@ -62,6 +63,7 @@ class User < ApplicationRecord
# It seems possible that a future release of devise-two-factor will
# handle this itself, and this can be removed from our User class.
attribute :otp_secret
attribute :ldap_username

has_many :session_activations, dependent: :destroy

Expand Down Expand Up @@ -105,12 +107,24 @@ def session_active?(id)
session_activations.active? id
end

def ldap_user?
Rails.configuration.x.use_ldap && ldap_dn.present?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ldap_dn :text, so seems correct.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, sorry, I was misreading. I was confused because of the attribute :ldap_username, above.

end

protected

def send_devise_notification(notification, *args)
devise_mailer.send(notification, self, *args).deliver_later
end

def confirmation_required?
if ldap_user?
!Rails.configuration.x.ldap_skip_email_confirmation
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super unless Rails.configuration.x.ldap_skip_email_configuration seems equivalent?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uh nope, it would be ldap_user? && !Rails.configuration.x.ldap_skip_email_confirmation || super, but then it's much harder to read?

else
super
end
end

private

def sanitize_languages
Expand Down
4 changes: 3 additions & 1 deletion app/views/about/show.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
= render 'registration'
- else
.closed-registrations-message
- if @instance_presenter.closed_registrations_message.blank?
- if Rails.configuration.x.use_ldap
%p= t('about.ldap_registrations')
- elsif @instance_presenter.closed_registrations_message.blank?
Copy link
Member

@nightpool nightpool Jul 4, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so in this case, it's not possible to close registrations if you have ldap enabled? seems bad.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think LDAP registrations ever need to be closed, because they're not really registrations - these people already have logins in LDAP, so it is expected that they can login on a connected Mastodon.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, if you don't want you users to use mastodon, then you disable them from LDAP, it's no more up to mastodon to decide that. (At least that's how I see LDAP)

Copy link
Member

@nightpool nightpool Jul 4, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that makes sense, but if ldap_only is false, then how would you prevent people from signing up with their email addresses? or is that handled elsewhere?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I just understood your point.

%p= t('about.closed_registrations')
- else
!= @instance_presenter.closed_registrations_message
Expand Down
5 changes: 4 additions & 1 deletion app/views/auth/sessions/new.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
= t('auth.login')

= simple_form_for(resource, as: resource_name, url: session_path(resource_name)) do |f|
= f.input :email, autofocus: true, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }
- if Rails.configuration.x.use_ldap
= f.input :ldap_username, autofocus: true, placeholder: t('simple_form.labels.defaults.ldap_username'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.ldap_username') }
- else
= f.input :email, autofocus: true, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }
= f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password') }

.actions
Expand Down
109 changes: 109 additions & 0 deletions config/initializers/0_ldap_authenticatable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
require 'net/ldap'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the fact that this is required to run before initializers/devise feels like a code smell and a bad hack.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, but how can we do otherwise?
As far as I know (little experience with other projects), config/initializer files are supposed to be ordered, it's just luck that makes the ones in mastodon not necessarily ordered?

require 'devise/strategies/authenticatable'

module Devise
module Strategies
class LdapAuthenticatable < Authenticatable
def valid?
Rails.configuration.x.use_ldap && params[:user].present?
end

def authenticate!
config = Rails.configuration
Copy link
Member

@nightpool nightpool Jul 4, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe a better way to do this would be something like delegate :ldap_host, :ldap_port, :ldap_manager_dn, :ldap_manager_password, :ldap_manager_bind, :ldap_search_filter, :ldap_search_base, :ldap_mail_attribute, :ldap_username_attribute, to: Rails.configuration.x, or something? so we have all of the expected config variables listed out explicitly.

Honestly, it doesn't feel like Rails.configuration.x is the right place to put these variables...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe it is probably okay to simply read the ENV variables in this very class, instead of doing so in an initializer. If nothing but this class will use them.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

devise.rb uses it to decide which key to use (ldap_username or email). I need to figure out how to make it not sensible to ordering though


ldap = Net::LDAP.new
ldap.host = config.x.ldap_host
ldap.port = config.x.ldap_port

if config.x.ldap_manager_bind
ldap.auth config.x.ldap_manager_dn, config.x.ldap_manager_password

if !ldap.bind
return fail(:ldap_configuration_error)
end

search_filter = config.x.ldap_search_filter % { username: params[:user][:ldap_username] }

result = ldap.search(base: config.x.ldap_search_base, filter: search_filter, result_set: true)

if result.count != 1
return login_fail
end

user_dn = result.first.dn
user_email = result.first[config.x.ldap_mail_attribute].first
user_name = result.first[config.x.ldap_username_attribute].first
else
user_dn = config.x.ldap_bind_template % { username: params[:user][:ldap_username] }
end

ldap.auth user_dn, params[:user][:password]

if ldap.bind
user = User.find_by(ldap_dn: user_dn)

# We don't want to trust too much the email attribute from
# LDAP: if the user can edit it himself, he may login as
# anyone
if user.nil?
if !config.x.ldap_manager_bind
result = ldap.search(base: user_dn, scope: Net::LDAP::SearchScope_BaseObject, filter: "(objectClass=*)", result_set: true)
user_email = result.first[config.x.ldap_mail_attribute].first
user_name = result.first[config.x.ldap_username_attribute].first
end

if user_email.present? && User.find_by(email: user_email).nil?
# Password is used for remember_me token
user = User.new(email: user_email, ldap_dn: user_dn, password: SecureRandom.hex, account_attributes: { username: user_name })
user.locale = I18n.locale
user.build_account if user.account.nil?
user.save
elsif User.find_by(email: user_email).present?
return fail(:ldap_existing_email)
else
return fail(:ldap_cannot_create_account_without_email)
end
end

success!(user)
else
return login_fail
end
end

def login_fail
if Rails.configuration.x.ldap_only
return fail(:ldap_invalid_login)
else
return pass
end
end
end
end
end

Rails.application.configure do
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these two things should be in separate files.

config.x.use_ldap = ENV.fetch('USE_LDAP') { nil }.present?
if config.x.use_ldap
config.x.ldap_host = ENV.fetch('LDAP_HOST') { 'localhost' }
config.x.ldap_port = ENV.fetch('LDAP_PORT') { 389 }
config.x.ldap_only = ENV.fetch('LDAP_ONLY') { nil }.present?

config.x.ldap_manager_bind = (ENV.fetch('LDAP_BIND_DN') { '' }).present?

config.x.ldap_username_attribute = ENV.fetch('LDAP_USERNAME_ATTRIBUTE') { 'uid' }
config.x.ldap_mail_attribute = ENV.fetch('LDAP_MAIL_ATTRIBUTE') { 'mail' }
config.x.ldap_skip_email_confirmation = ENV.fetch('LDAP_SKIP_EMAIL_CONFIRMATION') { true }

if config.x.ldap_manager_bind
config.x.ldap_manager_dn = ENV.fetch('LDAP_BIND_DN') { '' }
config.x.ldap_manager_password = ENV.fetch('LDAP_BIND_PW') { '' }
config.x.ldap_search_filter = ENV.fetch('LDAP_SEARCH_FILTER') { 'uid=%{username}' }
config.x.ldap_search_base = ENV.fetch('LDAP_SEARCH_BASE') { 'dc=example,dc=com' }
else
config.x.ldap_bind_template = ENV.fetch('LDAP_BIND_TEMPLATE') { 'uid=%{username},dc=example,dc=com' }
end
end
end

Warden::Strategies.add(:ldap_authenticatable, Devise::Strategies::LdapAuthenticatable)
7 changes: 6 additions & 1 deletion config/initializers/devise.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
config.warden do |manager|
manager.default_strategies(scope: :user).unshift :two_factor_authenticatable
manager.default_strategies(scope: :user).unshift :two_factor_backupable
manager.default_strategies(scope: :user).unshift :ldap_authenticatable
end

# The secret key used by Devise. Devise uses this key to generate
Expand Down Expand Up @@ -50,7 +51,11 @@
# session. If you need permissions, you should implement that in a before filter.
# You can also supply a hash where the value is a boolean determining whether
# or not authentication should be aborted when the value is not present.
# config.authentication_keys = [:email]
if Rails.configuration.x.use_ldap
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this doesn't handle ldap_only properly, if i'm reading this correctly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the "ldap_username" should be named "ldap_username_or_email" actually, but it seems very long. Or yes we could have three keys depending on "ldap_only", "ldap", "no_ldap" ?

config.authentication_keys = [:ldap_username]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a way to delay this configuration until later, so we don't have to worry about the load order? (and remove the ugly 0_ hack)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried in vain, but as I said I have little experience with that, if someone knows better...

else
config.authentication_keys = [:email]
end

# Configure parameters from the request object used for authentication. Each entry
# given should be a request method and it will automatically be passed to the
Expand Down
5 changes: 5 additions & 0 deletions config/locales/devise.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ en:
timeout: Your session expired. Please sign in again to continue.
unauthenticated: You need to sign in or sign up before continuing.
unconfirmed: You have to confirm your email address before continuing.
user:
ldap_configuration_error: LDAP configuration error.
ldap_invalid_login: Invalid LDAP credentials.
ldap_existing_email: A non-LDAP user already exists with your e-mail.
ldap_cannot_create_account_without_email: No e-mail was found in your LDAP informations. No account could be created.
mailer:
confirmation_instructions:
subject: 'Mastodon: Confirmation instructions for %{instance}'
Expand Down
5 changes: 5 additions & 0 deletions config/locales/devise.fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ fr:
timeout: Votre session a expiré. Veuillez vous reconnecter pour continuer.
unauthenticated: Vous devez vous connecter ou vous inscrire pour continuer.
unconfirmed: Vous devez valider votre compte pour continuer.
user:
ldap_configuration_error: Erreur de configuration LDAP.
ldap_invalid_login: login ou mot de passe LDAP incorrect.
ldap_existing_email: Un utilisateur non-LDAP existe déjà avec votre courriel.
ldap_cannot_create_account_without_email: Aucun courriel n’a pu être trouvé dans vos informations LDAP. Votre compte n’a pu être créé.
mailer:
confirmation_instructions:
subject: "Merci de confirmer votre inscription sur %{instance}"
Expand Down
1 change: 1 addition & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ en:
apps: Apps
business_email: 'Business e-mail:'
closed_registrations: Registrations are currently closed on this instance.
ldap_registrations: This instance accepts only LDAP registrations. Login with your LDAP username to create an account.
contact: Contact
description_headline: What is %{domain}?
domain_count_after: other instances
Expand Down
1 change: 1 addition & 0 deletions config/locales/fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ fr:
apps: Applications
business_email: Courriel professionnel
closed_registrations: Les inscriptions sont actuellement fermées sur cette instance.
ldap_registrations: Cette instance n’accepte que des inscriptions par LDAP. Connectez-vous avec vos informations LDAP pour créer votre compte.
contact: Contact
description_headline: Qu'est-ce que %{domain} ?
domain_count_after: autres instances
Expand Down
1 change: 1 addition & 0 deletions config/locales/simple_form.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ en:
display_name: Display name
email: E-mail address
header: Header
ldap_username: LDAP login or E-mail address
locale: Language
locked: Lock account
new_password: New password
Expand Down
1 change: 1 addition & 0 deletions config/locales/simple_form.fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ fr:
display_name: Nom public
email: Adresse courriel
header: Image d’en-tête
ldap_username: Login LDAP ou Adresse courriel
locale: Langue
locked: Rendre le compte privé
new_password: Nouveau mot de passe
Expand Down
6 changes: 6 additions & 0 deletions db/migrate/20170618111000_add_ldap_dn_to_users.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class AddLdapDnToUsers < ActiveRecord::Migration[5.0]
def change
add_column :users, :ldap_dn, :text, null: true, default: nil
add_index :users, :ldap_dn
end
end
2 changes: 2 additions & 0 deletions db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -364,10 +364,12 @@
t.datetime "last_emailed_at"
t.string "otp_backup_codes", array: true
t.string "filtered_languages", default: [], null: false, array: true
t.text "ldap_dn"
t.index ["account_id"], name: "index_users_on_account_id"
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["filtered_languages"], name: "index_users_on_filtered_languages", using: :gin
t.index ["ldap_dn"], name: "index_users_on_ldap_dn"
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end

Expand Down
22 changes: 22 additions & 0 deletions spec/models/user_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,28 @@
end
end

describe '#ldap_user?' do
around(:each) do |example|
old_ldap = Rails.configuration.x.use_ldap

Rails.configuration.x.use_ldap = true

example.run

Rails.configuration.x.use_ldap = old_ldap
end

it 'returns true for ldap users' do
user = Fabricate(:user, ldap_dn: "uid=foo,dc=example,dc=com")
expect(user.ldap_user?).to eq true
end

it 'returns false for other users' do
user = Fabricate(:user)
expect(user.ldap_user?).to eq false
end
end

describe 'whitelist' do
around(:each) do |example|
old_whitelist = Rails.configuration.x.email_whitelist
Expand Down