Skip to content

Commit

Permalink
wip: oidc
Browse files Browse the repository at this point in the history
  • Loading branch information
adamcooke committed Mar 12, 2024
1 parent 4e13577 commit 7761b75
Show file tree
Hide file tree
Showing 19 changed files with 321 additions and 27 deletions.
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ gem "mysql2"
gem "nifty-utils"
gem "nilify_blanks"
gem "nio4r"
gem "omniauth_openid_connect"
gem "omniauth-rails_csrf_protection"
gem "prometheus-client"
gem "puma"
gem "rails", "= 7.0.8.1"
Expand Down
69 changes: 69 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions OIDC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
- Catch errors from the OIDC callback to display properly
- Show OIDC enabled users in the user list
- Disable callback when OIDC is not enabled
- Support for non-discovery mode
- Don't require a password to change user details when the user has no password
- Don't allow the user to set a password locally if they don't have one already
- Tests for the user model
- Tests for the sessions controller
29 changes: 23 additions & 6 deletions app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ class SessionsController < ApplicationController

layout "sub"

skip_before_action :login_required, only: [:new, :create, :begin_password_reset, :finish_password_reset, :ip, :raise_error]
skip_before_action :login_required, only: [:new, :create, :begin_password_reset, :finish_password_reset, :ip, :raise_error, :create_from_oidc]

def create
login(User.authenticate(params[:email_address], params[:password]))
Expand All @@ -29,12 +29,16 @@ def persist
def begin_password_reset
return unless request.post?

if user = User.where(email_address: params[:email_address]).first
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."
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."
end

def finish_password_reset
Expand All @@ -49,6 +53,7 @@ def finish_password_reset
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
Expand All @@ -61,4 +66,16 @@ def ip
render plain: "ip: #{request.ip} remote ip: #{request.remote_ip}"
end

def create_from_oidc
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)
redirect_to_with_return_to root_path
end

end
19 changes: 17 additions & 2 deletions app/models/concerns/has_authentication.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -30,6 +35,10 @@ def authenticate_with_previous_password_first(unencrypted_password)
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!
Expand All @@ -45,6 +54,12 @@ def clear_password_reset_token_on_password_change
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
79 changes: 70 additions & 9 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,21 @@
# Table name: users
#
# id :integer not null, primary key
# uuid :string(255)
# admin :boolean default(FALSE)
# email_address :string(255)
# email_verification_token :string(255)
# email_verified_at :datetime
# first_name :string(255)
# last_name :string(255)
# email_address :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
# time_zone :string(255)
# email_verification_token :string(255)
# email_verified_at :datetime
# uuid :string(255)
# created_at :datetime
# updated_at :datetime
# password_reset_token :string(255)
# password_reset_token_valid_until :datetime
# admin :boolean default(FALSE)
#
# Indexes
#
Expand Down Expand Up @@ -69,8 +71,67 @@ 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]

# 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
2 changes: 2 additions & 0 deletions app/views/sessions/new.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@
%li= link_to "Forgotten your password?", login_reset_path(:return_to => params[:return_to])
%p= submit_tag "Login", :class => 'button button--positive', :tabindex => 3

- if Postal::Config.oidc.enabled?
= link_to "Login with #{Postal::Config.oidc.name}", "/auth/oidc", method: :post
10 changes: 9 additions & 1 deletion app/views/users/_form.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,15 @@
- unless @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'
Expand Down
1 change: 1 addition & 0 deletions config/initializers/inflections.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Loading

0 comments on commit 7761b75

Please sign in to comment.