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

feat: openid connect support (#2873)

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

عرض الملف

@@ -32,6 +32,13 @@ gem "sentry-rails"
gem "turbolinks", "~> 5" gem "turbolinks", "~> 5"
gem "webrick" 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 group :development, :assets do
gem "coffee-rails", "~> 5.0" gem "coffee-rails", "~> 5.0"
gem "jquery-rails" gem "jquery-rails"

عرض الملف

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

عرض الملف

@@ -1,22 +1,32 @@
.loginForm { .loginForm {}
}
.loginForm__input { .loginForm__input {
margin-bottom:15px; margin-bottom: 15px;
} }
.loginForm__submit { .loginForm__submit {
display:flex; display: flex;
justify-content:space-between; justify-content: space-between;
align-items:center; align-items: center;
} }
.loginForm__links { .loginForm__links {
font-size:12px; font-size: 12px;
color:#999; color: #999;
text-decoration: underline; text-decoration: underline;
line-height:1.7; 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;
} }

عرض الملف

@@ -1,74 +1,67 @@
.userList { .userList {
border-radius:4px; border-radius: 4px;
color:$darkBlue; color: $darkBlue;
overflow:hidden; overflow: hidden;
box-shadow:0 0 10px rgba(0,0,0,0.2); box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
} }
.userList__item { .userList__item {
display:block; display: block;
background:#fff; background: #fff;
padding:15px; padding: 15px;
display:flex; display: flex;
align-items: center; align-items: center;
} }
.userList__item:nth-child(even) { .userList__item:nth-child(even) {
background:none; background: none;
} }
.userList__item + .userList__item { .userList__item+.userList__item {
border-top:1px solid lighten(#ccd4e0, 10%); 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 { .userList__details {
flex: 1 1 auto; flex: 1 1 auto;
margin:0 25px; margin: 0 0;
} }
.userList__actions { .userList__actions {
flex: 0 0 auto; flex: 0 0 auto;
width:180px; width: 120px;
font-size:12px; font-size: 12px;
line-height:1.5; line-height: 1.5;
color:#999; color: #999;
text-decoration: underline;
a {
text-decoration: underline;
}
} }
.userList__name { .userList__name {
font-weight:600; font-weight: 600;
font-size:16px; font-size: 16px;
margin-bottom:3px; margin-bottom: 3px;
} }
.userList__owner { .userList__owner {
vertical-align:2px; vertical-align: 2px;
margin-left:5px; margin-left: 5px;
background-color:$orange; background-color: $orange;
} }
.userList__pending { .userList__pending {
vertical-align:2px; vertical-align: 2px;
margin-left:5px; margin-left: 5px;
background-color:#ccc; background-color: #ccc;
} }
.userList__admin { .userList__tag {
vertical-align:2px; vertical-align: 2px;
margin-left:5px; margin-left: 3px;
background-color:$blue;
} }
.userList__revoke { .userList__revoke {
color:$red; color: $red;
} }

عرض الملف

@@ -1,98 +1,117 @@
.button { .button {
display:inline-block; display: inline-block;
font:inherit; font: inherit;
border-radius:4px; border-radius: 4px;
appearance:none; appearance: none;
background:$blue; background: $blue;
color:#fff; color: #fff;
font-size:14px !important; font-size: 14px !important;
margin:0; margin: 0;
vertical-align:top; vertical-align: top;
padding:6px 15px; padding: 6px 15px;
border:2px solid transparent; border: 2px solid transparent;
border-bottom:2px solid darken($blue, 20%); border-bottom: 2px solid darken($blue, 20%);
&:active { &:active {
background-color:darken($blue, 15%); background-color: darken($blue, 15%);
} }
&:focus { &:focus {
border-color:darken($blue, 15%); border-color: darken($blue, 15%);
background-color:lighten($blue, 5%); background-color: lighten($blue, 5%);
} }
&.is-spinning { &.is-spinning {
color:transparent; color: transparent;
background-repeat:no-repeat; background-repeat: no-repeat;
background-position: center center; background-position: center center;
background-size:25px; background-size: 25px;
background-image:image-url('button-spinner.gif'); background-image: image-url('button-spinner.gif');
} }
} }
.button--small { .button--small {
font-size:12px !important; font-size: 12px !important;
padding:3px 10px; padding: 3px 10px;
border-width:1px; border-width: 1px;
} }
.button--positive { .button--positive {
background-color:$green; background-color: $green;
border-bottom-color:darken($green, 15%); border-bottom-color: darken($green, 15%);
&:active { &:active {
background-color:darken($green, 15%); background-color: darken($green, 15%);
} }
&:focus { &:focus {
border-color:darken($green, 15%); border-color: darken($green, 15%);
background-color:lighten($green, 5%); background-color: lighten($green, 5%);
} }
&.is-spinning { &.is-spinning {
background-image:image-url('button-spinner-positive.gif'); background-image: image-url('button-spinner-positive.gif');
} }
} }
.button--neutral { .button--neutral {
background-color:#ccc; background-color: #ccc;
border-bottom-color:darken(#ccc, 15%); border-bottom-color: darken(#ccc, 15%);
&:active { &:active {
background-color:darken(#ccc, 15%); background-color: darken(#ccc, 15%);
} }
&:focus { &:focus {
border-color:darken(#ccc, 15%); border-color: darken(#ccc, 15%);
background-color:lighten(#ccc, 5%); background-color: lighten(#ccc, 5%);
} }
&.is-spinning { &.is-spinning {
background-image:image-url('button-spinner-neutral.gif'); background-image: image-url('button-spinner-neutral.gif');
} }
} }
.button--danger { .button--danger {
background-color:$red; background-color: $red;
border-bottom-color:darken($red, 15%); border-bottom-color: darken($red, 15%);
&:active { &:active {
background-color:darken($red, 15%); background-color: darken($red, 15%);
} }
&:focus { &:focus {
border-color:darken($red, 15%); border-color: darken($red, 15%);
background-color:lighten($red, 5%); background-color: lighten($red, 5%);
} }
&.is-spinning { &.is-spinning {
background-image:image-url('button-spinner-danger.gif'); background-image: image-url('button-spinner-danger.gif');
} }
} }
.button--dark { .button--dark {
background-color:$darkBlue; background-color: $darkBlue;
border-bottom-color:darken($darkBlue, 15%); border-bottom-color: darken($darkBlue, 15%);
&:active { &:active {
background-color:darken($darkBlue, 15%); background-color: darken($darkBlue, 15%);
} }
&:focus { &:focus {
border-color:darken($darkBlue, 15%); border-color: darken($darkBlue, 15%);
background-color:lighten($darkBlue, 5%); background-color: lighten($darkBlue, 5%);
} }
&.is-spinning { &.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;
}

عرض الملف

@@ -1,136 +1,137 @@
.label { .label {
display:inline-block; display: inline-block;
background:#000; background: #000;
color:#fff; color: #fff;
font-size:9px; font-size: 9px;
text-transform: uppercase; text-transform: uppercase;
border-radius:40px; border-radius: 40px;
padding:2px 6px; padding: 2px 6px;
line-height:0.9; line-height: 0.9;
} }
.label--green { .label--green {
background-color:$green; background-color: $green;
} }
.label--red { .label--red {
background-color:$red; background-color: $red;
} }
.label--orange { .label--orange {
background-color:$orange; background-color: $orange;
} }
.label--blue { .label--blue {
background-color:$blue; background-color: $blue;
} }
.label--grey { .label--grey {
background-color:#999; background-color: #999;
} }
.label--turquoise { .label--turquoise {
background-color:$blue; background-color: $blue;
} }
.label--purple { .label--purple {
background-color:$purple; background-color: $purple;
} }
.label--large { .label--large {
font-size:11px; font-size: 11px;
padding:4px 10px; padding: 4px 10px;
} }
.label--serverStatus-live { .label--serverStatus-live {
background-color:$green; background-color: $green;
} }
.label--serverStatus-development { .label--serverStatus-development {
background-color:#636363; background-color: #636363;
} }
.label--serverStatus-suspended { .label--serverStatus-suspended {
background-color:$red; background-color: $red;
} }
.label--messageStatus-pending { .label--messageStatus-pending {
background-color:$subBlue; background-color: $subBlue;
} }
.label--messageStatus-held { .label--messageStatus-held {
background-color:#aaa; background-color: #aaa;
} }
.label--messageStatus-processed { .label--messageStatus-processed {
background-color:$green; background-color: $green;
} }
.label--messageStatus-sent { .label--messageStatus-sent {
background-color:$green; background-color: $green;
} }
.label--messageStatus-hard_fail { .label--messageStatus-hard_fail {
background-color:$red; background-color: $red;
} }
.label--messageStatus-soft_fail { .label--messageStatus-soft_fail {
background-color:$orange; background-color: $orange;
} }
.label--messageStatus-bounced { .label--messageStatus-bounced {
background-color:$red; background-color: $red;
} }
.label--messageStatus-hold_cancelled { .label--messageStatus-hold_cancelled {
background-color:#ccc; background-color: #ccc;
} }
.label--credentialType-api { .label--credentialType-api {
background-color:$blue; background-color: $blue;
} }
.label--credentialType-smtp { .label--credentialType-smtp {
background-color:$turquoise; background-color: $turquoise;
} }
.label--credentialType-smtp_ip { .label--credentialType-smtp_ip {
background-color:$orange; background-color: $orange;
} }
.label--spamStatus-not_checked { .label--spamStatus-not_checked {
background:#aaa; background: #aaa;
} }
.label--spamStatus-spam { .label--spamStatus-spam {
background:$orange; background: $orange;
} }
.label--spamStatus-not_spam { .label--spamStatus-not_spam {
background:$turquoise; background: $turquoise;
} }
.label--http-status-2 { .label--http-status-2 {
background-color:$green; background-color: $green;
} }
.label--http-status-3 { .label--http-status-3 {
background-color:$orange; background-color: $orange;
} }
.label--http-status-4, .label--http-status-4,
.label--http-status-5 { .label--http-status-5 {
background-color:$red; background-color: $red;
} }
.domainList__ssl { .domainList__ssl {
color:$green; color: $green;
&:hover { &:hover {
text-decoration:underline; text-decoration: underline;
} }
} }
.domainList__ssl--disabled { .domainList__ssl--disabled {
color:#999; color: #999;
} }

عرض الملف

@@ -4,7 +4,8 @@ class SessionsController < ApplicationController
layout "sub" 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 def create
login(User.authenticate(params[:email_address], params[:password])) login(User.authenticate(params[:email_address], params[:password]))
@@ -29,12 +30,16 @@ class SessionsController < ApplicationController
def begin_password_reset def begin_password_reset
return unless request.post? 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.begin_password_reset(params[:return_to]) user = user_scope.find_by(email_address: params[:email_address])
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 if user.nil?
redirect_to login_reset_path(return_to: params[:return_to]), alert: "No user exists with that e-mail address. Please check and try again." 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 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 end
def finish_password_reset def finish_password_reset
@@ -49,6 +54,7 @@ class SessionsController < ApplicationController
flash.now[:alert] = "You must enter a new password" flash.now[:alert] = "You must enter a new password"
return return
end end
@user.password = params[:password] @user.password = params[:password]
@user.password_confirmation = params[:password_confirmation] @user.password_confirmation = params[:password_confirmation]
return unless @user.save return unless @user.save
@@ -61,4 +67,33 @@ class SessionsController < ApplicationController
render plain: "ip: #{request.ip} remote ip: #{request.remote_ip}" render plain: "ip: #{request.ip} remote ip: #{request.remote_ip}"
end 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 end

عرض الملف

@@ -30,23 +30,28 @@ class UserController < ApplicationController
def update def update
@user = User.find(current_user.id) @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.authenticate_with_previous_password_first(params[:password]) if @user.password? && Postal::Config.oidc.local_authentication_enabled?
@password_correct = true safe_params += [:password, :password_confirmation]
else if @user.authenticate_with_previous_password_first(params[:password])
respond_to do |wants| @password_correct = true
wants.html do else
flash.now[:alert] = "The current password you have entered is incorrect. Please check and try again." respond_to do |wants|
render "edit" wants.html do
end flash.now[:alert] = "The current password you have entered is incorrect. Please check and try again."
wants.json do render "edit"
render json: { alert: "The current password you've entered is incorrect. Please check and try again" } end
wants.json do
render json: { alert: "The current password you've entered is incorrect. Please check and try again" }
end
end end
return
end end
return
end end
@user.attributes = params.require(:user).permit(safe_params)
if @user.save if @user.save
redirect_to_with_json settings_path, notice: "Your settings have been updated successfully." redirect_to_with_json settings_path, notice: "Your settings have been updated successfully."
else else

عرض الملف

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

عرض الملف

@@ -5,19 +5,21 @@
# Table name: users # Table name: users
# #
# id :integer not null, primary key # id :integer not null, primary key
# uuid :string(255) # admin :boolean default(FALSE)
# first_name :string(255)
# last_name :string(255)
# email_address :string(255) # email_address :string(255)
# password_digest :string(255)
# time_zone :string(255)
# email_verification_token :string(255) # email_verification_token :string(255)
# email_verified_at :datetime # email_verified_at :datetime
# created_at :datetime # first_name :string(255)
# updated_at :datetime # last_name :string(255)
# oidc_issuer :string(255)
# oidc_uid :string(255)
# password_digest :string(255)
# password_reset_token :string(255) # password_reset_token :string(255)
# password_reset_token_valid_until :datetime # password_reset_token_valid_until :datetime
# admin :boolean default(FALSE) # time_zone :string(255)
# uuid :string(255)
# created_at :datetime
# updated_at :datetime
# #
# Indexes # Indexes
# #
@@ -28,13 +30,11 @@
class User < ApplicationRecord class User < ApplicationRecord
include HasUUID include HasUUID
include HasAuthentication include HasAuthentication
validates :first_name, presence: true validates :first_name, presence: true
validates :last_name, presence: true validates :last_name, presence: true
validates :email_address, presence: true, uniqueness: { case_sensitive: false }, format: { with: /@/, allow_blank: 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" } default_value :time_zone, -> { "UTC" }
@@ -53,24 +53,85 @@ class User < ApplicationRecord
"#{first_name} #{last_name}" "#{first_name} #{last_name}"
end end
def password?
password_digest.present?
end
def oidc?
oidc_uid.present?
end
def to_param def to_param
uuid uuid
end 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 def email_tag
"#{name} <#{email_address}>" "#{name} <#{email_address}>"
end end
def self.[](email) class << self
where(email_address: email).first
# 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
end end

عرض الملف

@@ -6,13 +6,19 @@
.subPageBox__content .subPageBox__content
= form_tag login_path, :class => 'loginForm' do = form_tag login_path, :class => 'loginForm' do
= hidden_field_tag 'return_to', params[:return_to] = 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?
%p.loginForm__input= password_field_tag 'password', '', :class => 'input input--text input--onWhite', :placeholder => "Your password", :tabindex => 2 .loginForm__oidcButton
.loginForm__submit = link_to "Login with #{Postal::Config.oidc.name}", "/auth/oidc", method: :post, class: 'button button--full'
%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
- 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,15 +6,16 @@
= form_for @user, :url => settings_path, :remote => true do |f| = form_for @user, :url => settings_path, :remote => true do |f|
= f.error_messages = f.error_messages
%fieldset.fieldSet %fieldset.fieldSet
.fieldSet__field - if @user.password? && Postal::Config.oidc.local_authentication_enabled?
= label_tag :password, 'Your Password', :class => 'fieldSet__label' .fieldSet__field
.fieldSet__input = label_tag :password, 'Your Password', :class => 'fieldSet__label'
= 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" .fieldSet__input
- if @password_correct = 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"
= hidden_field_tag :password, params[:password] - if @password_correct
%p.fieldSet__text = hidden_field_tag :password, params[:password]
In order to protect your account, you need to enter your current password in the field above %p.fieldSet__text
to authenticate the change of your details. 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 .fieldSet__title
Your details Your details
@@ -41,14 +42,15 @@
Choose the time zone that you'd like times to be displayed to you when you use our 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. web interface. By default, times are displayed in UTC.
.fieldSet__title - if @user.password? && Postal::Config.oidc.local_authentication_enabled?
Change your password? .fieldSet__title
.fieldSet__field Change your password?
= f.label :password, "New Password", :class => 'fieldSet__label' .fieldSet__field
.fieldSet__input = f.label :password, "New Password", :class => 'fieldSet__label'
.inputPair .fieldSet__input
= f.password_field :password, :class => 'input input--text', :placeholder => "•••••••••••", :value => @user.password .inputPair
= f.password_field :password_confirmation, :class => 'input input--text', :placeholder => "and confirm it", :value => @user.password_confirmation = 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 %p.fieldSetSubmit.buttonSet

عرض الملف

@@ -11,11 +11,25 @@
.fieldSet__input= f.text_field :last_name, :class => 'input input--text' .fieldSet__input= f.text_field :last_name, :class => 'input input--text'
.fieldSet__field .fieldSet__field
= f.label :email_address, :class => 'fieldSet__label' = f.label :email_address, :class => 'fieldSet__label'
.fieldSet__input= f.text_field :email_address, :class => 'input input--text', autocomplete: 'one-time-code' .fieldSet__input
- unless @user.persisted? = 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 .fieldSet__field
= f.label :password, :class => 'fieldSet__label' = 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 .fieldSet__field
= f.label :password_confirmation, "Confirm".html_safe, :class => 'fieldSet__label' = 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' .fieldSet__input= f.password_field :password_confirmation, :class => 'input input--text', :placeholder => '•••••••••••', autocomplete: 'one-time-code'

عرض الملف

@@ -7,15 +7,20 @@
%ul.userList.u-margin %ul.userList.u-margin
- for user in @users - for user in @users
%li.userList__item %li.userList__item
= image_tag user.avatar_url, :class => 'userList__avatar'
.userList__details .userList__details
%p.userList__name %p.userList__name
= user.name = user.name
- if user.admin? - 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 %p.userList__email= user.email_address
%ul.userList__actions %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' %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' %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 # Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production. # 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 module Postal
class Application < Rails::Application class Application < Rails::Application

عرض الملف

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

عرض الملف

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

عرض الملف

@@ -85,6 +85,10 @@ Rails.application.routes.draw do
match "login/reset" => "sessions#begin_password_reset", :via => [:get, :post] match "login/reset" => "sessions#begin_password_reset", :via => [:get, :post]
match "login/reset/:token" => "sessions#finish_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" get "ip" => "sessions#ip"
root "organizations#index" root "organizations#index"

عرض الملف

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

عرض الملف

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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| create_table "additional_route_endpoints", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.integer "route_id" t.integer "route_id"
t.string "endpoint_type" 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.string "password_reset_token"
t.datetime "password_reset_token_valid_until", precision: nil t.datetime "password_reset_token_valid_until", precision: nil
t.boolean "admin", default: false 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 ["email_address"], name: "index_users_on_email_address", length: 8
t.index ["uuid"], name: "index_users_on_uuid", length: 8 t.index ["uuid"], name: "index_users_on_uuid", length: 8
end 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_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_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 | | `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 attempts: 120
# The number of seconds to wait between each migration check # The number of seconds to wait between each migration check
sleep_time: 2 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 default 2
end end
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 end
class << self class << self

عرض الملف

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

عرض الملف

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

عرض الملف

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

عرض الملف

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

عرض الملف

@@ -11,6 +11,8 @@
# email_verified_at :datetime # email_verified_at :datetime
# first_name :string(255) # first_name :string(255)
# last_name :string(255) # last_name :string(255)
# oidc_issuer :string(255)
# oidc_uid :string(255)
# password_digest :string(255) # password_digest :string(255)
# password_reset_token :string(255) # password_reset_token :string(255)
# password_reset_token_valid_until :datetime # password_reset_token_valid_until :datetime
@@ -27,34 +29,105 @@
require "rails_helper" require "rails_helper"
describe User do describe User do
context "model" do subject(:user) { build(:user) }
subject(:user) { create(: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 it "should have a UUID" do
expect(user.uuid).to be_a String expect(user.uuid).to be_a String
expect(user.uuid.length).to eq 36 expect(user.uuid.length).to eq 36
end end
it "has a default timezone" do
expect(user.time_zone).to eq "UTC"
end
end end
context ".authenticate" do describe "#organizations_scope" do
it "should not authenticate users with invalid emails" do context "when the user is an admin" do
expect { User.authenticate("nothing@nothing.com", "hello") }.to raise_error(Postal::Errors::AuthenticationError) do |e| it "returns a scope of all organizations" do
expect(e.error).to eq "InvalidEmailAddress" user.admin = true
scope = user.organizations_scope
expect(scope).to eq Organization.present
end end
end end
it "should not authenticate users with invalid passwords" do context "when the user not an admin" do
user = create(:user) it "returns a scope including only orgs the user is associated with" do
expect { User.authenticate(user.email_address, "hello") }.to raise_error(Postal::Errors::AuthenticationError) do |e| user.admin = false
expect(e.error).to eq "InvalidPassword" user.organizations << create(:organization)
scope = user.organizations_scope
expect(scope).to eq user.organizations.present
end end
end end
end
it "should authenticate valid users" do 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) user = create(:user)
auth_user = nil expect(User[user.email_address]).to eq user
expect { auth_user = User.authenticate(user.email_address, "passw0rd") }.to_not raise_error
expect(auth_user).to eq user
end end
end end
end end