├── .rspec ├── .rubocop.yml ├── .eslintrc.cjs ├── .gitignore ├── .prettierrc.cjs ├── config ├── locales │ ├── client.es.yml │ ├── client.it.yml │ ├── client.pt_BR.yml │ ├── client.en.yml │ ├── server.pt_BR.yml │ ├── server.en.yml │ ├── server.es.yml │ └── server.it.yml └── settings.yml ├── ldap_users.yml ├── Gemfile ├── .github └── workflows │ ├── plugin-tests.yml │ ├── codeql.yml │ └── release.yml ├── package.json ├── codeql-scan.svg ├── LICENSE ├── css └── form.css ├── lib ├── ldap_user.rb ├── omniauth │ └── strategies │ │ └── ldap.rb └── omniauth-ldap │ └── adaptor.rb ├── plugin.rb ├── spec └── ldap_authenticator_spec.rb ├── README.md └── Gemfile.lock /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | rubocop-discourse: stree-compat.yml 3 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require("@discourse/lint-configs/eslint"); 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | auto_generated 2 | gems 3 | *.iml 4 | .idea 5 | node_modules 6 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require("@discourse/lint-configs/prettier"); 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /ldap_users.yml: -------------------------------------------------------------------------------- 1 | - :name: Example User 2 | :email: example_user@gmail.com 3 | :username: example_user 4 | :groups: ['team', 'engineering'] -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | group :development do 6 | gem "rubocop-discourse" 7 | gem "syntax_tree" 8 | end 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | uses: discourse/.github/.github/workflows/discourse-plugin.yml@v1 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discourse-ldap-auth", 3 | "version": "0.8.0", 4 | "repository": "https://github.com/jonmbake/discourse-ldap-auth", 5 | "author": "Jon Bake", 6 | "license": "MIT", 7 | "devDependencies": { 8 | "@discourse/lint-configs": "^1.0.0", 9 | "ember-template-lint": "^5.11.2", 10 | "eslint": "^8.52.0", 11 | "prettier": "^2.8.8" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /codeql-scan.svg: -------------------------------------------------------------------------------- 1 | Scan: CodeQLScanCodeQL -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/ldap_user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class LDAPUser 3 | attr_reader :name, :email, :username, :user 4 | 5 | def initialize (auth_info) 6 | @name = auth_info[:name] 7 | @email = auth_info[:email] 8 | @username = auth_info[:nickname] 9 | @user = SiteSetting.ldap_lookup_users_by == 'username' ? User.find_by_username(@username) : User.find_by_email(@email) 10 | create_user_groups(auth_info[:groups]) unless self.account_exists? 11 | end 12 | 13 | def auth_result 14 | result = Auth::Result.new 15 | result.name = @name 16 | result.username = @username 17 | result.email = @email 18 | result.user = @user 19 | if result.respond_to? :overrides_username 20 | result.overrides_username = true if !account_exists? 21 | else 22 | # TODO: Remove once Discourse 2.8 stable is released 23 | result.omit_username = true 24 | end 25 | result.email_valid = true 26 | result 27 | end 28 | 29 | def account_exists? 30 | !@user.nil? 31 | end 32 | 33 | private 34 | def create_user_groups(user_groups) 35 | return if user_groups.nil? 36 | #user account must exist in order to create user groups 37 | @user = User.create!(name: self.name, email: self.email, username: self.username) 38 | @user.activate 39 | user_groups.each do |group_name| 40 | group = Group.find_by(name: group_name) 41 | @user.groups << group unless group.nil? 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /plugin.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # name:ldap 3 | # about: A plugin to provide ldap authentication. 4 | # version: 0.8.0 5 | # authors: Jon Bake 6 | 7 | enabled_site_setting :ldap_enabled 8 | 9 | gem 'pyu-ruby-sasl', '0.0.3.3', require: false 10 | gem 'rubyntlm', '0.3.4', require: false 11 | gem 'net-ldap', '0.18.0' 12 | 13 | require 'yaml' 14 | require_relative 'lib/omniauth-ldap/adaptor' 15 | require_relative 'lib/omniauth/strategies/ldap' 16 | require_relative 'lib/ldap_user' 17 | 18 | # rubocop:disable Discourse/Plugins/NoMonkeyPatching 19 | class ::LDAPAuthenticator < ::Auth::Authenticator 20 | def name 21 | 'ldap' 22 | end 23 | 24 | def enabled? 25 | true 26 | end 27 | 28 | def after_authenticate(auth_options) 29 | auth_result(auth_options.info) 30 | end 31 | 32 | def register_middleware(omniauth) 33 | omniauth.configure{ |c| c.form_css = File.read(File.expand_path("../css/form.css", __FILE__)) } 34 | omniauth.provider :ldap, 35 | setup: -> (env) { 36 | env["omniauth.strategy"].options.merge!( 37 | host: SiteSetting.ldap_hostname, 38 | port: SiteSetting.ldap_port, 39 | method: SiteSetting.ldap_method, 40 | base: SiteSetting.ldap_base, 41 | uid: SiteSetting.ldap_uid, 42 | # In 0.3.0, we fixed a typo in the ldap_bind_dn config name. This fallback will be removed in a future version. 43 | bind_dn: SiteSetting.ldap_bind_dn.presence || SiteSetting.try(:ldap_bind_db), 44 | password: SiteSetting.ldap_password, 45 | filter: SiteSetting.ldap_filter 46 | ) 47 | } 48 | end 49 | 50 | private 51 | def auth_result(auth_info) 52 | case SiteSetting.ldap_user_create_mode 53 | when 'none' 54 | ldap_user = LDAPUser.new(auth_info) 55 | ldap_user.account_exists? ? ldap_user.auth_result : fail_auth('User account does not exist.') 56 | when 'list' 57 | user_descriptions = load_user_descriptions 58 | return fail_auth('List of users must be provided when ldap_user_create_mode setting is set to \'list\'.') if user_descriptions.nil? 59 | #match on email 60 | match = user_descriptions.find { |ud| auth_info[:email].casecmp(ud[:email]) == 0 } 61 | return fail_auth('User with email is not listed in LDAP user list.') if match.nil? 62 | match[:nickname] = match[:username] || auth_info[:nickname] 63 | match[:name] = match[:name] || auth_info[:name] 64 | LDAPUser.new(match).auth_result 65 | when 'auto' 66 | LDAPUser.new(auth_info).auth_result 67 | else 68 | fail_auth('Invalid option for ldap_user_create_mode setting.') 69 | end 70 | end 71 | def fail_auth(reason) 72 | result = Auth::Result.new 73 | result.failed = true 74 | result.failed_reason = reason 75 | result 76 | end 77 | def load_user_descriptions 78 | file_path = "#{File.expand_path(File.dirname(__FILE__))}/ldap_users.yml" 79 | return nil unless File.exist?(file_path) 80 | YAML.load_file(file_path) 81 | end 82 | end 83 | # rubocop:enable Discourse/Plugins/NoMonkeyPatching 84 | 85 | auth_provider authenticator: LDAPAuthenticator.new 86 | 87 | register_css < 'cn', 11 | 'first_name' => 'givenName', 12 | 'last_name' => 'sn', 13 | 'email' => ['mail', "email", 'userPrincipalName'], 14 | 'phone' => ['telephoneNumber', 'homePhone', 'facsimileTelephoneNumber'], 15 | 'mobile' => ['mobile', 'mobileTelephoneNumber'], 16 | 'nickname' => ['uid', 'userid', 'sAMAccountName'], 17 | 'title' => 'title', 18 | 'location' => {"%0, %1, %2, %3 %4" => [['address', 'postalAddress', 'homePostalAddress', 'street', 'streetAddress'], ['l'], ['st'],['co'],['postOfficeBox']]}, 19 | 'uid' => 'dn', 20 | 'url' => ['wwwhomepage'], 21 | 'image' => 'jpegPhoto', 22 | 'description' => 'description' 23 | } 24 | option :title, "LDAP Authentication" #default title for authentication form 25 | option :port, 389 26 | option :method, :plain 27 | option :uid, 'sAMAccountName' 28 | option :name_proc, lambda {|n| n} 29 | 30 | def request_phase 31 | OmniAuth::LDAP::Adaptor.validate @options 32 | f = OmniAuth::Form.new(title: (options[:title] || "LDAP Authentication"), url: callback_path) 33 | f.text_field 'Login', 'username' 34 | f.password_field 'Password', 'password' 35 | f.button "Sign In" 36 | f.to_response 37 | end 38 | 39 | def callback_phase 40 | @adaptor = OmniAuth::LDAP::Adaptor.new @options 41 | 42 | return fail!(:missing_credentials) if missing_credentials? 43 | begin 44 | @ldap_user_info = @adaptor.bind_as(filter: filter(@adaptor), size: 1, password: request['password']) 45 | return fail!(:invalid_credentials) if !@ldap_user_info 46 | 47 | @user_info = self.class.map_user(@@config, @ldap_user_info) 48 | super 49 | rescue Exception => e 50 | fail!(:ldap_error, e) 51 | end 52 | end 53 | 54 | def filter(adaptor) 55 | if adaptor.filter && !adaptor.filter.empty? 56 | Net::LDAP::Filter.construct(adaptor.filter % {username: @options[:name_proc].call(request['username'])}) 57 | else 58 | Net::LDAP::Filter.eq(adaptor.uid, @options[:name_proc].call(request['username'])) 59 | end 60 | end 61 | 62 | uid { 63 | @user_info["uid"] 64 | } 65 | info { 66 | @user_info 67 | } 68 | extra { 69 | { raw_info: @ldap_user_info } 70 | } 71 | 72 | def self.map_user(mapper, object) 73 | user = {} 74 | mapper.each do |key, value| 75 | case value 76 | when String 77 | user[key] = object[value.downcase.to_sym].first&.to_s if object.respond_to? value.downcase.to_sym 78 | when Array 79 | value.each {|v| (user[key] = object[v.downcase.to_sym].first&.to_s; break;) if object.respond_to? v.downcase.to_sym} 80 | when Hash 81 | value.map do |key1, value1| 82 | pattern = key1.dup 83 | value1.each_with_index do |v,i| 84 | part = ''; v.collect(&:downcase).collect(&:to_sym).each {|v1| (part = object[v1].first&.to_s; break;) if object.respond_to? v1} 85 | pattern.gsub!("%#{i}",part||'') 86 | end 87 | user[key] = pattern 88 | end 89 | end 90 | end 91 | user 92 | end 93 | 94 | protected 95 | 96 | def missing_credentials? 97 | request['username'].nil? or request['username'].empty? or request['password'].nil? or request['password'].empty? 98 | end # missing_credentials? 99 | end 100 | end 101 | end 102 | 103 | OmniAuth.config.add_camelization 'ldap', 'LDAP' 104 | -------------------------------------------------------------------------------- /spec/ldap_authenticator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'rails_helper' 3 | 4 | describe LDAPAuthenticator do 5 | 6 | let(:authenticator) { LDAPAuthenticator.new } 7 | let(:auth_hash) { OmniAuth::AuthHash.new( 8 | info: { 9 | email: 'test@test.org', 10 | nickname: 'tester', 11 | name: 'Testy McTesterson' 12 | } 13 | )} 14 | 15 | context 'when SiteSettings.ldap_user_create_mode is auto' do 16 | it 'will create auth result with ldap entry data and nil user if user with email does not exist' do 17 | result = authenticator.after_authenticate(auth_hash) 18 | expect(result.email).to eq(auth_hash.info[:email]) 19 | expect(result.name).to eq(auth_hash.info[:name]) 20 | expect(result.username).to eq(auth_hash.info[:nickname]) 21 | expect(result.failed?).to eq(false) 22 | expect(result.user).to be_nil 23 | end 24 | end 25 | 26 | context 'when SiteSettings.ldap_user_create_mode is none' do 27 | before do 28 | SiteSetting.ldap_user_create_mode = 'none' 29 | end 30 | it 'will fail auth if user account does not exist' do 31 | result = authenticator.after_authenticate(auth_hash) 32 | expect(result.failed?).to eq(true) 33 | expect(result.failed_reason).to eq('User account does not exist.') 34 | end 35 | it 'will pass auth if user account exists' do 36 | user = Fabricate(:user, email: auth_hash.info[:email]) 37 | result = authenticator.after_authenticate(auth_hash) 38 | expect(result.failed?).to eq(false) 39 | expect(result.user).to eq(user) 40 | end 41 | end 42 | 43 | context 'when SiteSettings.ldap_user_create_mode is list' do 44 | before do 45 | SiteSetting.ldap_user_create_mode = 'list' 46 | Fabricate(:group, name: 'team') 47 | Fabricate(:group, name: 'engineering') 48 | end 49 | it 'will fail auth if list does not contain user with email' do 50 | result = authenticator.after_authenticate(auth_hash) 51 | expect(result.failed?).to eq(true) 52 | expect(result.failed_reason).to eq('User with email is not listed in LDAP user list.') 53 | end 54 | it 'will pass auth if list contains user with email' do 55 | #user account exists 56 | Fabricate(:user, email: 'example_user@gmail.com') 57 | entry = OmniAuth::AuthHash.new({ 58 | info: { 59 | email: 'example_user@gmail.com', 60 | nickname: 'ldap_user', 61 | name: 'LDAP User' 62 | } 63 | }) 64 | result = authenticator.after_authenticate(entry) 65 | expect(result.failed?).to eq(false) 66 | end 67 | it 'will create user groups when creating new user account' do 68 | #user account does not exist 69 | entry = OmniAuth::AuthHash.new({ 70 | info: { 71 | email: 'example_user@gmail.com', 72 | nickname: 'ldap_user', 73 | name: 'LDAP User' 74 | } 75 | }) 76 | result = authenticator.after_authenticate(entry) 77 | expect(result.failed?).to eq(false) 78 | expect(result.email).to eq(entry.info[:email]) 79 | #username and name from ldap_user.yml 80 | expect(result.username).to eq('example_user') 81 | expect(result.name).to eq('Example User') 82 | expect(result.user.groups[0].name).to eq('team') 83 | expect(result.user.groups[1].name).to eq('engineering') 84 | end 85 | end 86 | 87 | context 'when SiteSettings.ldap_lookup_users_by is username' do 88 | before do 89 | SiteSetting.ldap_user_create_mode = 'auto' 90 | SiteSetting.ldap_lookup_users_by = 'username' 91 | end 92 | it 'will lookup user by username' do 93 | user = Fabricate(:user, username: "tester") 94 | result = authenticator.after_authenticate(auth_hash) 95 | expect(result.user).to eq(user) 96 | end 97 | end 98 | 99 | describe "overrides_username behaviour" do 100 | it "overrides username during initial signup" do 101 | result = authenticator.after_authenticate(auth_hash) 102 | expect(result.username).to eq(auth_hash.info[:nickname]) 103 | expect(result.failed?).to eq(false) 104 | expect(result.user).to be_nil 105 | expect(result.overrides_username).to eq(true) 106 | end 107 | 108 | it "does not override username on future logins" do 109 | SiteSetting.ldap_lookup_users_by = 'username' 110 | user = Fabricate(:user, username: "tester") 111 | result = authenticator.after_authenticate(auth_hash) 112 | expect(result.username).to eq(auth_hash.info[:nickname]) 113 | expect(result.failed?).to eq(false) 114 | expect(result.user).to eq(user) 115 | expect(result.overrides_username).to eq(nil) 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CI](https://github.com/jonmbake/discourse-ldap-auth/actions/workflows/plugin-tests.yml/badge.svg)](https://github.com/jonmbake/discourse-ldap-auth/actions/workflows/plugin-tests.yml) 2 | [![CodeQL](https://github.com/jonmbake/discourse-ldap-auth/actions/workflows/codeql.yml/badge.svg)](https://github.com/jonmbake/discourse-ldap-auth/actions/workflows/codeql.yml) 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 | -------------------------------------------------------------------------------- /lib/omniauth-ldap/adaptor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # taken from https://github.com/omniauth/omniauth-ldap/blob/master/lib/omniauth-ldap/adaptor.rb 3 | #this code borrowed pieces from activeldap and net-ldap 4 | require 'rack' 5 | require 'net/ldap' 6 | require 'net/ntlm' 7 | require 'sasl' 8 | require 'kconv' 9 | module OmniAuth 10 | module LDAP 11 | class Adaptor 12 | class LdapError < StandardError; end 13 | class ConfigurationError < StandardError; end 14 | class AuthenticationError < StandardError; end 15 | class ConnectionError < StandardError; end 16 | 17 | VALID_ADAPTER_CONFIGURATION_KEYS = [:host, :port, :method, :bind_dn, :password, :try_sasl, :sasl_mechanisms, :uid, :base, :allow_anonymous, :filter] 18 | 19 | # A list of needed keys. Possible alternatives are specified using sub-lists. 20 | MUST_HAVE_KEYS = [:host, :port, :method, [:uid, :filter], :base] 21 | 22 | METHOD = { 23 | ssl: :simple_tls, 24 | tls: :start_tls, 25 | plain: nil, 26 | } 27 | 28 | attr_accessor :bind_dn, :password 29 | attr_reader :connection, :uid, :base, :auth, :filter 30 | def self.validate(configuration={}) 31 | message = [] 32 | MUST_HAVE_KEYS.each do |names| 33 | names = [names].flatten 34 | missing_keys = names.select{|name| configuration[name].nil?} 35 | if missing_keys == names 36 | message << names.join(' or ') 37 | end 38 | end 39 | raise ArgumentError.new(message.join(",") +" MUST be provided") unless message.empty? 40 | end 41 | def initialize(configuration={}) 42 | Adaptor.validate(configuration) 43 | @configuration = configuration.dup 44 | @configuration[:allow_anonymous] ||= false 45 | @logger = @configuration.delete(:logger) 46 | VALID_ADAPTER_CONFIGURATION_KEYS.each do |name| 47 | instance_variable_set("@#{name}", @configuration[name]) 48 | end 49 | method = ensure_method(@method) 50 | config = { 51 | host: @host, 52 | port: @port, 53 | base: @base 54 | } 55 | @bind_method = @try_sasl ? :sasl : (@allow_anonymous||!@bind_dn||!@password ? :anonymous : :simple) 56 | 57 | 58 | @auth = sasl_auths({username: @bind_dn, password: @password}).first if @bind_method == :sasl 59 | @auth ||= { method: @bind_method, 60 | username: @bind_dn, 61 | password: @password 62 | } 63 | config[:auth] = @auth 64 | config[:encryption] = method 65 | @connection = Net::LDAP.new(config) 66 | end 67 | 68 | #:base => "dc=yourcompany, dc=com", 69 | # :filter => "(mail=#{user})", 70 | # :password => psw 71 | def bind_as(args = {}) 72 | result = false 73 | @connection.open do |me| 74 | rs = me.search args 75 | if rs && rs.first && (dn = rs.first.dn) 76 | password = args[:password] 77 | method = args[:method] || @method 78 | password = password.call if password.respond_to?(:call) 79 | if method == 'sasl' 80 | result = rs.first if me.bind(sasl_auths({username: dn, password: password}).first) 81 | else 82 | result = rs.first if me.bind(method: :simple, username: dn, 83 | password: password) 84 | end 85 | end 86 | end 87 | result 88 | end 89 | 90 | private 91 | def ensure_method(method) 92 | method ||= "plain" 93 | normalized_method = method.to_s.downcase.to_sym 94 | return METHOD[normalized_method] if METHOD.has_key?(normalized_method) 95 | 96 | available_methods = METHOD.keys.collect {|m| m.inspect}.join(", ") 97 | format = "%s is not one of the available connect methods: %s" 98 | raise ConfigurationError, format % [method.inspect, available_methods] 99 | end 100 | 101 | def sasl_auths(options={}) 102 | auths = [] 103 | sasl_mechanisms = options[:sasl_mechanisms] || @sasl_mechanisms 104 | sasl_mechanisms.each do |mechanism| 105 | normalized_mechanism = mechanism.downcase.gsub(/-/, '_') 106 | sasl_bind_setup = "sasl_bind_setup_#{normalized_mechanism}" 107 | next unless respond_to?(sasl_bind_setup, true) 108 | initial_credential, challenge_response = send(sasl_bind_setup, options) 109 | auths << { 110 | method: :sasl, 111 | initial_credential: initial_credential, 112 | mechanism: mechanism, 113 | challenge_response: challenge_response 114 | } 115 | end 116 | auths 117 | end 118 | 119 | def sasl_bind_setup_digest_md5(options) 120 | bind_dn = options[:username] 121 | initial_credential = "" 122 | challenge_response = Proc.new do |cred| 123 | pref = SASL::Preferences.new digest_uri: "ldap/#{@host}", username: bind_dn, has_password?: true, password: options[:password] 124 | sasl = SASL.new("DIGEST-MD5", pref) 125 | response = sasl.receive("challenge", cred) 126 | response[1] 127 | end 128 | [initial_credential, challenge_response] 129 | end 130 | 131 | def sasl_bind_setup_gss_spnego(options) 132 | bind_dn = options[:username] 133 | psw = options[:password] 134 | raise LdapError.new( "invalid binding information" ) unless (bind_dn && psw) 135 | 136 | nego = proc {|challenge| 137 | t2_msg = Net::NTLM::Message.parse( challenge ) 138 | bind_dn, domain = bind_dn.split('\\').reverse 139 | t2_msg.target_name = Net::NTLM::encode_utf16le(domain) if domain 140 | t3_msg = t2_msg.response( {user: bind_dn, password: psw}, {ntlmv2: true} ) 141 | t3_msg.serialize 142 | } 143 | [Net::NTLM::Message::Type1.new.serialize, nego] 144 | end 145 | 146 | end 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activesupport (8.1.1) 5 | base64 6 | bigdecimal 7 | concurrent-ruby (~> 1.0, >= 1.3.1) 8 | connection_pool (>= 2.2.5) 9 | drb 10 | i18n (>= 1.6, < 2) 11 | json 12 | logger (>= 1.4.2) 13 | minitest (>= 5.1) 14 | securerandom (>= 0.3) 15 | tzinfo (~> 2.0, >= 2.0.5) 16 | uri (>= 0.13.1) 17 | ast (2.4.3) 18 | base64 (0.3.0) 19 | bigdecimal (3.3.1) 20 | concurrent-ruby (1.3.5) 21 | connection_pool (3.0.2) 22 | drb (2.2.3) 23 | i18n (1.14.7) 24 | concurrent-ruby (~> 1.0) 25 | json (2.17.1) 26 | language_server-protocol (3.17.0.5) 27 | lint_roller (1.1.0) 28 | logger (1.7.0) 29 | minitest (5.26.2) 30 | parallel (1.27.0) 31 | parser (3.3.10.0) 32 | ast (~> 2.4.1) 33 | racc 34 | prettier_print (1.2.1) 35 | prism (1.6.0) 36 | racc (1.8.1) 37 | rack (3.2.4) 38 | rainbow (3.1.1) 39 | regexp_parser (2.11.3) 40 | rubocop (1.81.7) 41 | json (~> 2.3) 42 | language_server-protocol (~> 3.17.0.2) 43 | lint_roller (~> 1.1.0) 44 | parallel (~> 1.10) 45 | parser (>= 3.3.0.2) 46 | rainbow (>= 2.2.2, < 4.0) 47 | regexp_parser (>= 2.9.3, < 3.0) 48 | rubocop-ast (>= 1.47.1, < 2.0) 49 | ruby-progressbar (~> 1.7) 50 | unicode-display_width (>= 2.4.0, < 4.0) 51 | rubocop-ast (1.48.0) 52 | parser (>= 3.3.7.2) 53 | prism (~> 1.4) 54 | rubocop-capybara (2.22.1) 55 | lint_roller (~> 1.1) 56 | rubocop (~> 1.72, >= 1.72.1) 57 | rubocop-discourse (3.14.0) 58 | activesupport (>= 6.1) 59 | lint_roller (>= 1.1.0) 60 | rubocop-capybara (>= 2.22.0) 61 | rubocop-discourse-base (>= 1.0.0) 62 | rubocop-factory_bot (>= 2.27.0) 63 | rubocop-rails (>= 2.30.3) 64 | rubocop-rspec (>= 3.0.1) 65 | rubocop-rspec_rails (>= 2.31.0) 66 | rubocop-discourse-base (1.0.0) 67 | rubocop (>= 1.80.0) 68 | rubocop-factory_bot (2.28.0) 69 | lint_roller (~> 1.1) 70 | rubocop (~> 1.72, >= 1.72.1) 71 | rubocop-rails (2.34.2) 72 | activesupport (>= 4.2.0) 73 | lint_roller (~> 1.1) 74 | rack (>= 1.1) 75 | rubocop (>= 1.75.0, < 2.0) 76 | rubocop-ast (>= 1.44.0, < 2.0) 77 | rubocop-rspec (3.8.0) 78 | lint_roller (~> 1.1) 79 | rubocop (~> 1.81) 80 | rubocop-rspec_rails (2.32.0) 81 | lint_roller (~> 1.1) 82 | rubocop (~> 1.72, >= 1.72.1) 83 | rubocop-rspec (~> 3.5) 84 | ruby-progressbar (1.13.0) 85 | securerandom (0.4.1) 86 | syntax_tree (6.3.0) 87 | prettier_print (>= 1.2.0) 88 | tzinfo (2.0.6) 89 | concurrent-ruby (~> 1.0) 90 | unicode-display_width (3.2.0) 91 | unicode-emoji (~> 4.1) 92 | unicode-emoji (4.1.0) 93 | uri (1.1.1) 94 | 95 | PLATFORMS 96 | arm64-darwin-24 97 | ruby 98 | 99 | DEPENDENCIES 100 | rubocop-discourse 101 | syntax_tree 102 | 103 | CHECKSUMS 104 | activesupport (8.1.1) sha256=5e92534e8d0c8b8b5e6b16789c69dbea65c1d7b752269f71a39422e9546cea67 105 | ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 106 | base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b 107 | bigdecimal (3.3.1) sha256=eaa01e228be54c4f9f53bf3cc34fe3d5e845c31963e7fcc5bedb05a4e7d52218 108 | concurrent-ruby (1.3.5) sha256=813b3e37aca6df2a21a3b9f1d497f8cbab24a2b94cab325bffe65ee0f6cbebc6 109 | connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a 110 | drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 111 | i18n (1.14.7) sha256=ceba573f8138ff2c0915427f1fc5bdf4aa3ab8ae88c8ce255eb3ecf0a11a5d0f 112 | json (2.17.1) sha256=e0e4824541336a44915436f53e7ea74c687314fb8f88080fa1456f6a34ead92e 113 | language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc 114 | lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 115 | logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 116 | minitest (5.26.2) sha256=f021118a6185b9ba9f5af71f2ba103ad770c75afde9f2ab8da512677c550cde3 117 | parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 118 | parser (3.3.10.0) sha256=ce3587fa5cc55a88c4ba5b2b37621b3329aadf5728f9eafa36bbd121462aabd6 119 | prettier_print (1.2.1) sha256=a72838b5f23facff21f90a5423cdcdda19e4271092b41f4ea7f50b83929e6ff9 120 | prism (1.6.0) sha256=bfc0281a81718c4872346bc858dc84abd3a60cae78336c65ad35c8fbff641c6b 121 | racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f 122 | rack (3.2.4) sha256=5d74b6f75082a643f43c1e76b419c40f0e5527fcfee1e669ac1e6b73c0ccb6f6 123 | rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a 124 | regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 125 | rubocop (1.81.7) sha256=6fb5cc298c731691e2a414fe0041a13eb1beed7bab23aec131da1bcc527af094 126 | rubocop-ast (1.48.0) sha256=22df9bbf3f7a6eccde0fad54e68547ae1e2a704bf8719e7c83813a99c05d2e76 127 | rubocop-capybara (2.22.1) sha256=ced88caef23efea53f46e098ff352f8fc1068c649606ca75cb74650970f51c0c 128 | rubocop-discourse (3.14.0) sha256=6262935d684312bd8f7efe3c238a7b6c6fce4e2030f89704bdaed91597076592 129 | rubocop-discourse-base (1.0.0) sha256=a4121f0f2a8e32c3259fee22106af9fd35372cbeac14b0c69673bdee79b472b7 130 | rubocop-factory_bot (2.28.0) sha256=4b17fc02124444173317e131759d195b0d762844a71a29fe8139c1105d92f0cb 131 | rubocop-rails (2.34.2) sha256=10ff246ee48b25ffeabddc5fee86d159d690bb3c7b9105755a9c7508a11d6e22 132 | rubocop-rspec (3.8.0) sha256=28440dccb3f223a9938ca1f946bd3438275b8c6c156dab909e2cb8bc424cab33 133 | rubocop-rspec_rails (2.32.0) sha256=4a0d641c72f6ebb957534f539d9d0a62c47abd8ce0d0aeee1ef4701e892a9100 134 | ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 135 | securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 136 | syntax_tree (6.3.0) sha256=56e25a9692c798ec94c5442fe94c5e94af76bef91edc8bb02052cbdecf35f13d 137 | tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b 138 | unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 139 | unicode-emoji (4.1.0) sha256=4997d2d5df1ed4252f4830a9b6e86f932e2013fbff2182a9ce9ccabda4f325a5 140 | uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 141 | 142 | BUNDLED WITH 143 | 4.0.1 144 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'Version to release (e.g., 0.7.1)' 8 | required: true 9 | type: string 10 | 11 | permissions: 12 | contents: write 13 | pull-requests: write 14 | 15 | jobs: 16 | release: 17 | name: Create Release 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | token: ${{ secrets.GITHUB_TOKEN }} 26 | 27 | - name: Setup git 28 | run: | 29 | git config user.name "github-actions[bot]" 30 | git config user.email "github-actions[bot]@users.noreply.github.com" 31 | 32 | - name: Get version 33 | id: get_version 34 | run: | 35 | VERSION="${{ inputs.version }}" 36 | # Remove 'v' prefix if present 37 | VERSION_NUMBER="${VERSION#v}" 38 | echo "version=v${VERSION_NUMBER}" >> $GITHUB_OUTPUT 39 | echo "version_number=${VERSION_NUMBER}" >> $GITHUB_OUTPUT 40 | echo "branch_name=release/v${VERSION_NUMBER}" >> $GITHUB_OUTPUT 41 | 42 | - name: Create release branch 43 | run: | 44 | git checkout -b ${{ steps.get_version.outputs.branch_name }} 45 | 46 | - name: Update plugin.rb version 47 | run: | 48 | sed -i.bak "s/^# version: .*$/# version: ${{ steps.get_version.outputs.version_number }}/" plugin.rb 49 | rm plugin.rb.bak 50 | 51 | - name: Update package.json version 52 | run: | 53 | sed -i.bak "s/\"version\": \".*\"/\"version\": \"${{ steps.get_version.outputs.version_number }}\"/" package.json 54 | rm package.json.bak 55 | 56 | - name: Commit version updates 57 | run: | 58 | git add plugin.rb package.json 59 | git commit -m "Bump version to ${{ steps.get_version.outputs.version_number }}" 60 | git push origin ${{ steps.get_version.outputs.branch_name }} 61 | 62 | - name: Generate changelog 63 | id: changelog 64 | run: | 65 | # Get the previous tag 66 | PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") 67 | 68 | if [ -z "$PREV_TAG" ]; then 69 | echo "No previous tag found, generating changelog from initial commit" 70 | CHANGELOG=$(git log HEAD --pretty=format:"- %s (%h)" --reverse) 71 | else 72 | echo "Generating changelog from ${PREV_TAG} to HEAD" 73 | CHANGELOG=$(git log ${PREV_TAG}..HEAD --pretty=format:"- %s (%h)") 74 | fi 75 | 76 | # Write changelog to file for multiline handling 77 | echo "$CHANGELOG" > /tmp/changelog.txt 78 | 79 | # Set output using multiline format 80 | { 81 | echo "notes<> $GITHUB_OUTPUT 85 | 86 | - name: Create Pull Request 87 | id: create_pr 88 | uses: actions/github-script@v7 89 | with: 90 | script: | 91 | const { data: pr } = await github.rest.pulls.create({ 92 | owner: context.repo.owner, 93 | repo: context.repo.repo, 94 | title: `Release ${{ steps.get_version.outputs.version }}`, 95 | head: `${{ steps.get_version.outputs.branch_name }}`, 96 | base: 'master', 97 | body: `## Changes in ${{ steps.get_version.outputs.version }} 98 | 99 | ${{ steps.changelog.outputs.notes }} 100 | 101 | ## Installation 102 | 103 | Follow the [plugin installation guide](https://meta.discourse.org/t/install-a-plugin/19157) using this repository. 104 | 105 | --- 106 | 107 | This PR was automatically created by the release workflow. After merging, the release and tag will be created automatically.` 108 | }); 109 | core.setOutput('pr_number', pr.number); 110 | core.setOutput('pr_url', pr.html_url); 111 | return pr.number; 112 | 113 | - name: Summary 114 | run: | 115 | echo "## Release PR Created! :rocket:" >> $GITHUB_STEP_SUMMARY 116 | echo "" >> $GITHUB_STEP_SUMMARY 117 | echo "Version: ${{ steps.get_version.outputs.version }}" >> $GITHUB_STEP_SUMMARY 118 | echo "PR: ${{ steps.create_pr.outputs.pr_url }}" >> $GITHUB_STEP_SUMMARY 119 | echo "" >> $GITHUB_STEP_SUMMARY 120 | echo "After the PR is merged, run the 'Create Release Tag' workflow to create the GitHub release." >> $GITHUB_STEP_SUMMARY 121 | 122 | create-release: 123 | name: Create Release After Merge 124 | runs-on: ubuntu-latest 125 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads/master') 126 | 127 | steps: 128 | - name: Checkout code 129 | uses: actions/checkout@v4 130 | with: 131 | fetch-depth: 0 132 | 133 | - name: Check if this is a version bump commit 134 | id: check_version_bump 135 | run: | 136 | COMMIT_MSG=$(git log -1 --pretty=%B) 137 | if [[ "$COMMIT_MSG" =~ ^Bump\ version\ to\ ([0-9]+\.[0-9]+\.[0-9]+) ]]; then 138 | VERSION="${BASH_REMATCH[1]}" 139 | echo "is_version_bump=true" >> $GITHUB_OUTPUT 140 | echo "version=v${VERSION}" >> $GITHUB_OUTPUT 141 | echo "version_number=${VERSION}" >> $GITHUB_OUTPUT 142 | else 143 | echo "is_version_bump=false" >> $GITHUB_OUTPUT 144 | fi 145 | 146 | - name: Create and push tag 147 | if: steps.check_version_bump.outputs.is_version_bump == 'true' 148 | run: | 149 | git config user.name "github-actions[bot]" 150 | git config user.email "github-actions[bot]@users.noreply.github.com" 151 | git tag ${{ steps.check_version_bump.outputs.version }} 152 | git push origin ${{ steps.check_version_bump.outputs.version }} 153 | 154 | - name: Generate changelog 155 | if: steps.check_version_bump.outputs.is_version_bump == 'true' 156 | id: changelog 157 | run: | 158 | # Get the previous tag 159 | PREV_TAG=$(git describe --tags --abbrev=0 ${{ steps.check_version_bump.outputs.version }}^ 2>/dev/null || echo "") 160 | 161 | if [ -z "$PREV_TAG" ]; then 162 | echo "No previous tag found, generating changelog from initial commit" 163 | CHANGELOG=$(git log ${{ steps.check_version_bump.outputs.version }} --pretty=format:"- %s (%h)" --reverse) 164 | else 165 | echo "Generating changelog from ${PREV_TAG} to ${{ steps.check_version_bump.outputs.version }}" 166 | CHANGELOG=$(git log ${PREV_TAG}..${{ steps.check_version_bump.outputs.version }} --pretty=format:"- %s (%h)") 167 | fi 168 | 169 | # Write changelog to file for multiline handling 170 | echo "$CHANGELOG" > /tmp/changelog.txt 171 | 172 | # Set output using multiline format 173 | { 174 | echo "notes<> $GITHUB_OUTPUT 178 | 179 | - name: Create Release 180 | if: steps.check_version_bump.outputs.is_version_bump == 'true' 181 | uses: actions/create-release@v1 182 | env: 183 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 184 | with: 185 | tag_name: ${{ steps.check_version_bump.outputs.version }} 186 | release_name: Release ${{ steps.check_version_bump.outputs.version }} 187 | body: | 188 | ## Changes in ${{ steps.check_version_bump.outputs.version }} 189 | 190 | ${{ steps.changelog.outputs.notes }} 191 | 192 | ## Installation 193 | 194 | Follow the [plugin installation guide](https://meta.discourse.org/t/install-a-plugin/19157) using this repository. 195 | draft: false 196 | prerelease: false 197 | --------------------------------------------------------------------------------