diff --git a/Gemfile b/Gemfile index c3efa379..231f2001 100644 --- a/Gemfile +++ b/Gemfile @@ -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" diff --git a/Gemfile.lock b/Gemfile.lock index c6b997b3..0f4fe96b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/app/assets/stylesheets/application/components/_login_form.scss b/app/assets/stylesheets/application/components/_login_form.scss index 030b0567..16de08a6 100644 --- a/app/assets/stylesheets/application/components/_login_form.scss +++ b/app/assets/stylesheets/application/components/_login_form.scss @@ -1,22 +1,32 @@ -.loginForm { - -} +.loginForm {} .loginForm__input { - margin-bottom:15px; + margin-bottom: 15px; } .loginForm__submit { - display:flex; - justify-content:space-between; - align-items:center; + display: flex; + justify-content: space-between; + align-items: center; } .loginForm__links { - font-size:12px; - color:#999; + font-size: 12px; + color: #999; text-decoration: underline; - line-height:1.7; + line-height: 1.7; +} + +.loginForm__oidcButton { + margin-bottom: 15px; + padding-bottom: 25px; + border-bottom: 1px solid #e4e8ef; +} + +.loginForm__localTitle { + text-align: center; + margin-bottom: 15px; + color: #999; } diff --git a/app/assets/stylesheets/application/components/_user_list.scss b/app/assets/stylesheets/application/components/_user_list.scss index f6e315ba..c3eeabf6 100644 --- a/app/assets/stylesheets/application/components/_user_list.scss +++ b/app/assets/stylesheets/application/components/_user_list.scss @@ -1,74 +1,67 @@ .userList { - border-radius:4px; - color:$darkBlue; - overflow:hidden; - box-shadow:0 0 10px rgba(0,0,0,0.2); + border-radius: 4px; + color: $darkBlue; + overflow: hidden; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); } .userList__item { - display:block; - background:#fff; - padding:15px; - display:flex; + display: block; + background: #fff; + padding: 15px; + display: flex; align-items: center; } + .userList__item:nth-child(even) { - background:none; + background: none; } -.userList__item + .userList__item { - 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__item+.userList__item { + border-top: 1px solid lighten(#ccd4e0, 10%); } .userList__details { flex: 1 1 auto; - margin:0 25px; + margin: 0 0; } .userList__actions { flex: 0 0 auto; - width:180px; - font-size:12px; - line-height:1.5; - color:#999; - text-decoration: underline; + width: 120px; + font-size: 12px; + line-height: 1.5; + color: #999; + + a { + text-decoration: underline; + } } .userList__name { - font-weight:600; - font-size:16px; - margin-bottom:3px; + font-weight: 600; + font-size: 16px; + margin-bottom: 3px; } .userList__owner { - vertical-align:2px; - margin-left:5px; - background-color:$orange; + vertical-align: 2px; + margin-left: 5px; + background-color: $orange; } .userList__pending { - vertical-align:2px; - margin-left:5px; - background-color:#ccc; + vertical-align: 2px; + margin-left: 5px; + background-color: #ccc; } -.userList__admin { - vertical-align:2px; - margin-left:5px; - background-color:$blue; +.userList__tag { + vertical-align: 2px; + margin-left: 3px; } .userList__revoke { - color:$red; + color: $red; } diff --git a/app/assets/stylesheets/application/elements/_button.scss b/app/assets/stylesheets/application/elements/_button.scss index b66116a1..0ea514d6 100644 --- a/app/assets/stylesheets/application/elements/_button.scss +++ b/app/assets/stylesheets/application/elements/_button.scss @@ -1,98 +1,117 @@ .button { - display:inline-block; - font:inherit; - border-radius:4px; - appearance:none; - background:$blue; - color:#fff; - font-size:14px !important; - margin:0; - vertical-align:top; - padding:6px 15px; - border:2px solid transparent; - border-bottom:2px solid darken($blue, 20%); + display: inline-block; + font: inherit; + border-radius: 4px; + appearance: none; + background: $blue; + color: #fff; + font-size: 14px !important; + margin: 0; + vertical-align: top; + padding: 6px 15px; + border: 2px solid transparent; + border-bottom: 2px solid darken($blue, 20%); + &:active { - background-color:darken($blue, 15%); + background-color: darken($blue, 15%); } + &:focus { - border-color:darken($blue, 15%); - background-color:lighten($blue, 5%); + border-color: darken($blue, 15%); + background-color: lighten($blue, 5%); } + &.is-spinning { - color:transparent; - background-repeat:no-repeat; + color: transparent; + background-repeat: no-repeat; background-position: center center; - background-size:25px; - background-image:image-url('button-spinner.gif'); + background-size: 25px; + background-image: image-url('button-spinner.gif'); } } .button--small { - font-size:12px !important; - padding:3px 10px; - border-width:1px; + font-size: 12px !important; + padding: 3px 10px; + border-width: 1px; } .button--positive { - background-color:$green; - border-bottom-color:darken($green, 15%); + background-color: $green; + border-bottom-color: darken($green, 15%); + &:active { - background-color:darken($green, 15%); + background-color: darken($green, 15%); } + &:focus { - border-color:darken($green, 15%); - background-color:lighten($green, 5%); + border-color: darken($green, 15%); + background-color: lighten($green, 5%); } + &.is-spinning { - background-image:image-url('button-spinner-positive.gif'); + background-image: image-url('button-spinner-positive.gif'); } } .button--neutral { - background-color:#ccc; - border-bottom-color:darken(#ccc, 15%); + background-color: #ccc; + border-bottom-color: darken(#ccc, 15%); + &:active { - background-color:darken(#ccc, 15%); + background-color: darken(#ccc, 15%); } + &:focus { - border-color:darken(#ccc, 15%); - background-color:lighten(#ccc, 5%); + border-color: darken(#ccc, 15%); + background-color: lighten(#ccc, 5%); } + &.is-spinning { - background-image:image-url('button-spinner-neutral.gif'); + background-image: image-url('button-spinner-neutral.gif'); } } .button--danger { - background-color:$red; - border-bottom-color:darken($red, 15%); + background-color: $red; + border-bottom-color: darken($red, 15%); + &:active { - background-color:darken($red, 15%); + background-color: darken($red, 15%); } + &:focus { - border-color:darken($red, 15%); - background-color:lighten($red, 5%); + border-color: darken($red, 15%); + background-color: lighten($red, 5%); } + &.is-spinning { - background-image:image-url('button-spinner-danger.gif'); + background-image: image-url('button-spinner-danger.gif'); } } .button--dark { - background-color:$darkBlue; - border-bottom-color:darken($darkBlue, 15%); + background-color: $darkBlue; + border-bottom-color: darken($darkBlue, 15%); + &:active { - background-color:darken($darkBlue, 15%); + background-color: darken($darkBlue, 15%); } + &:focus { - border-color:darken($darkBlue, 15%); - background-color:lighten($darkBlue, 5%); + border-color: darken($darkBlue, 15%); + background-color: lighten($darkBlue, 5%); } + &.is-spinning { - background-image:image-url('button-spinner-dark.gif'); + background-image: image-url('button-spinner-dark.gif'); } } +.button--full { + width: 100%; + text-align: center; +} diff --git a/app/assets/stylesheets/application/elements/_label.scss b/app/assets/stylesheets/application/elements/_label.scss index e8752633..b769c746 100644 --- a/app/assets/stylesheets/application/elements/_label.scss +++ b/app/assets/stylesheets/application/elements/_label.scss @@ -1,136 +1,137 @@ .label { - display:inline-block; - background:#000; - color:#fff; - font-size:9px; + display: inline-block; + background: #000; + color: #fff; + font-size: 9px; text-transform: uppercase; - border-radius:40px; - padding:2px 6px; - line-height:0.9; + border-radius: 40px; + padding: 2px 6px; + line-height: 0.9; } .label--green { - background-color:$green; + background-color: $green; } .label--red { - background-color:$red; + background-color: $red; } .label--orange { - background-color:$orange; + background-color: $orange; } .label--blue { - background-color:$blue; + background-color: $blue; } .label--grey { - background-color:#999; + background-color: #999; } .label--turquoise { - background-color:$blue; + background-color: $blue; } .label--purple { - background-color:$purple; + background-color: $purple; } .label--large { - font-size:11px; - padding:4px 10px; + font-size: 11px; + padding: 4px 10px; } .label--serverStatus-live { - background-color:$green; + background-color: $green; } .label--serverStatus-development { - background-color:#636363; + background-color: #636363; } .label--serverStatus-suspended { - background-color:$red; + background-color: $red; } .label--messageStatus-pending { - background-color:$subBlue; + background-color: $subBlue; } .label--messageStatus-held { - background-color:#aaa; + background-color: #aaa; } .label--messageStatus-processed { - background-color:$green; + background-color: $green; } .label--messageStatus-sent { - background-color:$green; + background-color: $green; } .label--messageStatus-hard_fail { - background-color:$red; + background-color: $red; } .label--messageStatus-soft_fail { - background-color:$orange; + background-color: $orange; } .label--messageStatus-bounced { - background-color:$red; + background-color: $red; } .label--messageStatus-hold_cancelled { - background-color:#ccc; + background-color: #ccc; } .label--credentialType-api { - background-color:$blue; + background-color: $blue; } .label--credentialType-smtp { - background-color:$turquoise; + background-color: $turquoise; } .label--credentialType-smtp_ip { - background-color:$orange; + background-color: $orange; } .label--spamStatus-not_checked { - background:#aaa; + background: #aaa; } .label--spamStatus-spam { - background:$orange; + background: $orange; } .label--spamStatus-not_spam { - background:$turquoise; + background: $turquoise; } .label--http-status-2 { - background-color:$green; + background-color: $green; } .label--http-status-3 { - background-color:$orange; + background-color: $orange; } .label--http-status-4, .label--http-status-5 { - background-color:$red; + background-color: $red; } .domainList__ssl { - color:$green; + color: $green; + &:hover { - text-decoration:underline; + text-decoration: underline; } } .domainList__ssl--disabled { - color:#999; + color: #999; } diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 99a80f21..19026a05 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -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, :oauth_failure] def create login(User.authenticate(params[:email_address], params[:password])) @@ -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 @@ -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 @@ -61,4 +66,25 @@ def ip 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 + end diff --git a/app/controllers/user_controller.rb b/app/controllers/user_controller.rb index a9af998a..ff6cd5ae 100644 --- a/app/controllers/user_controller.rb +++ b/app/controllers/user_controller.rb @@ -30,23 +30,28 @@ def create 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) - - if @user.authenticate_with_previous_password_first(params[:password]) - @password_correct = true - else - respond_to do |wants| - wants.html do - flash.now[:alert] = "The current password you have entered is incorrect. Please check and try again." - render "edit" - end - wants.json do - render json: { alert: "The current password you've entered is incorrect. Please check and try again" } + safe_params = [:first_name, :last_name, :time_zone, :email_address] + + if @user.password? + safe_params += [:password, :password_confirmation] + if @user.authenticate_with_previous_password_first(params[:password]) + @password_correct = true + else + respond_to do |wants| + wants.html do + flash.now[:alert] = "The current password you have entered is incorrect. Please check and try again." + render "edit" + end + wants.json do + render json: { alert: "The current password you've entered is incorrect. Please check and try again" } + end end + return end - return 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." else diff --git a/app/models/concerns/has_authentication.rb b/app/models/concerns/has_authentication.rb index 17c06d2c..c19bc7b1 100644 --- a/app/models/concerns/has_authentication.rb +++ b/app/models/concerns/has_authentication.rb @@ -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 @@ 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! @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index b2831f3c..b22926ea 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 # @@ -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,83 @@ def name "#{first_name} #{last_name}" end - def to_param - uuid + def password? + password_digest.present? end - def md5_for_gravatar - @md5_for_gravatar ||= Digest::MD5.hexdigest(email_address.to_s.downcase) + def oidc? + oidc_uid.present? end - def avatar_url - @avatar_url ||= email_address ? "https://secure.gravatar.com/avatar/#{md5_for_gravatar}?rating=PG&size=120&d=mm" : nil + def to_param + uuid 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] + + # 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 diff --git a/app/views/sessions/new.html.haml b/app/views/sessions/new.html.haml index bab54527..f4410745 100644 --- a/app/views/sessions/new.html.haml +++ b/app/views/sessions/new.html.haml @@ -6,13 +6,15 @@ .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' + + %p.loginForm__localTitle or login with a local user + %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 - diff --git a/app/views/user/edit.html.haml b/app/views/user/edit.html.haml index 53d2fa24..48e136be 100644 --- a/app/views/user/edit.html.haml +++ b/app/views/user/edit.html.haml @@ -6,15 +6,16 @@ = form_for @user, :url => settings_path, :remote => true do |f| = f.error_messages %fieldset.fieldSet - .fieldSet__field - = label_tag :password, 'Your Password', :class => 'fieldSet__label' - .fieldSet__input - = password_field_tag :password, params[:password], :autofocus => @password_correct.nil?, :disabled => @password_correct, :class => 'input input--text', :placeholder => "Enter your current password to change your details" - - if @password_correct - = hidden_field_tag :password, params[:password] - %p.fieldSet__text - In order to protect your account, you need to enter your current password in the field above - to authenticate the change of your details. + - if @user.password? + .fieldSet__field + = label_tag :password, 'Your Password', :class => 'fieldSet__label' + .fieldSet__input + = password_field_tag :password, params[:password], :autofocus => @password_correct.nil?, :disabled => @password_correct, :class => 'input input--text', :placeholder => "Enter your current password to change your details" + - if @password_correct + = hidden_field_tag :password, params[:password] + %p.fieldSet__text + In order to protect your account, you need to enter your current password in the field above + to authenticate the change of your details. .fieldSet__title Your details @@ -41,14 +42,15 @@ 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. - .fieldSet__title - Change your password? - .fieldSet__field - = f.label :password, "New Password", :class => 'fieldSet__label' - .fieldSet__input - .inputPair - = f.password_field :password, :class => 'input input--text', :placeholder => "•••••••••••", :value => @user.password - = f.password_field :password_confirmation, :class => 'input input--text', :placeholder => "and confirm it", :value => @user.password_confirmation + - if @user.password? + .fieldSet__title + Change your password? + .fieldSet__field + = f.label :password, "New Password", :class => 'fieldSet__label' + .fieldSet__input + .inputPair + = f.password_field :password, :class => 'input input--text', :placeholder => "•••••••••••", :value => @user.password + = f.password_field :password_confirmation, :class => 'input input--text', :placeholder => "and confirm it", :value => @user.password_confirmation %p.fieldSetSubmit.buttonSet diff --git a/app/views/users/_form.html.haml b/app/views/users/_form.html.haml index 0011fb14..f7e89ef1 100644 --- a/app/views/users/_form.html.haml +++ b/app/views/users/_form.html.haml @@ -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' diff --git a/app/views/users/index.html.haml b/app/views/users/index.html.haml index 73c28f32..6d31da9c 100644 --- a/app/views/users/index.html.haml +++ b/app/views/users/index.html.haml @@ -7,15 +7,16 @@ %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? && user.oidc? + %span.userList__tag.label.label--green OIDC %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' diff --git a/config/application.rb b/config/application.rb index a52bee33..ad17d2f8 100644 --- a/config/application.rb +++ b/config/application.rb @@ -12,7 +12,9 @@ # 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 diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 3a68b273..c4efa423 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -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" diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb new file mode 100644 index 00000000..d3c59809 --- /dev/null +++ b/config/initializers/omniauth.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +config = Postal::Config.oidc +if config.enabled? + client_options = { identifier: config.identifier, secret: config.secret } + + 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 + client_options[:scheme] = config.scheme + client_options[:host] = config.host + client_options[:port] = config.port + 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 diff --git a/config/routes.rb b/config/routes.rb index 51026e12..281b0d5a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -85,6 +85,10 @@ 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" diff --git a/db/migrate/20240311205229_add_oidc_fields_to_user.rb b/db/migrate/20240311205229_add_oidc_fields_to_user.rb new file mode 100644 index 00000000..a617222d --- /dev/null +++ b/db/migrate/20240311205229_add_oidc_fields_to_user.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 01d7ef65..9ab08fdb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 @@ 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 diff --git a/doc/config/environment-variables.md b/doc/config/environment-variables.md index 37b9a4e0..2d7dab37 100644 --- a/doc/config/environment-variables.md +++ b/doc/config/environment-variables.md @@ -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) | | diff --git a/doc/config/yaml.yml b/doc/config/yaml.yml index b9d91d78..9ded5f9d 100644 --- a/doc/config/yaml.yml +++ b/doc/config/yaml.yml @@ -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: diff --git a/lib/postal/config_schema.rb b/lib/postal/config_schema.rb index 8e997627..9e2f750f 100644 --- a/lib/postal/config_schema.rb +++ b/lib/postal/config_schema.rb @@ -508,6 +508,72 @@ module Postal default 2 end end + + group :oidc do + boolean :enabled do + description "Enable OIDC authentication" + default false + 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" + 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 "sub" + 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 diff --git a/lib/postal/yaml_config_exporter.rb b/lib/postal/yaml_config_exporter.rb index f7c2ab94..d85cfb71 100644 --- a/lib/postal/yaml_config_exporter.rb +++ b/lib/postal/yaml_config_exporter.rb @@ -19,7 +19,7 @@ def export contents << " #{name}: []" else contents << " #{name}:" - attr.default.each do |d| + attr.transform(attr.default).each do |d| contents << " - #{d}" end end diff --git a/spec/factories/user_factory.rb b/spec/factories/user_factory.rb index 03373491..5ece8cf3 100644 --- a/spec/factories/user_factory.rb +++ b/spec/factories/user_factory.rb @@ -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 # diff --git a/spec/models/user/authentication_spec.rb b/spec/models/user/authentication_spec.rb new file mode 100644 index 00000000..f8dca533 --- /dev/null +++ b/spec/models/user/authentication_spec.rb @@ -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 diff --git a/spec/models/user/oidc_spec.rb b/spec/models/user/oidc_spec.rb new file mode 100644 index 00000000..a03668cb --- /dev/null +++ b/spec/models/user/oidc_spec.rb @@ -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 diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 023ef71d..efbca329 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -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 + + it "has a default timezone" do + expect(user.time_zone).to eq "UTC" + 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" + 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 - it "should 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" + 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 " + end + end - it "should authenticate valid users" do + describe ".[]" do + it "should find a user by email address" 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