Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,22 @@ DEVISE_PEPPER=
# LDAP Configuration (optional - for LDAP authentication)
# -----------------------------------------------------------------------------

# Enable LDAP debug logging (true/false)
DEVISE_LDAP_LOGGER=

# Use admin credentials to bind to LDAP (true/false)
DEVISE_LDAP_LOGGER_ADMIN_BIND=
# Enable LDAP authentication: true or false (1/0, yes/no and on/off also work).
# Any other value is rejected: the error is logged and local authentication is used.
LDAP_ENABLED=false

# Use the admin credentials from config/ldap.yml for directory searches: true or false.
# false searches the directory anonymously.
LDAP_ADMIN_BIND=false

# Deprecated: only read while the variable above is unset or empty, and a warning
# is logged at boot. They keep their historical meaning, which is easy to misread:
# - DEVISE_LDAP_LOGGER enables LDAP with any non-empty value, including "false".
# - DEVISE_LDAP_LOGGER_ADMIN_BIND enables the admin bind whenever it is defined,
# including when it is empty or "false".
# Replace them with LDAP_ENABLED and LDAP_ADMIN_BIND.
# DEVISE_LDAP_LOGGER=
# DEVISE_LDAP_LOGGER_ADMIN_BIND=

# -----------------------------------------------------------------------------
# CORS (Cross-Origin Resource Sharing)
Expand Down
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ gem 'bootsnap', '>= 1.4.5', require: false
gem 'rack-cors', require: 'rack/cors'

gem 'devise', '>= 5.0.3'
gem 'devise_ldap_authenticatable'
gem 'devise-security'
gem 'net-ldap', '>= 0.21.0'

gem 'doorkeeper'
gem 'ostruct'
Expand Down
6 changes: 1 addition & 5 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,6 @@ GEM
rails-i18n
devise-security (0.18.0)
devise (>= 4.3.0)
devise_ldap_authenticatable (0.8.7)
devise (>= 3.4.1)
net-ldap (>= 0.16.0)
doorkeeper (5.9.9)
railties (>= 5)
doorkeeper-i18n (5.2.9)
Expand Down Expand Up @@ -340,14 +337,14 @@ DEPENDENCIES
devise (>= 5.0.3)
devise-i18n
devise-security
devise_ldap_authenticatable
doorkeeper
doorkeeper-i18n
dotenv-rails
factory_bot_rails
json (< 3)
mime-types (~> 3.7)
minitest (~> 6.0)
net-ldap (>= 0.21.0)
ostruct
pg
puma
Expand Down Expand Up @@ -392,7 +389,6 @@ CHECKSUMS
devise (5.0.4) sha256=d605f2b85854e74e56ee789e2d398702bc2d06e6bcd894717a670a3199c74cc1
devise-i18n (1.16.1) sha256=66321fa28111a78de25cbd7d4b5e7edcf67d3b73a419a8df563365849f68b2ba
devise-security (0.18.0) sha256=fc06be1624b5151044ff9c5d8e61abdfa7d56eb16bfdaec16a11235d54708513
devise_ldap_authenticatable (0.8.7) sha256=8af6f839661e24ca9afc5a1508a7ec7e1327e93af4516f2baabacdf511ee5a2e
doorkeeper (5.9.9) sha256=691fc87815c2ea7c075518be26855c2343bb3cf6a41400a92a5782f62786ec86
doorkeeper-i18n (5.2.9) sha256=dea73afae44ffbf3bf7f2921b24ca801ee199d931591f15e24cb07af9c45b4a4
dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d
Expand Down
102 changes: 102 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,108 @@ See the [Install PostgreSQL](https://github.com/LINCnil/pia-back/wiki/Install-Po

![PIA Settings](public/pia-settings.png)

## LDAP authentication

Configure the LDAP server for each environment in `config/ldap.yml`. Development
reads `ldap_host`, `ldap_port`, `ldap_attribute`, `ldap_base`, `ldap_admin_user`,
`ldap_admin_user_password` and `ldap_ssl` from Rails credentials.

- Set `LDAP_ENABLED` to `true` to enable LDAP authentication and to `false` to
disable it (`1`/`0`, `yes`/`no` and `on`/`off` are also accepted, ignoring
case). Any other value is rejected: the error is logged on each login and local
authentication is used instead.
- Set `LDAP_ADMIN_BIND` to `true` to use the configured admin credentials for
directory searches, or `false` (the default) for anonymous searches. It accepts
the same values as `LDAP_ENABLED`.
- Deprecated: `DEVISE_LDAP_LOGGER` and `DEVISE_LDAP_LOGGER_ADMIN_BIND` are read
only while `LDAP_ENABLED` and `LDAP_ADMIN_BIND` respectively are unset or
empty, and keep their historical meaning: any non-empty `DEVISE_LDAP_LOGGER`
enables LDAP, even the string `false`, and any defined
`DEVISE_LDAP_LOGGER_ADMIN_BIND`, even an empty one, enables the admin bind.
A warning at boot states the value each of them currently stands for, so
renaming one without checking can reverse the behavior. `DEVISE_LDAP_LOGGER`
no longer controls debug logging.
- Set `ssl` to `simple_tls` (or `true`) for LDAPS, or `start_tls` for StartTLS.
The server certificate and hostname are verified using the system trust store.
An unset or false `ssl` retains legacy plaintext LDAP behavior and logs a
warning: credentials will be transmitted without encryption.

For a private CA, configure `encryption`, which takes precedence over `ssl`:

```yaml
encryption:
method: start_tls
tls_options:
ca_file: /path/to/ldap-ca.pem
```

The previous LDAP integration did not validate the certificate chain, so a
self-signed or private-CA certificate that used to work now fails with
`LDAP TLS certificate verification failed` in the logs. Trusting the CA as above
is the recommended fix. As a last resort, verification can be disabled
explicitly:

```yaml
encryption:
method: start_tls
verify_certificates: false
```

`verify_certificates` must be a boolean and is only read from `encryption`;
`tls_options` cannot disable verification. Setting it to `false` turns off both
certificate and hostname verification, so anyone able to intercept the
connection can impersonate the directory and capture passwords. A warning is
logged on every connection.

The login is searched using the configured attribute. If the search returns no
user, or anonymous binding or searching is refused, authentication also tries
the legacy DN format `attribute=login,base` and logs an `info` message that does
not identify the user. A successful first LDAP login creates a local user with
the LDAP `mail` attribute, or the login when no email is available.

Directories usually ignore case and extra spaces when searching, so `ALICE` and
` alice ` both find the user stored as `alice`. The local account is matched on
the login as spelled by the directory, not on what was typed, so every spelling
signs in to the same account. When the directory does not return the login
attribute (for example when the legacy DN format is used), the typed login is
used. Accounts created by the previous integration with another spelling are
still found by the typed login when no account matches the directory spelling.
Local logins are never matched case-insensitively, because some directories
distinguish `Alice` from `alice`.

A first LDAP login never links itself to an existing local account by email: the
directory `mail` attribute is not proof that the person owns that account. When
the LDAP email (or the login, if the directory returns no `mail`) already
belongs to a local account with another `login`, no account is created and the
token request fails with a 422 error; the logs show `Validation failed: Email
has already been taken`. To let that user sign in with LDAP, set the `login` of
the existing local account to the LDAP login, for example from a Rails console:

```ruby
User.find_by!(email: 'alice@example.test').update!(login: 'alice')
```

Alternatively, give one of the two accounts another email. The `users` API does
not accept a `login`. Two simultaneous first logins of the same user can make one
of the requests fail, because the other one creates the account; signing in
again succeeds.

If the LDAP server refuses the user bind, whatever the result code
(wrong password, locked or disabled account, ...), the result code is logged and
existing local email/password authentication remains available. The same applies
when the LDAP check itself fails (connection or TLS error, invalid configuration,
ambiguous search result): the error is logged at `error` level and the login falls
back to local authentication, so local administrator accounts keep working during
an LDAP outage. Users created by a first LDAP login have a random local password,
so they cannot sign in with their LDAP password until the directory is available
again. A hanging directory delays each login by up to 10 seconds before the
fallback.

The previous LDAP group and attribute authorization checks were disabled in
this application and are not implemented. An LDAP configuration that explicitly
enables group checks is rejected rather than silently bypassing them. LDAP
password changes are not performed by this application.

## How to update to the latest version in a development environment

Go to the folder pia-back : `cd pia-back`
Expand Down
4 changes: 0 additions & 4 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
class ApplicationController < ActionController::API
rescue_from DeviseLdapAuthenticatable::LdapException do |exception|
render text: exception, status: :internal_server_error
end

include Pundit::Authorization if ENV['ENABLE_AUTHENTICATION'].present?
before_action :doorkeeper_authorize!, except: %i[info]
before_action :active_storage_url_options
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def index
def create
user = User.new(user_params)

password = [*'0'..'9', *'a'..'z', *'A'..'Z', *'!'..'?'].sample(16).join
password = User.random_password
user.password = password
user.password_confirmation = password

Expand Down
50 changes: 27 additions & 23 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,34 +29,38 @@ def validate_login_uniqueness
errors.add(:login, :taken) if User.where(login: login).where.not(id: id).exists?
end

def check_ldap_email
if email.blank?
mail = Devise::LDAP::Adapter.get_ldap_param(login, 'mail')
self.email = if mail.present?
mail.first
else # No email, use login
login
end
RANDOM_PASSWORD_CHARS = [*'a'..'z', *'A'..'Z', *'0'..'9', *'!@#$%^&*()-_=+[]{}<>?'.chars].freeze
RANDOM_PASSWORD_LENGTH = 64
RANDOM_PASSWORD_ATTEMPTS = 10

# Uniformly random password, drawn again until the model's own password rules accept it, so they are not
# restated here. Gives up rather than looping forever when the rules cannot be met with these characters.
def self.random_password(random: SecureRandom)
RANDOM_PASSWORD_ATTEMPTS.times do
password = random.alphanumeric(RANDOM_PASSWORD_LENGTH, chars: RANDOM_PASSWORD_CHARS)
return password if password_accepted?(password)
end
self.is_user = true
end

def self.check_ldap_credentials(login, password)
Devise::LDAP::Adapter.valid_credentials?(login, password)
raise 'Could not generate a random password accepted by the password rules'
end

# create user with ldap
def self.create_with_ldap(login)
user = User.new
user.login = login
# Devise hashes a password as soon as it is assigned, so each candidate costs one hash.
def self.password_accepted?(password)
candidate = new(password: password, password_confirmation: password)
candidate.validate
candidate.errors[:password].empty?
end
private_class_method :password_accepted?

password = [*'0'..'9', *'a'..'z', *'A'..'Z', *'!'..'?'].sample(16).join
user.password = password
user.password_confirmation = password
user.check_ldap_email
user.generate_uuid
user.save
user
def self.create_with_ldap(login, email)
password = random_password
create!(
login: login,
email: email.presence || login,
password: password,
password_confirmation: password,
is_user: true
)
end

def generate_uuid
Expand Down
Loading
Loading