├── .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 |
--------------------------------------------------------------------------------
/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 | [](https://github.com/jonmbake/discourse-ldap-auth/actions/workflows/plugin-tests.yml)
2 | [](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 | 
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------