├── .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 | [![CI](https://github.com/jonmbake/discourse-ldap-auth/workflows/CI/badge.svg)](https://github.com/jonmbake/discourse-ldap-auth/actions?query=workflow%3ACI) 2 | [![CodeQL Scan](codeql-scan.svg)](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 | ![Login Popup](https://raw.githubusercontent.com/jonmbake/screenshots/master/discourse-ldap-auth/login.png) 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 | ![Settings Page](https://github.com/jonmbake/screenshots/blob/master/discourse-ldap-auth/settings.png) 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 | ![Disable Local](https://github.com/jonmbake/screenshots/blob/master/discourse-ldap-auth/disable_local.png) 48 | 49 | ![LDAP Login Popup](https://github.com/jonmbake/screenshots/blob/master/discourse-ldap-auth/ldap_popup.png) 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 | Scan: CodeQLScanCodeQL -------------------------------------------------------------------------------- /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 <