مراية لـ
https://github.com/postalserver/postal.git
تم المزامنة 2025-12-01 05:43:04 +00:00
feat: openid connect support (#2873)
هذا الالتزام موجود في:
7
Gemfile
7
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"
|
||||
|
||||
69
Gemfile.lock
69
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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
28
config/initializers/omniauth.rb
Normal file
28
config/initializers/omniauth.rb
Normal file
@@ -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"
|
||||
|
||||
10
db/migrate/20240311205229_add_oidc_fields_to_user.rb
Normal file
10
db/migrate/20240311205229_add_oidc_fields_to_user.rb
Normal file
@@ -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
|
||||
#
|
||||
|
||||
27
spec/models/user/authentication_spec.rb
Normal file
27
spec/models/user/authentication_spec.rb
Normal file
@@ -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
|
||||
115
spec/models/user/oidc_spec.rb
Normal file
115
spec/models/user/oidc_spec.rb
Normal file
@@ -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
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم