├── .github
└── workflows
│ ├── codeql.yml
│ └── plugin-tests.yml
├── .gitignore
├── .rspec
├── LICENSE
├── README.md
├── codeql-scan.svg
├── config
├── locales
│ ├── client.en.yml
│ ├── client.es.yml
│ ├── client.it.yml
│ ├── client.pt_BR.yml
│ ├── server.en.yml
│ ├── server.es.yml
│ ├── server.it.yml
│ └── server.pt_BR.yml
└── settings.yml
├── css
└── form.css
├── ldap_users.yml
├── lib
├── ldap_user.rb
├── omniauth-ldap
│ └── adaptor.rb
└── omniauth
│ └── strategies
│ └── ldap.rb
├── plugin.rb
└── spec
└── ldap_authenticator_spec.rb
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
1 | name: CodeQL
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches:
7 | - master
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - name: Checkout repository
15 | uses: actions/checkout@v2
16 |
17 | - name: Initialize CodeQL
18 | uses: github/codeql-action/init@v1
19 | with:
20 | languages: ruby
21 |
22 | - uses: ruby/setup-ruby@v1
23 | with:
24 | ruby-version: 2.7
25 |
26 | - name: Perform CodeQL Analysis
27 | uses: github/codeql-action/analyze@v1
28 |
--------------------------------------------------------------------------------
/.github/workflows/plugin-tests.yml:
--------------------------------------------------------------------------------
1 | # Based on https://github.com/discourse/discourse-plugin-skeleton/blob/main/.github/workflows/plugin-tests.yml
2 | name: Plugin Tests
3 |
4 | on:
5 | push:
6 | branches:
7 | - master
8 | - main
9 | pull_request:
10 |
11 | jobs:
12 | build:
13 | name: ${{ matrix.build_type }}
14 | runs-on: ubuntu-latest
15 | container: discourse/discourse_test:slim${{ matrix.build_type == 'frontend' && '-browsers' || '' }}
16 | timeout-minutes: 60
17 |
18 | env:
19 | DISCOURSE_HOSTNAME: www.example.com
20 | RUBY_GLOBAL_METHOD_CACHE_SIZE: 131072
21 | RAILS_ENV: test
22 | PGUSER: discourse
23 | PGPASSWORD: discourse
24 |
25 | strategy:
26 | fail-fast: false
27 |
28 | matrix:
29 | build_type: ["backend", "frontend"]
30 |
31 | steps:
32 | - uses: actions/checkout@v2
33 | with:
34 | repository: discourse/discourse
35 | fetch-depth: 1
36 |
37 | - name: Install plugin
38 | uses: actions/checkout@v2
39 | with:
40 | path: plugins/${{ github.event.repository.name }}
41 | fetch-depth: 1
42 |
43 | - name: Setup Git
44 | run: |
45 | git config --global user.email "ci@ci.invalid"
46 | git config --global user.name "Discourse CI"
47 |
48 | - name: Start redis
49 | run: |
50 | redis-server /etc/redis/redis.conf &
51 |
52 | - name: Start Postgres
53 | run: |
54 | chown -R postgres /var/run/postgresql
55 | sudo -E -u postgres script/start_test_db.rb
56 | sudo -u postgres psql -c "CREATE ROLE $PGUSER LOGIN SUPERUSER PASSWORD '$PGPASSWORD';"
57 |
58 | - name: Bundler cache
59 | uses: actions/cache@v2
60 | with:
61 | path: vendor/bundle
62 | key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }}
63 | restore-keys: |
64 | ${{ runner.os }}-gem-
65 |
66 | - name: Setup gems
67 | run: |
68 | bundle config --local path vendor/bundle
69 | bundle config --local deployment true
70 | bundle config --local without development
71 | bundle install --jobs 4
72 | bundle clean
73 |
74 | - name: Lint English locale
75 | if: matrix.build_type == 'backend'
76 | run: bundle exec ruby script/i18n_lint.rb "plugins/${{ github.event.repository.name }}/locales/{client,server}.en.yml"
77 |
78 | - name: Get yarn cache directory
79 | id: yarn-cache-dir
80 | run: echo "::set-output name=dir::$(yarn cache dir)"
81 |
82 | - name: Yarn cache
83 | uses: actions/cache@v2
84 | id: yarn-cache
85 | with:
86 | path: ${{ steps.yarn-cache-dir.outputs.dir }}
87 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
88 | restore-keys: |
89 | ${{ runner.os }}-yarn-
90 |
91 | - name: Yarn install
92 | run: yarn install
93 |
94 | - name: Fetch app state cache
95 | uses: actions/cache@v2
96 | id: app-cache
97 | with:
98 | path: tmp/app-cache
99 | key: >- # postgres version, hash of migrations, "parallel?"
100 | ${{ runner.os }}-
101 | ${{ hashFiles('.github/workflows/tests.yml') }}-
102 | ${{ matrix.postgres }}-
103 | ${{ hashFiles('db/**/*', 'plugins/**/db/**/*') }}-
104 | ${{ env.USES_PARALLEL_DATABASES }}
105 |
106 | - name: Restore database from cache
107 | if: steps.app-cache.outputs.cache-hit == 'true'
108 | run: psql -f tmp/app-cache/cache.sql postgres
109 |
110 | - name: Restore uploads from cache
111 | if: steps.app-cache.outputs.cache-hit == 'true'
112 | run: rm -rf public/uploads && cp -r tmp/app-cache/uploads public/uploads
113 |
114 | - name: Create and migrate database
115 | if: steps.app-cache.outputs.cache-hit != 'true'
116 | run: |
117 | bin/rake db:create
118 | bin/rake db:migrate
119 |
120 | - name: Dump database for cache
121 | if: steps.app-cache.outputs.cache-hit != 'true'
122 | run: mkdir -p tmp/app-cache && pg_dumpall > tmp/app-cache/cache.sql
123 |
124 | - name: Dump uploads for cache
125 | if: steps.app-cache.outputs.cache-hit != 'true'
126 | run: rm -rf tmp/app-cache/uploads && cp -r public/uploads tmp/app-cache/uploads
127 |
128 | - name: Check spec existence
129 | id: check_spec
130 | shell: bash
131 | run: |
132 | if [ 0 -lt $(find plugins/${{ github.event.repository.name }}/spec -type f -name "*.rb" 2> /dev/null | wc -l) ]; then
133 | echo "::set-output name=files_exist::true"
134 | fi
135 |
136 | - name: Plugin RSpec
137 | if: matrix.build_type == 'backend' && steps.check_spec.outputs.files_exist == 'true'
138 | run: bin/rake plugin:spec[${{ github.event.repository.name }}]
139 |
140 | - name: Check qunit existence
141 | id: check_qunit
142 | shell: bash
143 | run: |
144 | if [ 0 -lt $(find plugins/${{ github.event.repository.name }}/test/javascripts -type f \( -name "*.js" -or -name "*.es6" \) 2> /dev/null | wc -l) ]; then
145 | echo "::set-output name=files_exist::true"
146 | fi
147 |
148 | - name: Plugin QUnit
149 | if: matrix.build_type == 'frontend' && steps.check_qunit.outputs.files_exist == 'true'
150 | run: bundle exec rake plugin:qunit['${{ github.event.repository.name }}','1200000']
151 | timeout-minutes: 30
152 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | auto_generated
2 | gems
3 | *.iml
4 | .idea
5 |
--------------------------------------------------------------------------------
/.rspec:
--------------------------------------------------------------------------------
1 | --color
2 | --require spec_helper
3 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Jon Bake
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://github.com/jonmbake/discourse-ldap-auth/actions?query=workflow%3ACI)
2 | [](https://github.com/jonmbake/discourse-ldap-auth/security/code-scanning?query=tool%3ACodeQL)
3 |
4 | # discourse-ldap-auth
5 |
6 | A [Discourse](https://github.com/discourse/discourse) plugin to enable LDAP/ActiveDirectory authentication.
7 |
8 | 
9 |
10 | ## Setup
11 |
12 | Checkout: [Installing a Plugin](https://meta.discourse.org/t/install-a-plugin/19157).
13 |
14 | ## Configuration
15 |
16 | After the plugin is installed, logging in as an Admin and navigating to `admin/site_settings/category/plugins` will enable you to specify your LDAP settings. Most of the configuration options are documented in [omniauth-ldap](https://github.com/intridea/omniauth-ldap).
17 |
18 | 
19 |
20 | ## A Note on User Account Creation
21 |
22 | By default, user accounts are automatically created (if they don't already exist) after authentication using *name*, *nickname* and *email* attributes of the LDAP entry. If you do not want this behavior, you can change the *ldap_user_create_mode* configuration value to one of the following:
23 |
24 | Name | Description
25 | -------| --------------
26 | auto | Automatically create a Discourse Account after authenticating through LDAP if account does not exist (default).
27 | list | Provide a list of users in *ldap_users.yml*. Will only create an account and pass authentication if user with email is in list. See example [ldap_user.yml](ldap_users.yml).
28 | none | Fail auth if the user account does not already exist. This is a good option for an Admin that creates accounts ahead of time.
29 |
30 | *list* also allows the specifying of *User Groups*, which will be automatically assigned to the user on creation. It also allows specifying a different *username* (for local account) and *name* for the Discourse User Account than what is returned in the LDAP entry.
31 |
32 | ## Yet Another Note on the _Attribute to lookup users by_ Setting
33 |
34 | The _Lookup Users By Attribute_ setting specifies how to to link the LDAP response entry to a user within _Discourse_. By
35 | default, it uses the _email_ attribute in the LDAP entry to try and find an existing Discourse user. If email address is
36 | unlikely to change for a given user, it is a good idea to keep this default setting.
37 |
38 | If, on the other hand, LDAP email address has the possibility of changing, setting _Attribute to lookup users by_ to _username_ is the way to go to avoid "de-linking"
39 | the Discourse User from its corresponding LDAP entry.
40 |
41 | The only possible values are _email_ or _username_.
42 |
43 | ## Other Tips
44 |
45 | When disabling Local Login and other authentication services, clicking the `Login` or `Sign Up` button will directly bring up the LDAP Login popup.
46 |
47 | 
48 |
49 | 
50 |
51 | ## Submitting a PR
52 |
53 | Make sure coding style is maintained and all tests pass by running:
54 |
55 | ```
56 | rspec
57 | ```
58 |
59 | ## Version History
60 |
61 | [0.7.0](https://github.com/jonmbake/discourse-ldap-auth/tree/v0.7.0)- Temporarily pulled in omniauth-ldap due to omniauth version conflict with Discourse; bumped net-ldap version
62 |
63 | [0.6.1](https://github.com/jonmbake/discourse-ldap-auth/tree/v0.6.1)- Bump net-ldap dependency version in order to support Ruby 3
64 |
65 | [0.6.0](https://github.com/jonmbake/discourse-ldap-auth/tree/v0.6.0)- Update omit_username to override_username following Discourse core changes
66 |
67 | [0.5.0](https://github.com/jonmbake/discourse-ldap-auth/tree/v0.5.0)- Remove call to deprecated Discourse code from [FEATURE: Use full page redirection for all external auth methods](https://github.com/discourse/discourse/pull/8092)
68 |
69 | [0.4.1](https://github.com/jonmbake/discourse-ldap-auth/tree/v0.4.1)- Make LDAP Password field secret
70 |
71 | [0.4.0](https://github.com/jonmbake/discourse-ldap-auth/tree/v0.4.0)- Fix Undefined variable: "$fa-var-sitemap" error when upgrading to Discourse version > 2.2.0
72 |
73 | [0.3.8](https://github.com/jonmbake/discourse-ldap-auth/tree/v0.3.8)- Fix `enabled?` undefined warning
74 |
75 | [0.3.7](https://github.com/jonmbake/discourse-ldap-auth/tree/v0.3.7)- Add _Attribute to lookup users by_ Setting
76 |
77 | [0.3.6](https://github.com/jonmbake/discourse-ldap-auth/tree/v0.3.6)- Fixed bug where user who changed email can no longer be looked up
78 |
79 | [0.3.5](https://github.com/jonmbake/discourse-ldap-auth/tree/v0.3.5)- Updated styling of LDAP login popup
80 |
81 | [0.3.0](https://github.com/jonmbake/discourse-ldap-auth/tree/v0.3.0)- Fixed typo to `ldap_bind_db` configuration name
82 |
83 | [0.2.0](https://github.com/jonmbake/discourse-ldap-auth/tree/v0.2.0) - Added ldap_user_create_mode configuration option.
84 |
85 | [0.1.0](https://github.com/jonmbake/discourse-ldap-auth/tree/v0.1.0) - Init
86 |
87 | **Note on Updating to Version 0.3** A typo was fixed in the name of a configuration. `ldap_bind_db` was renamed to `ldap_bind_dn`. If you update from <0.2 to 0.3, you will have to reset the `ldap_bind_dn` configuration value. There is a fallback to use the old configuration value, but this will be removed in a future release.
88 |
89 | ## License
90 |
91 | MIT
92 |
--------------------------------------------------------------------------------
/codeql-scan.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/config/locales/client.en.yml:
--------------------------------------------------------------------------------
1 | en:
2 | js:
3 | login:
4 | ldap:
5 | name: "LDAP"
6 | title: "with LDAP"
7 | sr_title: "Login with LDAP"
8 | message: "Authenticating with LDAP"
9 |
--------------------------------------------------------------------------------
/config/locales/client.es.yml:
--------------------------------------------------------------------------------
1 | es:
2 | js:
3 | login:
4 | ldap:
5 | name: "LDAP"
6 | title: "con LDAP"
7 |
--------------------------------------------------------------------------------
/config/locales/client.it.yml:
--------------------------------------------------------------------------------
1 | it:
2 | js:
3 | login:
4 | ldap:
5 | name: "LDAP"
6 | title: "con LDAP"
7 |
--------------------------------------------------------------------------------
/config/locales/client.pt_BR.yml:
--------------------------------------------------------------------------------
1 | pt_BR:
2 | js:
3 | login:
4 | ldap:
5 | name: "LDAP"
6 | title: "com a LDAP"
7 |
--------------------------------------------------------------------------------
/config/locales/server.en.yml:
--------------------------------------------------------------------------------
1 | en:
2 | site_settings:
3 | ldap_enabled: "Is LDAP plugin enabled?"
4 | ldap_user_create_mode: "User create mode: auto, list or none"
5 | ldap_lookup_users_by: "Attribute to lookup users by: email or username"
6 | ldap_hostname: "Hostname of LDAP server"
7 | ldap_port: "Connection port to LDAP server"
8 | ldap_method: "Connection method: ssl, tls or plain"
9 | ldap_base: "LDAP base"
10 | ldap_uid: "LDAP uid"
11 | ldap_bind_dn: "LDAP bind dn (needed if LDAP server does not allow anonymous binding)"
12 | ldap_password: "LDAP bind password (needed if LDAP server does not allow anonymous binding)"
13 | ldap_filter: "LDAP filter (for group based authentication)"
14 |
--------------------------------------------------------------------------------
/config/locales/server.es.yml:
--------------------------------------------------------------------------------
1 | es:
2 | site_settings:
3 | ldap_enabled: "Está activado el plugin de LDAP?"
4 | ldap_user_create_mode: "Modo de creación de usuario: auto, list o none"
5 | ldap_lookup_users_by: "Atributo para encontrar usuarios por: email or username"
6 | ldap_hostname: "Hostname del servidor LDAP"
7 | ldap_port: "Puerto del servidor LDAP"
8 | ldap_method: "Método de conexión: ssl, tls o plain"
9 | ldap_base: "LDAP base"
10 | ldap_uid: "LDAP uid"
11 | ldap_bind_db: "LDAP bind db (necesario si el servidor LDAP no permite bind anónimo)"
12 | ldap_password: "LDAP bind password (necesario si el servidor LDAP no permite binding anónimo)"
13 | ldap_filter: "Filtro LDAP (para especificar permisos por grupos, etc)"
14 |
--------------------------------------------------------------------------------
/config/locales/server.it.yml:
--------------------------------------------------------------------------------
1 | it:
2 | site_settings:
3 | ldap_enabled: "É il plugin LDAP attivato?"
4 | ldap_user_create_mode: "Modalità di creazione degli utenti:: auto, list o none"
5 | ldap_lookup_users_by: "Attributo per trovare utenti da: email o username"
6 | ldap_hostname: "Hostname del server LDAP"
7 | ldap_port: "Porta del server LDAP"
8 | ldap_method: "Metodo di connessione: ssl, tls o plain"
9 | ldap_base: "Base LDAP"
10 | ldap_uid: "LDAP uid"
11 | ldap_bind_db: "LDAP bind db (necessaria se il server LDAP non consente bind anonimo)"
12 | ldap_password: "LDAP bind password (necessaria se il server LDAP non consente bind anonimo)"
13 | ldap_filter: "Filtro LDAP (per specificare le autorizzazioni per i grupi, etc.)"
14 |
--------------------------------------------------------------------------------
/config/locales/server.pt_BR.yml:
--------------------------------------------------------------------------------
1 | pt_BR:
2 | site_settings:
3 | ldap_enabled: "O plugin LDAP está ativo?"
4 | ldap_user_create_mode: "Usuário modo de criação: auto, list or none"
5 | ldap_lookup_users_by: "Atributo para encontrar usuários por: email ou username"
6 | ldap_hostname: "Hostname do servidor LDAP"
7 | ldap_port: "Porta de conexão do servidor LDAP"
8 | ldap_method: "Método da conexão:: ssl, tls ou plain"
9 | ldap_base: "LDAP base"
10 | ldap_uid: "LDAP uid"
11 | ldap_bind_dn: "Usuário LDAP bind (necessário se o servidor LDAP não permite binding anônimo)"
12 | ldap_password: "Senha LDAP bind (necessário se o servidor LDAP não permite binding anônimo)"
13 |
--------------------------------------------------------------------------------
/config/settings.yml:
--------------------------------------------------------------------------------
1 | plugins:
2 | ldap_enabled:
3 | default: true
4 | ldap_user_create_mode:
5 | default: 'auto'
6 | ldap_lookup_users_by:
7 | default: 'email'
8 | ldap_hostname:
9 | default: 'adfs.example.com'
10 | ldap_port:
11 | default: 389
12 | ldap_method:
13 | default: 'plain'
14 | ldap_base:
15 | default: 'dc=example,dc=com'
16 | ldap_uid:
17 | default: 'sAMAccountName'
18 | ldap_bind_dn:
19 | default: ''
20 | ldap_password:
21 | default: ''
22 | secret: true
23 | ldap_filter:
24 | default: ''
25 |
--------------------------------------------------------------------------------
/css/form.css:
--------------------------------------------------------------------------------
1 | body {
2 | color: #222222;
3 | background-color: white;
4 | font-family: Helvetica, Arial, sans-serif;
5 | font-size: 14px;
6 | line-height: 19px;
7 | }
8 |
9 | h1 {
10 | font-size: 1.429em;
11 | padding: 10px 15px 7px;
12 | border-bottom: 1px solid #e9e9e9;
13 | }
14 |
15 | form {
16 | padding: 20px;
17 | margin: 0px auto;
18 | width: 710px;
19 | border: 1px solid #e9e9e9;
20 | box-shadow: 0 3px 7px rgba(0, 0, 0, 0.8);
21 | }
22 |
23 | input {
24 | background-color: white;
25 | border: 1px solid #e9e9e9;
26 | border-radius: 3px;
27 | box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.3);
28 | display: inline-block;
29 | height: 18px;
30 | padding: 4px;
31 | margin-bottom: 9px;
32 | font-size: 0.929em;
33 | line-height: 18px;
34 | }
35 |
36 | #username {
37 | margin-left: 27px
38 | }
39 |
40 | button {
41 | border: none;
42 | font-weight: normal;
43 | color: white;
44 | background: #0088cc;
45 | padding: 9px 18px;
46 | font-size: 1.143em;
47 | line-height: 15px;
48 | display: block;
49 | }
50 |
51 | label:before {
52 | content: ' '; display: block;
53 | }
54 |
--------------------------------------------------------------------------------
/ldap_users.yml:
--------------------------------------------------------------------------------
1 | - :name: Example User
2 | :email: example_user@gmail.com
3 | :username: example_user
4 | :groups: ['team', 'engineering']
--------------------------------------------------------------------------------
/lib/ldap_user.rb:
--------------------------------------------------------------------------------
1 | class LDAPUser
2 | attr_reader :name, :email, :username, :user
3 |
4 | def initialize (auth_info)
5 | @name = auth_info[:name]
6 | @email = auth_info[:email]
7 | @username = auth_info[:nickname]
8 | @user = SiteSetting.ldap_lookup_users_by == 'username' ? User.find_by_username(@username) : User.find_by_email(@email)
9 | create_user_groups(auth_info[:groups]) unless self.account_exists?
10 | end
11 |
12 | def auth_result
13 | result = Auth::Result.new
14 | result.name = @name
15 | result.username = @username
16 | result.email = @email
17 | result.user = @user
18 | if result.respond_to? :overrides_username
19 | result.overrides_username = true if !account_exists?
20 | else
21 | # TODO: Remove once Discourse 2.8 stable is released
22 | result.omit_username = true
23 | end
24 | result.email_valid = true
25 | return result
26 | end
27 |
28 | def account_exists?
29 | return !@user.nil?
30 | end
31 |
32 | private
33 | def create_user_groups(user_groups)
34 | return if user_groups.nil?
35 | #user account must exist in order to create user groups
36 | @user = User.create!(name: self.name, email: self.email, username: self.username)
37 | @user.activate
38 | user_groups.each do |group_name|
39 | group = Group.find_by(name: group_name)
40 | @user.groups << group unless group.nil?
41 | end
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/lib/omniauth-ldap/adaptor.rb:
--------------------------------------------------------------------------------
1 | # taken from https://github.com/omniauth/omniauth-ldap/blob/master/lib/omniauth-ldap/adaptor.rb
2 | #this code borrowed pieces from activeldap and net-ldap
3 | require 'rack'
4 | require 'net/ldap'
5 | require 'net/ntlm'
6 | require 'sasl'
7 | require 'kconv'
8 | module OmniAuth
9 | module LDAP
10 | class Adaptor
11 | class LdapError < StandardError; end
12 | class ConfigurationError < StandardError; end
13 | class AuthenticationError < StandardError; end
14 | class ConnectionError < StandardError; end
15 |
16 | VALID_ADAPTER_CONFIGURATION_KEYS = [:host, :port, :method, :bind_dn, :password, :try_sasl, :sasl_mechanisms, :uid, :base, :allow_anonymous, :filter]
17 |
18 | # A list of needed keys. Possible alternatives are specified using sub-lists.
19 | MUST_HAVE_KEYS = [:host, :port, :method, [:uid, :filter], :base]
20 |
21 | METHOD = {
22 | :ssl => :simple_tls,
23 | :tls => :start_tls,
24 | :plain => nil,
25 | }
26 |
27 | attr_accessor :bind_dn, :password
28 | attr_reader :connection, :uid, :base, :auth, :filter
29 | def self.validate(configuration={})
30 | message = []
31 | MUST_HAVE_KEYS.each do |names|
32 | names = [names].flatten
33 | missing_keys = names.select{|name| configuration[name].nil?}
34 | if missing_keys == names
35 | message << names.join(' or ')
36 | end
37 | end
38 | raise ArgumentError.new(message.join(",") +" MUST be provided") unless message.empty?
39 | end
40 | def initialize(configuration={})
41 | Adaptor.validate(configuration)
42 | @configuration = configuration.dup
43 | @configuration[:allow_anonymous] ||= false
44 | @logger = @configuration.delete(:logger)
45 | VALID_ADAPTER_CONFIGURATION_KEYS.each do |name|
46 | instance_variable_set("@#{name}", @configuration[name])
47 | end
48 | method = ensure_method(@method)
49 | config = {
50 | :host => @host,
51 | :port => @port,
52 | :base => @base
53 | }
54 | @bind_method = @try_sasl ? :sasl : (@allow_anonymous||!@bind_dn||!@password ? :anonymous : :simple)
55 |
56 |
57 | @auth = sasl_auths({:username => @bind_dn, :password => @password}).first if @bind_method == :sasl
58 | @auth ||= { :method => @bind_method,
59 | :username => @bind_dn,
60 | :password => @password
61 | }
62 | config[:auth] = @auth
63 | config[:encryption] = method
64 | @connection = Net::LDAP.new(config)
65 | end
66 |
67 | #:base => "dc=yourcompany, dc=com",
68 | # :filter => "(mail=#{user})",
69 | # :password => psw
70 | def bind_as(args = {})
71 | result = false
72 | @connection.open do |me|
73 | rs = me.search args
74 | if rs and rs.first and dn = rs.first.dn
75 | password = args[:password]
76 | method = args[:method] || @method
77 | password = password.call if password.respond_to?(:call)
78 | if method == 'sasl'
79 | result = rs.first if me.bind(sasl_auths({:username => dn, :password => password}).first)
80 | else
81 | result = rs.first if me.bind(:method => :simple, :username => dn,
82 | :password => password)
83 | end
84 | end
85 | end
86 | result
87 | end
88 |
89 | private
90 | def ensure_method(method)
91 | method ||= "plain"
92 | normalized_method = method.to_s.downcase.to_sym
93 | return METHOD[normalized_method] if METHOD.has_key?(normalized_method)
94 |
95 | available_methods = METHOD.keys.collect {|m| m.inspect}.join(", ")
96 | format = "%s is not one of the available connect methods: %s"
97 | raise ConfigurationError, format % [method.inspect, available_methods]
98 | end
99 |
100 | def sasl_auths(options={})
101 | auths = []
102 | sasl_mechanisms = options[:sasl_mechanisms] || @sasl_mechanisms
103 | sasl_mechanisms.each do |mechanism|
104 | normalized_mechanism = mechanism.downcase.gsub(/-/, '_')
105 | sasl_bind_setup = "sasl_bind_setup_#{normalized_mechanism}"
106 | next unless respond_to?(sasl_bind_setup, true)
107 | initial_credential, challenge_response = send(sasl_bind_setup, options)
108 | auths << {
109 | :method => :sasl,
110 | :initial_credential => initial_credential,
111 | :mechanism => mechanism,
112 | :challenge_response => challenge_response
113 | }
114 | end
115 | auths
116 | end
117 |
118 | def sasl_bind_setup_digest_md5(options)
119 | bind_dn = options[:username]
120 | initial_credential = ""
121 | challenge_response = Proc.new do |cred|
122 | pref = SASL::Preferences.new :digest_uri => "ldap/#{@host}", :username => bind_dn, :has_password? => true, :password => options[:password]
123 | sasl = SASL.new("DIGEST-MD5", pref)
124 | response = sasl.receive("challenge", cred)
125 | response[1]
126 | end
127 | [initial_credential, challenge_response]
128 | end
129 |
130 | def sasl_bind_setup_gss_spnego(options)
131 | bind_dn = options[:username]
132 | psw = options[:password]
133 | raise LdapError.new( "invalid binding information" ) unless (bind_dn && psw)
134 |
135 | nego = proc {|challenge|
136 | t2_msg = Net::NTLM::Message.parse( challenge )
137 | bind_dn, domain = bind_dn.split('\\').reverse
138 | t2_msg.target_name = Net::NTLM::encode_utf16le(domain) if domain
139 | t3_msg = t2_msg.response( {:user => bind_dn, :password => psw}, {:ntlmv2 => true} )
140 | t3_msg.serialize
141 | }
142 | [Net::NTLM::Message::Type1.new.serialize, nego]
143 | end
144 |
145 | end
146 | end
147 | end
148 |
--------------------------------------------------------------------------------
/lib/omniauth/strategies/ldap.rb:
--------------------------------------------------------------------------------
1 | # taken from https://github.com/omniauth/omniauth-ldap/blob/master/lib/omniauth/strategies/ldap.rb
2 | require 'omniauth'
3 |
4 | module OmniAuth
5 | module Strategies
6 | class LDAP
7 | include OmniAuth::Strategy
8 | @@config = {
9 | 'name' => 'cn',
10 | 'first_name' => 'givenName',
11 | 'last_name' => 'sn',
12 | 'email' => ['mail', "email", 'userPrincipalName'],
13 | 'phone' => ['telephoneNumber', 'homePhone', 'facsimileTelephoneNumber'],
14 | 'mobile' => ['mobile', 'mobileTelephoneNumber'],
15 | 'nickname' => ['uid', 'userid', 'sAMAccountName'],
16 | 'title' => 'title',
17 | 'location' => {"%0, %1, %2, %3 %4" => [['address', 'postalAddress', 'homePostalAddress', 'street', 'streetAddress'], ['l'], ['st'],['co'],['postOfficeBox']]},
18 | 'uid' => 'dn',
19 | 'url' => ['wwwhomepage'],
20 | 'image' => 'jpegPhoto',
21 | 'description' => 'description'
22 | }
23 | option :title, "LDAP Authentication" #default title for authentication form
24 | option :port, 389
25 | option :method, :plain
26 | option :uid, 'sAMAccountName'
27 | option :name_proc, lambda {|n| n}
28 |
29 | def request_phase
30 | OmniAuth::LDAP::Adaptor.validate @options
31 | f = OmniAuth::Form.new(:title => (options[:title] || "LDAP Authentication"), :url => callback_path)
32 | f.text_field 'Login', 'username'
33 | f.password_field 'Password', 'password'
34 | f.button "Sign In"
35 | f.to_response
36 | end
37 |
38 | def callback_phase
39 | @adaptor = OmniAuth::LDAP::Adaptor.new @options
40 |
41 | return fail!(:missing_credentials) if missing_credentials?
42 | begin
43 | @ldap_user_info = @adaptor.bind_as(:filter => filter(@adaptor), :size => 1, :password => request['password'])
44 | return fail!(:invalid_credentials) if !@ldap_user_info
45 |
46 | @user_info = self.class.map_user(@@config, @ldap_user_info)
47 | super
48 | rescue Exception => e
49 | return fail!(:ldap_error, e)
50 | end
51 | end
52 |
53 | def filter adaptor
54 | if adaptor.filter and !adaptor.filter.empty?
55 | Net::LDAP::Filter.construct(adaptor.filter % {username: @options[:name_proc].call(request['username'])})
56 | else
57 | Net::LDAP::Filter.eq(adaptor.uid, @options[:name_proc].call(request['username']))
58 | end
59 | end
60 |
61 | uid {
62 | @user_info["uid"]
63 | }
64 | info {
65 | @user_info
66 | }
67 | extra {
68 | { :raw_info => @ldap_user_info }
69 | }
70 |
71 | def self.map_user(mapper, object)
72 | user = {}
73 | mapper.each do |key, value|
74 | case value
75 | when String
76 | user[key] = object[value.downcase.to_sym].first if object.respond_to? value.downcase.to_sym
77 | when Array
78 | value.each {|v| (user[key] = object[v.downcase.to_sym].first; break;) if object.respond_to? v.downcase.to_sym}
79 | when Hash
80 | value.map do |key1, value1|
81 | pattern = key1.dup
82 | value1.each_with_index do |v,i|
83 | part = ''; v.collect(&:downcase).collect(&:to_sym).each {|v1| (part = object[v1].first; break;) if object.respond_to? v1}
84 | pattern.gsub!("%#{i}",part||'')
85 | end
86 | user[key] = pattern
87 | end
88 | end
89 | end
90 | user
91 | end
92 |
93 | protected
94 |
95 | def missing_credentials?
96 | request['username'].nil? or request['username'].empty? or request['password'].nil? or request['password'].empty?
97 | end # missing_credentials?
98 | end
99 | end
100 | end
101 |
102 | OmniAuth.config.add_camelization 'ldap', 'LDAP'
103 |
--------------------------------------------------------------------------------
/plugin.rb:
--------------------------------------------------------------------------------
1 | # name:ldap
2 | # about: A plugin to provide ldap authentication.
3 | # version: 0.7.0
4 | # authors: Jon Bake
5 |
6 | enabled_site_setting :ldap_enabled
7 |
8 | gem 'pyu-ruby-sasl', '0.0.3.3', require: false
9 | gem 'rubyntlm', '0.3.4', require: false
10 | gem 'net-ldap', '0.18.0'
11 |
12 | require 'yaml'
13 | require_relative 'lib/omniauth-ldap/adaptor'
14 | require_relative 'lib/omniauth/strategies/ldap'
15 | require_relative 'lib/ldap_user'
16 |
17 | class ::LDAPAuthenticator < ::Auth::Authenticator
18 | def name
19 | 'ldap'
20 | end
21 |
22 | def enabled?
23 | true
24 | end
25 |
26 | def after_authenticate(auth_options)
27 | return auth_result(auth_options.info)
28 | end
29 |
30 | def register_middleware(omniauth)
31 | omniauth.configure{ |c| c.form_css = File.read(File.expand_path("../css/form.css", __FILE__)) }
32 | omniauth.provider :ldap,
33 | setup: -> (env) {
34 | env["omniauth.strategy"].options.merge!(
35 | host: SiteSetting.ldap_hostname,
36 | port: SiteSetting.ldap_port,
37 | method: SiteSetting.ldap_method,
38 | base: SiteSetting.ldap_base,
39 | uid: SiteSetting.ldap_uid,
40 | # In 0.3.0, we fixed a typo in the ldap_bind_dn config name. This fallback will be removed in a future version.
41 | bind_dn: SiteSetting.ldap_bind_dn.presence || SiteSetting.try(:ldap_bind_db),
42 | password: SiteSetting.ldap_password,
43 | filter: SiteSetting.ldap_filter
44 | )
45 | }
46 | end
47 |
48 | private
49 | def auth_result(auth_info)
50 | case SiteSetting.ldap_user_create_mode
51 | when 'none'
52 | ldap_user = LDAPUser.new(auth_info)
53 | return ldap_user.account_exists? ? ldap_user.auth_result : fail_auth('User account does not exist.')
54 | when 'list'
55 | user_descriptions = load_user_descriptions
56 | return fail_auth('List of users must be provided when ldap_user_create_mode setting is set to \'list\'.') if user_descriptions.nil?
57 | #match on email
58 | match = user_descriptions.find { |ud| auth_info[:email].casecmp(ud[:email]) == 0 }
59 | return fail_auth('User with email is not listed in LDAP user list.') if match.nil?
60 | match[:nickname] = match[:username] || auth_info[:nickname]
61 | match[:name] = match[:name] || auth_info[:name]
62 | return LDAPUser.new(match).auth_result
63 | when 'auto'
64 | return LDAPUser.new(auth_info).auth_result
65 | else
66 | return fail_auth('Invalid option for ldap_user_create_mode setting.')
67 | end
68 | end
69 | def fail_auth(reason)
70 | result = Auth::Result.new
71 | result.failed = true
72 | result.failed_reason = reason
73 | result
74 | end
75 | def load_user_descriptions
76 | file_path = "#{File.expand_path(File.dirname(__FILE__))}/ldap_users.yml"
77 | return nil unless File.exist?(file_path)
78 | return YAML.load_file(file_path)
79 | end
80 | end
81 |
82 | auth_provider authenticator: LDAPAuthenticator.new
83 |
84 | register_css <