1
0
مراية لـ https://github.com/postalserver/postal.git تم المزامنة 2025-12-01 05:43:04 +00:00

feat: openid connect support (#2873)

هذا الالتزام موجود في:
Adam Cooke
2024-03-12 17:40:07 +00:00
ملتزم من قبل GitHub
الأصل 4e13577891
التزام 5ed94f6f85
28 ملفات معدلة مع 854 إضافات و232 حذوفات

عرض الملف

@@ -32,6 +32,13 @@ gem "sentry-rails"
gem "turbolinks", "~> 5"
gem "webrick"
group :oidc do
# These are gems which are needed for OpenID connect. They are only required by the application
# when OIDC is enabled in the Postal configuration.
gem "omniauth_openid_connect"
gem "omniauth-rails_csrf_protection"
end
group :development, :assets do
gem "coffee-rails", "~> 5.0"
gem "jquery-rails"

عرض الملف

@@ -68,16 +68,20 @@ GEM
tzinfo (~> 2.0)
addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0)
aes_key_wrap (1.1.0)
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0)
ast (2.4.2)
attr_required (1.0.2)
authie (4.1.3)
activerecord (>= 6.1, < 8.0)
autoprefixer-rails (10.4.13.0)
execjs (~> 2)
base64 (0.2.0)
bcrypt (3.1.20)
bigdecimal (3.1.6)
bindata (2.5.0)
builder (3.2.4)
chronic (0.10.2)
coffee-rails (5.0.0)
@@ -106,6 +110,8 @@ GEM
dynamic_form (1.3.1)
actionview (> 5.2.0)
activemodel (> 5.2.0)
email_validator (2.2.4)
activemodel
encrypto_signo (1.0.0)
erubi (1.12.0)
execjs (2.7.0)
@@ -114,6 +120,12 @@ GEM
factory_bot_rails (6.4.3)
factory_bot (~> 6.4)
railties (>= 5.0.0)
faraday (2.9.0)
faraday-net_http (>= 2.0, < 3.2)
faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3)
faraday-net_http (3.1.0)
net-http
ffi (1.15.5)
gelf (3.1.0)
json
@@ -133,6 +145,13 @@ GEM
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.7.1)
json-jwt (1.16.6)
activesupport (>= 4.2)
aes_key_wrap
base64
bindata
faraday (~> 2.0)
faraday-follow_redirects
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2)
@@ -169,6 +188,8 @@ GEM
json
rack (>= 1.4)
mysql2 (0.5.6)
net-http (0.4.1)
uri
net-imap (0.4.10)
date
net-protocol
@@ -194,6 +215,29 @@ GEM
racc (~> 1.4)
nokogiri (1.16.2-x86_64-linux)
racc (~> 1.4)
omniauth (2.1.2)
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
omniauth-rails_csrf_protection (1.0.1)
actionpack (>= 4.2)
omniauth (~> 2.0)
omniauth_openid_connect (0.7.1)
omniauth (>= 1.9, < 3)
openid_connect (~> 2.2)
openid_connect (2.3.0)
activemodel
attr_required (>= 1.0.0)
email_validator
faraday (~> 2.0)
faraday-follow_redirects
json-jwt (>= 1.16)
mail
rack-oauth2 (~> 2.2)
swd (~> 2.0)
tzinfo
validate_url
webfinger (~> 2.0)
parallel (1.22.1)
parser (3.2.1.1)
ast (~> 2.4.1)
@@ -203,6 +247,16 @@ GEM
nio4r (~> 2.0)
racc (1.7.3)
rack (2.2.8.1)
rack-oauth2 (2.2.1)
activesupport
attr_required
faraday (~> 2.0)
faraday-follow_redirects
json-jwt (>= 1.11.0)
rack (>= 2.1.0)
rack-protection (3.2.0)
base64 (>= 0.1.0)
rack (~> 2.2, >= 2.2.4)
rack-test (2.1.0)
rack (>= 1.3)
rails (7.0.8.1)
@@ -302,6 +356,11 @@ GEM
actionpack (>= 5.2)
activesupport (>= 5.2)
sprockets (>= 3.0.0)
swd (2.0.3)
activesupport (>= 3)
attr_required (>= 0.0.5)
faraday (~> 2.0)
faraday-follow_redirects
temple (0.10.3)
thor (1.3.0)
tilt (2.3.0)
@@ -315,6 +374,14 @@ GEM
uglifier (4.2.0)
execjs (>= 0.3.0, < 3)
unicode-display_width (2.4.2)
uri (0.13.0)
validate_url (1.0.15)
activemodel (>= 3.0.0)
public_suffix
webfinger (2.1.3)
activesupport
faraday (~> 2.0)
faraday-follow_redirects
webmock (3.20.0)
addressable (>= 2.8.0)
crack (>= 0.3.2)
@@ -360,6 +427,8 @@ DEPENDENCIES
nifty-utils
nilify_blanks
nio4r
omniauth-rails_csrf_protection
omniauth_openid_connect
prometheus-client
puma
rails (= 7.0.8.1)

عرض الملف

@@ -1,6 +1,4 @@
.loginForm {
}
.loginForm {}
.loginForm__input {
@@ -20,3 +18,15 @@
text-decoration: underline;
line-height: 1.7;
}
.loginForm__divider {
margin-top: 25px;
margin-bottom: 25px;
border-top: 1px solid #e4e8ef;
}
.loginForm__localTitle {
text-align: center;
margin-bottom: 15px;
color: #999;
}

عرض الملف

@@ -12,6 +12,7 @@
display: flex;
align-items: center;
}
.userList__item:nth-child(even) {
background: none;
}
@@ -20,30 +21,23 @@
border-top: 1px solid lighten(#ccd4e0, 10%);
}
.userList__avatar {
width:50px;
height:50px;
border-radius:50%;
background:#fff;
border:2px solid #efefef;
padding:3px;
flex: 0 0 auto;
}
.userList__details {
flex: 1 1 auto;
margin:0 25px;
margin: 0 0;
}
.userList__actions {
flex: 0 0 auto;
width:180px;
width: 120px;
font-size: 12px;
line-height: 1.5;
color: #999;
a {
text-decoration: underline;
}
}
.userList__name {
font-weight: 600;
@@ -63,10 +57,9 @@
background-color: #ccc;
}
.userList__admin {
.userList__tag {
vertical-align: 2px;
margin-left:5px;
background-color:$blue;
margin-left: 3px;
}
.userList__revoke {

عرض الملف

@@ -11,13 +11,16 @@
padding: 6px 15px;
border: 2px solid transparent;
border-bottom: 2px solid darken($blue, 20%);
&:active {
background-color: darken($blue, 15%);
}
&:focus {
border-color: darken($blue, 15%);
background-color: lighten($blue, 5%);
}
&.is-spinning {
color: transparent;
background-repeat: no-repeat;
@@ -36,13 +39,16 @@
.button--positive {
background-color: $green;
border-bottom-color: darken($green, 15%);
&:active {
background-color: darken($green, 15%);
}
&:focus {
border-color: darken($green, 15%);
background-color: lighten($green, 5%);
}
&.is-spinning {
background-image: image-url('button-spinner-positive.gif');
}
@@ -53,13 +59,16 @@
.button--neutral {
background-color: #ccc;
border-bottom-color: darken(#ccc, 15%);
&:active {
background-color: darken(#ccc, 15%);
}
&:focus {
border-color: darken(#ccc, 15%);
background-color: lighten(#ccc, 5%);
}
&.is-spinning {
background-image: image-url('button-spinner-neutral.gif');
}
@@ -68,13 +77,16 @@
.button--danger {
background-color: $red;
border-bottom-color: darken($red, 15%);
&:active {
background-color: darken($red, 15%);
}
&:focus {
border-color: darken($red, 15%);
background-color: lighten($red, 5%);
}
&.is-spinning {
background-image: image-url('button-spinner-danger.gif');
}
@@ -83,16 +95,23 @@
.button--dark {
background-color: $darkBlue;
border-bottom-color: darken($darkBlue, 15%);
&:active {
background-color: darken($darkBlue, 15%);
}
&:focus {
border-color: darken($darkBlue, 15%);
background-color: lighten($darkBlue, 5%);
}
&.is-spinning {
background-image: image-url('button-spinner-dark.gif');
}
}
.button--full {
width: 100%;
text-align: center;
}

عرض الملف

@@ -126,6 +126,7 @@
.domainList__ssl {
color: $green;
&:hover {
text-decoration: underline;
}

عرض الملف

@@ -4,7 +4,8 @@ class SessionsController < ApplicationController
layout "sub"
skip_before_action :login_required, only: [:new, :create, :begin_password_reset, :finish_password_reset, :ip, :raise_error]
before_action :require_local_authentication, only: [:create, :begin_password_reset, :finish_password_reset]
skip_before_action :login_required, only: [:new, :create, :begin_password_reset, :finish_password_reset, :ip, :raise_error, :create_from_oidc, :oauth_failure]
def create
login(User.authenticate(params[:email_address], params[:password]))
@@ -29,12 +30,16 @@ class SessionsController < ApplicationController
def begin_password_reset
return unless request.post?
if user = User.where(email_address: params[:email_address]).first
user_scope = Postal::Config.oidc.enabled? ? User.with_password : User
user = user_scope.find_by(email_address: params[:email_address])
if user.nil?
redirect_to login_reset_path(return_to: params[:return_to]), alert: "No local user exists with that e-mail address. Please check and try again."
return
end
user.begin_password_reset(params[:return_to])
redirect_to login_path(return_to: params[:return_to]), notice: "Please check your e-mail and click the link in the e-mail we've sent you."
else
redirect_to login_reset_path(return_to: params[:return_to]), alert: "No user exists with that e-mail address. Please check and try again."
end
end
def finish_password_reset
@@ -49,6 +54,7 @@ class SessionsController < ApplicationController
flash.now[:alert] = "You must enter a new password"
return
end
@user.password = params[:password]
@user.password_confirmation = params[:password_confirmation]
return unless @user.save
@@ -61,4 +67,33 @@ class SessionsController < ApplicationController
render plain: "ip: #{request.ip} remote ip: #{request.remote_ip}"
end
def create_from_oidc
unless Postal::Config.oidc.enabled?
raise Postal::Error, "OIDC cannot be used unless enabled in the configuration"
end
auth = request.env["omniauth.auth"]
user = User.find_from_oidc(auth.extra.raw_info, logger: Postal.logger)
if user.nil?
redirect_to login_path, alert: "No user was found matching your identity. Please contact your administrator."
return
end
login(user)
flash[:remember_login] = true
redirect_to_with_return_to root_path
end
def oauth_failure
redirect_to login_path, alert: "An issue occurred while logging you in with OpenID. Please try again later or contact your administrator."
end
private
def require_local_authentication
return if Postal::Config.oidc.local_authentication_enabled?
redirect_to login_path, alert: "Local authentication is not enabled"
end
end

عرض الملف

@@ -30,8 +30,10 @@ class UserController < ApplicationController
def update
@user = User.find(current_user.id)
@user.attributes = params.require(:user).permit(:first_name, :last_name, :time_zone, :email_address, :password, :password_confirmation)
safe_params = [:first_name, :last_name, :time_zone, :email_address]
if @user.password? && Postal::Config.oidc.local_authentication_enabled?
safe_params += [:password, :password_confirmation]
if @user.authenticate_with_previous_password_first(params[:password])
@password_correct = true
else
@@ -46,6 +48,9 @@ class UserController < ApplicationController
end
return
end
end
@user.attributes = params.require(:user).permit(safe_params)
if @user.save
redirect_to_with_json settings_path, notice: "Your settings have been updated successfully."

عرض الملف

@@ -5,15 +5,20 @@ module HasAuthentication
extend ActiveSupport::Concern
included do
has_secure_password
has_secure_password validations: false
validates :password, length: { minimum: 8, allow_blank: true }
validates :password, confirmation: { allow_blank: true }
validate :validate_password_presence
before_save :clear_password_reset_token_on_password_change
scope :with_password, -> { where.not(password_digest: nil) }
end
class_methods do
def authenticate(email_address, password)
user = where(email_address: email_address).first
user = find_by(email_address: email_address)
raise Postal::Errors::AuthenticationError, "InvalidEmailAddress" if user.nil?
raise Postal::Errors::AuthenticationError, "InvalidPassword" unless user.authenticate(password)
@@ -30,6 +35,10 @@ module HasAuthentication
end
def begin_password_reset(return_to = nil)
if Postal::Config.oidc.enabled? && (oidc_uid.present? || password_digest.blank?)
raise Postal::Error, "User has OIDC enabled, password resets are not supported"
end
self.password_reset_token = SecureRandom.alphanumeric(24)
self.password_reset_token_valid_until = 1.day.from_now
save!
@@ -45,6 +54,12 @@ module HasAuthentication
self.password_reset_token_valid_until = nil
end
def validate_password_presence
return if password_digest.present? || Postal::Config.oidc.enabled?
errors.add :password, :blank
end
end
# -*- SkipSchemaAnnotations

عرض الملف

@@ -5,19 +5,21 @@
# Table name: users
#
# id :integer not null, primary key
# uuid :string(255)
# first_name :string(255)
# last_name :string(255)
# admin :boolean default(FALSE)
# email_address :string(255)
# password_digest :string(255)
# time_zone :string(255)
# email_verification_token :string(255)
# email_verified_at :datetime
# created_at :datetime
# updated_at :datetime
# first_name :string(255)
# last_name :string(255)
# oidc_issuer :string(255)
# oidc_uid :string(255)
# password_digest :string(255)
# password_reset_token :string(255)
# password_reset_token_valid_until :datetime
# admin :boolean default(FALSE)
# time_zone :string(255)
# uuid :string(255)
# created_at :datetime
# updated_at :datetime
#
# Indexes
#
@@ -28,13 +30,11 @@
class User < ApplicationRecord
include HasUUID
include HasAuthentication
validates :first_name, presence: true
validates :last_name, presence: true
validates :email_address, presence: true, uniqueness: { case_sensitive: false }, format: { with: /@/, allow_blank: true }
validates :time_zone, presence: true
default_value :time_zone, -> { "UTC" }
@@ -53,24 +53,85 @@ class User < ApplicationRecord
"#{first_name} #{last_name}"
end
def password?
password_digest.present?
end
def oidc?
oidc_uid.present?
end
def to_param
uuid
end
def md5_for_gravatar
@md5_for_gravatar ||= Digest::MD5.hexdigest(email_address.to_s.downcase)
end
def avatar_url
@avatar_url ||= email_address ? "https://secure.gravatar.com/avatar/#{md5_for_gravatar}?rating=PG&size=120&d=mm" : nil
end
def email_tag
"#{name} <#{email_address}>"
end
def self.[](email)
where(email_address: email).first
class << self
# Lookup a user by email address
#
# @param email [String] the email address
#
# @return [User, nil] the user
def [](email)
find_by(email_address: email)
end
# Find a user based on an OIDC authentication hash
#
# @param auth [Hash] the authentication hash
# @param logger [Logger] a logger to log debug information to
#
# @return [User, nil] the user
def find_from_oidc(auth, logger: nil)
config = Postal::Config.oidc
uid = auth[config.uid_field]
oidc_name = auth[config.name_field]
oidc_email_address = auth[config.email_address_field]
logger&.debug "got auth details from issuer: #{auth.inspect}"
# look for an existing user with the same UID and OIDC issuer. If we find one,
# this is the user we'll want to use.
user = where(oidc_uid: uid, oidc_issuer: config.issuer).first
if user
logger&.debug "found user with UID #{uid} for issuer #{config.issuer} (user ID: #{user.id})"
else
logger&.debug "no user with UID #{uid} for issuer #{config.issuer}"
end
# if we don't have an existing user, we will look for users which have no OIDC
# credentials but with a matching e-mail address.
if user.nil? && oidc_email_address.present?
user = where(oidc_uid: nil, email_address: oidc_email_address).first
if user
logger&.debug "found user with e-mail address #{oidc_email_address} (user ID: #{user.id})"
else
logger&.debug "no user with e-mail address #{oidc_email_address}"
end
end
# now, if we still don't have a user, we're not going to create one so we'll just
# return nil (we might auto create users in the future but not right now)
return if user.nil?
# otherwise, let's update our user as appropriate
user.oidc_uid = uid
user.oidc_issuer = config.issuer
user.email_address = oidc_email_address if oidc_email_address.present?
user.first_name, user.last_name = oidc_name.split(/\s+/, 2) if oidc_name.present?
user.password = nil
user.save!
# return the user
user
end
end
end

عرض الملف

@@ -6,13 +6,19 @@
.subPageBox__content
= form_tag login_path, :class => 'loginForm' do
= hidden_field_tag 'return_to', params[:return_to]
- if params[:return_to] && params[:return_to] =~ /\/join\//
%p.loginForm__invite.warningBox.u-margin Login to your existing account to accept your invitation.
%p.loginForm__input= text_field_tag 'email_address', '', :type => 'email', :autocomplete => 'off', :spellcheck => 'false', :class => 'input input--text input--onWhite', :placeholder => "Your e-mail address", :autofocus => true, :tabindex => 1
- if Postal::Config.oidc.enabled?
.loginForm__oidcButton
= link_to "Login with #{Postal::Config.oidc.name}", "/auth/oidc", method: :post, class: 'button button--full'
- if Postal::Config.oidc.enabled? && Postal::Config.oidc.local_authentication_enabled?
.loginForm__divider
%p.loginForm__localTitle or login with a local user
- if Postal::Config.oidc.local_authentication_enabled?
%p.loginForm__input= text_field_tag 'email_address', '', :type => 'email', :spellcheck => 'false', :class => 'input input--text input--onWhite', :placeholder => "Your e-mail address", :autofocus => !Postal::Config.oidc.enabled?, :tabindex => 1
%p.loginForm__input= password_field_tag 'password', '', :class => 'input input--text input--onWhite', :placeholder => "Your password", :tabindex => 2
.loginForm__submit
%ul.loginForm__links
%li= link_to "Forgotten your password?", login_reset_path(:return_to => params[:return_to])
%p= submit_tag "Login", :class => 'button button--positive', :tabindex => 3

عرض الملف

@@ -6,6 +6,7 @@
= form_for @user, :url => settings_path, :remote => true do |f|
= f.error_messages
%fieldset.fieldSet
- if @user.password? && Postal::Config.oidc.local_authentication_enabled?
.fieldSet__field
= label_tag :password, 'Your Password', :class => 'fieldSet__label'
.fieldSet__input
@@ -41,6 +42,7 @@
Choose the time zone that you'd like times to be displayed to you when you use our
web interface. By default, times are displayed in UTC.
- if @user.password? && Postal::Config.oidc.local_authentication_enabled?
.fieldSet__title
Change your password?
.fieldSet__field

عرض الملف

@@ -11,11 +11,25 @@
.fieldSet__input= f.text_field :last_name, :class => 'input input--text'
.fieldSet__field
= f.label :email_address, :class => 'fieldSet__label'
.fieldSet__input= f.text_field :email_address, :class => 'input input--text', autocomplete: 'one-time-code'
- unless @user.persisted?
.fieldSet__input
= f.text_field :email_address, :class => 'input input--text', autocomplete: 'one-time-code'
- if Postal::Config.oidc.enabled?
%p.fieldSet__text
This e-mail address should match the address provided by your OpenID Connect identity provider.
- if Postal::Config.oidc.local_authentication_enabled? && !@user.persisted?
.fieldSet__field
= f.label :password, :class => 'fieldSet__label'
.fieldSet__input= f.password_field :password, :class => 'input input--text', :placeholder => '•••••••••••', autocomplete: 'one-time-code'
.fieldSet__input
= f.password_field :password, :class => 'input input--text', :placeholder => '•••••••••••', autocomplete: 'one-time-code'
- if Postal::Config.oidc.enabled?
%p.fieldSet__text
You have enabled OIDC which means a password is not required. If you do not provide
a password this user will be matched to an OIDC identity based on the e-mail address
provided above. You may, however, enter a password and this user will be permitted to
use that password until they have successfully logged in with OIDC.
.fieldSet__field
= f.label :password_confirmation, "Confirm".html_safe, :class => 'fieldSet__label'
.fieldSet__input= f.password_field :password_confirmation, :class => 'input input--text', :placeholder => '•••••••••••', autocomplete: 'one-time-code'

عرض الملف

@@ -7,15 +7,20 @@
%ul.userList.u-margin
- for user in @users
%li.userList__item
= image_tag user.avatar_url, :class => 'userList__avatar'
.userList__details
%p.userList__name
= user.name
- if user.admin?
%span.userList__admin.label Admin
%span.userList__tag.label.label--blue Admin
- if Postal::Config.oidc.enabled?
- if user.oidc?
%span.userList__tag.label.label--green OIDC
- elsif !Postal::Config.oidc.local_authentication_enabled?
%span.userList__tag.label.label--orange Pending
%p.userList__email= user.email_address
%ul.userList__actions
%li= link_to "Edit permissions", [:edit, user]
%li= link_to "Edit user", [:edit, user]
%li= link_to "Delete user", user, :method => :delete, :data => {:confirm => "Are you sure you wish to revoke #{user.name}'s access?", :disable_with => "Deleting..."}, :remote => true, :class => 'userList__revoke'
%p.u-center= link_to "Add a new user", :new_user, :class => 'button button--positive'

عرض الملف

@@ -12,7 +12,9 @@ require "sprockets/railtie"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
gem_groups = Rails.groups
gem_groups << :oidc if Postal::Config.oidc.enabled?
Bundler.require(*gem_groups)
module Postal
class Application < Rails::Application

عرض الملف

@@ -16,6 +16,7 @@
ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym "DKIM"
inflect.acronym "HTTP"
inflect.acronym "OIDC"
inflect.acronym "SMTP"
inflect.acronym "UUID"

عرض الملف

@@ -0,0 +1,28 @@
# frozen_string_literal: true
config = Postal::Config.oidc
if config.enabled?
client_options = { identifier: config.identifier, secret: config.secret }
client_options[:redirect_uri] = "#{Postal::Config.postal.web_protocol}://#{Postal::Config.postal.web_hostname}/auth/oidc/callback"
unless config.discovery?
client_options[:authorization_endpoint] = config.authorization_endpoint
client_options[:token_endpoint] = config.token_endpoint
client_options[:userinfo_endpoint] = config.userinfo_endpoint
client_options[:jwks_uri] = config.jwks_uri
end
Rails.application.config.middleware.use OmniAuth::Builder do
provider :openid_connect, name: :oidc,
scope: config.scopes.map(&:to_sym),
uid_field: config.uid_field,
issuer: config.issuer,
discovery: config.discovery?,
client_options: client_options
end
OmniAuth.config.on_failure = proc do |env|
SessionsController.action(:oauth_failure).call(env)
end
end

عرض الملف

@@ -85,6 +85,10 @@ Rails.application.routes.draw do
match "login/reset" => "sessions#begin_password_reset", :via => [:get, :post]
match "login/reset/:token" => "sessions#finish_password_reset", :via => [:get, :post]
if Postal::Config.oidc.enabled?
get "auth/oidc/callback", to: "sessions#create_from_oidc"
end
get "ip" => "sessions#ip"
root "organizations#index"

عرض الملف

@@ -0,0 +1,10 @@
# frozen_string_literal: true
class AddOIDCFieldsToUser < ActiveRecord::Migration[7.0]
def change
add_column :users, :oidc_uid, :string
add_column :users, :oidc_issuer, :string
end
end

عرض الملف

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2024_02_23_141501) do
ActiveRecord::Schema[7.0].define(version: 2024_03_11_205229) do
create_table "additional_route_endpoints", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.integer "route_id"
t.string "endpoint_type"
@@ -330,6 +330,8 @@ ActiveRecord::Schema[7.0].define(version: 2024_02_23_141501) do
t.string "password_reset_token"
t.datetime "password_reset_token_valid_until", precision: nil
t.boolean "admin", default: false
t.string "oidc_uid"
t.string "oidc_issuer"
t.index ["email_address"], name: "index_users_on_email_address", length: 8
t.index ["uuid"], name: "index_users_on_uuid", length: 8
end

عرض الملف

@@ -97,3 +97,17 @@ This document contains all the environment variables which are available for thi
| `MIGRATION_WAITER_ENABLED` | Boolean | Wait for all migrations to run before starting a process | false |
| `MIGRATION_WAITER_ATTEMPTS` | Integer | The number of attempts to try waiting for migrations to complete before start | 120 |
| `MIGRATION_WAITER_SLEEP_TIME` | Integer | The number of seconds to wait between each migration check | 2 |
| `OIDC_ENABLED` | Boolean | Enable OIDC authentication | false |
| `OIDC_NAME` | String | The name of the OIDC provider as shown in the UI | OIDC Provider |
| `OIDC_ISSUER` | String | The OIDC issuer URL | |
| `OIDC_IDENTIFIER` | String | The client ID for OIDC | |
| `OIDC_SECRET` | String | The client secret for OIDC | |
| `OIDC_SCOPES` | Array of strings | Scopes to request from the OIDC server. | openid |
| `OIDC_UID_FIELD` | String | The field to use to determine the user's UID | sub |
| `OIDC_EMAIL_ADDRESS_FIELD` | String | The field to use to determine the user's email address | sub |
| `OIDC_NAME_FIELD` | String | The field to use to determine the user's name | name |
| `OIDC_DISCOVERY` | Boolean | Enable discovery to determine endpoints from .well-known/openid-configuration from the Issuer | true |
| `OIDC_AUTHORIZATION_ENDPOINT` | String | The authorize endpoint on the authorization server (only used when discovery is false) | |
| `OIDC_TOKEN_ENDPOINT` | String | The token endpoint on the authorization server (only used when discovery is false) | |
| `OIDC_USERINFO_ENDPOINT` | String | The user info endpoint on the authorization server (only used when discovery is false) | |
| `OIDC_JWKS_URI` | String | The JWKS endpoint on the authorization server (only used when discovery is false) | |

عرض الملف

@@ -219,3 +219,34 @@ migration_waiter:
attempts: 120
# The number of seconds to wait between each migration check
sleep_time: 2
oidc:
# Enable OIDC authentication
enabled: false
# The name of the OIDC provider as shown in the UI
name: OIDC Provider
# The OIDC issuer URL
issuer:
# The client ID for OIDC
identifier:
# The client secret for OIDC
secret:
# Scopes to request from the OIDC server.
scopes:
- openid
# The field to use to determine the user's UID
uid_field: sub
# The field to use to determine the user's email address
email_address_field: sub
# The field to use to determine the user's name
name_field: name
# Enable discovery to determine endpoints from .well-known/openid-configuration from the Issuer
discovery: true
# The authorize endpoint on the authorization server (only used when discovery is false)
authorization_endpoint:
# The token endpoint on the authorization server (only used when discovery is false)
token_endpoint:
# The user info endpoint on the authorization server (only used when discovery is false)
userinfo_endpoint:
# The JWKS endpoint on the authorization server (only used when discovery is false)
jwks_uri:

عرض الملف

@@ -508,6 +508,77 @@ module Postal
default 2
end
end
group :oidc do
boolean :enabled do
description "Enable OIDC authentication"
default false
end
boolean :local_authentication_enabled do
description "When enabled, users with passwords will still be able to login locally. If disable, only OpenID Connect will be available."
default true
end
string :name do
description "The name of the OIDC provider as shown in the UI"
default "OIDC Provider"
end
string :issuer do
description "The OIDC issuer URL"
end
string :identifier do
description "The client ID for OIDC"
end
string :secret do
description "The client secret for OIDC"
end
string :scopes do
description "Scopes to request from the OIDC server."
array
default "openid,email"
end
string :uid_field do
description "The field to use to determine the user's UID"
default "sub"
end
string :email_address_field do
description "The field to use to determine the user's email address"
default "email"
end
string :name_field do
description "The field to use to determine the user's name"
default "name"
end
boolean :discovery do
description "Enable discovery to determine endpoints from .well-known/openid-configuration from the Issuer"
default true
end
string :authorization_endpoint do
description "The authorize endpoint on the authorization server (only used when discovery is false)"
end
string :token_endpoint do
description "The token endpoint on the authorization server (only used when discovery is false)"
end
string :userinfo_endpoint do
description "The user info endpoint on the authorization server (only used when discovery is false)"
end
string :jwks_uri do
description "The JWKS endpoint on the authorization server (only used when discovery is false)"
end
end
end
class << self

عرض الملف

@@ -19,7 +19,7 @@ module Postal
contents << " #{name}: []"
else
contents << " #{name}:"
attr.default.each do |d|
attr.transform(attr.default).each do |d|
contents << " - #{d}"
end
end

عرض الملف

@@ -5,19 +5,21 @@
# Table name: users
#
# id :integer not null, primary key
# uuid :string(255)
# first_name :string(255)
# last_name :string(255)
# admin :boolean default(FALSE)
# email_address :string(255)
# password_digest :string(255)
# time_zone :string(255)
# email_verification_token :string(255)
# email_verified_at :datetime
# created_at :datetime
# updated_at :datetime
# first_name :string(255)
# last_name :string(255)
# oidc_issuer :string(255)
# oidc_uid :string(255)
# password_digest :string(255)
# password_reset_token :string(255)
# password_reset_token_valid_until :datetime
# admin :boolean default(FALSE)
# time_zone :string(255)
# uuid :string(255)
# created_at :datetime
# updated_at :datetime
#
# Indexes
#

عرض الملف

@@ -0,0 +1,27 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe User do
describe ".authenticate" do
it "does not authenticate users with invalid emails" do
expect { User.authenticate("nothing@nothing.com", "hello") }.to raise_error(Postal::Errors::AuthenticationError) do |e|
expect(e.error).to eq "InvalidEmailAddress"
end
end
it "does not authenticate users with invalid passwords" do
user = create(:user)
expect { User.authenticate(user.email_address, "hello") }.to raise_error(Postal::Errors::AuthenticationError) do |e|
expect(e.error).to eq "InvalidPassword"
end
end
it "authenticates valid users" do
user = create(:user)
auth_user = nil
expect { auth_user = User.authenticate(user.email_address, "passw0rd") }.to_not raise_error
expect(auth_user).to eq user
end
end
end

عرض الملف

@@ -0,0 +1,115 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe User do
let(:user) { build(:user) }
describe "#oidc?" do
it "returns true if the user has an OIDC UID" do
user.oidc_uid = "123"
expect(user.oidc?).to be true
end
it "returns false if the user does not have an OIDC UID" do
user.oidc_uid = nil
expect(user.oidc?).to be false
end
end
describe ".find_from_oidc" do
let(:issuer) { "https://identity.example.com" }
before do
allow(Postal::Config.oidc).to receive(:enabled?).and_return(true)
allow(Postal::Config.oidc).to receive(:issuer).and_return(issuer)
allow(Postal::Config.oidc).to receive(:email_address_field).and_return("email")
end
let(:uid) { "abcdef" }
let(:oidc_name) { "John Smith" }
let(:oidc_email) { "test@example.com" }
let(:auth) { { "sub" => uid, "email" => oidc_email, "name" => oidc_name } }
let(:logger) { TestLogger.new }
subject(:result) { described_class.find_from_oidc(auth, logger: logger) }
context "when there is a user that matchers the UID and issuer" do
before do
@existing_user = create(:user, oidc_uid: uid, oidc_issuer: issuer, first_name: "mary",
last_name: "apples", email_address: "mary@apples.com")
end
it "returns that user" do
expect(result).to eq @existing_user
end
it "updates the name and email address" do
result
@existing_user.reload
expect(@existing_user.first_name).to eq "John"
expect(@existing_user.last_name).to eq "Smith"
expect(@existing_user.email_address).to eq "test@example.com"
end
it "logs" do
result
expect(logger).to have_logged(/found user with UID abcdef/i)
end
end
context "when there is no user which matches the UID and issuer" do
context "when there is a user which matches the email address without an OIDC UID" do
before do
@existing_user = create(:user, first_name: "mary",
last_name: "apples", email_address: "test@example.com")
end
it "returns that user" do
expect(result).to eq @existing_user
end
it "adds the UID and issuer to the user" do
result
@existing_user.reload
expect(@existing_user.oidc_uid).to eq uid
expect(@existing_user.oidc_issuer).to eq issuer
end
it "updates the name if changed" do
result
@existing_user.reload
expect(@existing_user.first_name).to eq "John"
expect(@existing_user.last_name).to eq "Smith"
end
it "removes the password" do
@existing_user.password = "password"
@existing_user.save!
result
@existing_user.reload
expect(@existing_user.password_digest).to be_nil
end
it "logs" do
result
expect(logger).to have_logged(/no user with UID abcdef/)
expect(logger).to have_logged(/found user with e-mail address test@example.com/)
end
end
context "when there is no user which matches the email address" do
it "returns nil" do
expect(result).to be_nil
end
it "logs" do
result
expect(logger).to have_logged(/no user with UID abcdef/)
expect(logger).to have_logged(/no user with e-mail address/)
end
end
end
end
end

عرض الملف

@@ -11,6 +11,8 @@
# email_verified_at :datetime
# first_name :string(255)
# last_name :string(255)
# oidc_issuer :string(255)
# oidc_uid :string(255)
# password_digest :string(255)
# password_reset_token :string(255)
# password_reset_token_valid_until :datetime
@@ -27,34 +29,105 @@
require "rails_helper"
describe User do
context "model" do
subject(:user) { create(:user) }
subject(:user) { build(:user) }
describe "validations" do
it { is_expected.to validate_presence_of(:first_name) }
it { is_expected.to validate_presence_of(:last_name) }
it { is_expected.to validate_presence_of(:email_address) }
it { is_expected.to validate_presence_of(:password) }
it { is_expected.to validate_uniqueness_of(:email_address).case_insensitive }
it { is_expected.to allow_value("test@example.com").for(:email_address) }
it { is_expected.to allow_value("test@example.co.uk").for(:email_address) }
it { is_expected.to allow_value("test+tagged@example.co.uk").for(:email_address) }
it { is_expected.to allow_value("test+tagged@EXAMPLE.COM").for(:email_address) }
it { is_expected.to_not allow_value("test+tagged").for(:email_address) }
it { is_expected.to_not allow_value("test.com").for(:email_address) }
it "does not require a password when OIDC is enabled" do
allow(Postal::Config.oidc).to receive(:enabled?).and_return(true)
user.password = nil
expect(user.save).to be true
end
end
describe "relationships" do
it { is_expected.to have_many(:organization_users) }
it { is_expected.to have_many(:organizations) }
end
describe "creation" do
before { user.save }
it "should have a UUID" do
expect(user.uuid).to be_a String
expect(user.uuid.length).to eq 36
end
end
context ".authenticate" do
it "should not authenticate users with invalid emails" do
expect { User.authenticate("nothing@nothing.com", "hello") }.to raise_error(Postal::Errors::AuthenticationError) do |e|
expect(e.error).to eq "InvalidEmailAddress"
it "has a default timezone" do
expect(user.time_zone).to eq "UTC"
end
end
it "should not authenticate users with invalid passwords" do
describe "#organizations_scope" do
context "when the user is an admin" do
it "returns a scope of all organizations" do
user.admin = true
scope = user.organizations_scope
expect(scope).to eq Organization.present
end
end
context "when the user not an admin" do
it "returns a scope including only orgs the user is associated with" do
user.admin = false
user.organizations << create(:organization)
scope = user.organizations_scope
expect(scope).to eq user.organizations.present
end
end
end
describe "#name" do
it "returns the name" do
user.first_name = "John"
user.last_name = "Doe"
expect(user.name).to eq "John Doe"
end
end
describe "#password?" do
it "returns true if the user has a password" do
user.password = "password"
expect(user.password?).to be true
end
it "returns false if the user does not have a password" do
user.password = nil
expect(user.password?).to be false
end
end
describe "#to_param" do
it "returns the UUID" do
user.uuid = "123"
expect(user.to_param).to eq "123"
end
end
describe "#email_tag" do
it "returns the name and email address" do
user.first_name = "John"
user.last_name = "Doe"
user.email_address = "john@example.com"
expect(user.email_tag).to eq "John Doe <john@example.com>"
end
end
describe ".[]" do
it "should find a user by email address" do
user = create(:user)
expect { User.authenticate(user.email_address, "hello") }.to raise_error(Postal::Errors::AuthenticationError) do |e|
expect(e.error).to eq "InvalidPassword"
end
end
it "should authenticate valid users" do
user = create(:user)
auth_user = nil
expect { auth_user = User.authenticate(user.email_address, "passw0rd") }.to_not raise_error
expect(auth_user).to eq user
expect(User[user.email_address]).to eq user
end
end
end