Feature: Single sign-on (SSO).

This commit is contained in:
Ryan Lue 2019-09-05 16:02:31 +02:00 committed by Thorsten Eckel
parent 0afb105dd7
commit 33bef7123d
10 changed files with 242 additions and 252 deletions

View file

@ -5,7 +5,7 @@ class ApplicationController < ActionController::Base
include ApplicationController::HandlesTransitions include ApplicationController::HandlesTransitions
include ApplicationController::Authenticates include ApplicationController::Authenticates
include ApplicationController::SetsHeaders include ApplicationController::SetsHeaders
include ApplicationController::ChecksMaintainance include ApplicationController::ChecksMaintenance
include ApplicationController::RendersModels include ApplicationController::RendersModels
include ApplicationController::HasUser include ApplicationController::HasUser
include ApplicationController::HasResponseExtentions include ApplicationController::HasResponseExtentions

View file

@ -50,15 +50,6 @@ module ApplicationController::Authenticates
return authentication_check_prerequesits(user, 'session', auth_param) if user return authentication_check_prerequesits(user, 'session', auth_param) if user
end end
# check sso based authentication
sso_user = User.sso(params)
if sso_user
if authentication_check_prerequesits(sso_user, 'session', auth_param)
session[:persistent] = true
return sso_user
end
end
# check http basic based authentication # check http basic based authentication
authenticate_with_http_basic do |username, password| authenticate_with_http_basic do |username, password|
request.session_options[:skip] = true # do not send a session cookie request.session_options[:skip] = true # do not send a session cookie
@ -135,21 +126,37 @@ module ApplicationController::Authenticates
false false
end end
def authenticate_with_password
user = User.authenticate(params[:username], params[:password])
raise Exceptions::NotAuthorized, 'Wrong Username or Password combination.' if !user
session.delete(:switched_from_user_id)
authentication_check_prerequesits(user, 'session', {})
end
def authenticate_with_sso
user = begin
login = request.env['REMOTE_USER'] ||
request.env['HTTP_REMOTE_USER'] ||
request.headers['X-Forwarded-User']
User.lookup(login: login&.downcase)
end
raise Exceptions::NotAuthorized, 'no valid session' if !user
session.delete(:switched_from_user_id)
authentication_check_prerequesits(user, 'session', {})
end
def authentication_check_prerequesits(user, auth_type, auth_param) def authentication_check_prerequesits(user, auth_type, auth_param)
if check_maintenance_only(user) raise Exceptions::NotAuthorized, 'Maintenance mode enabled!' if in_maintenance_mode?(user)
raise Exceptions::NotAuthorized, 'Maintenance mode enabled!'
end
raise Exceptions::NotAuthorized, 'User is inactive!' if !user.active raise Exceptions::NotAuthorized, 'User is inactive!' if !user.active
raise Exceptions::NotAuthorized, 'Not authorized (user)!' if auth_param[:permission] && !user.permissions?(auth_param[:permission])
# check scopes / permission check
if auth_param[:permission] && !user.permissions?(auth_param[:permission])
raise Exceptions::NotAuthorized, 'Not authorized (user)!'
end
current_user_set(user, auth_type) current_user_set(user, auth_type)
user_device_log(user, auth_type) user_device_log(user, auth_type)
logger.debug { "#{auth_type} for '#{user.login}'" } logger.debug { "#{auth_type} for '#{user.login}'" }
true user
end end
end end

View file

@ -1,16 +1,9 @@
module ApplicationController::ChecksMaintainance module ApplicationController::ChecksMaintenance
extend ActiveSupport::Concern extend ActiveSupport::Concern
private private
def check_maintenance(user) def in_maintenance_mode?(user)
return false if !check_maintenance_only(user)
raise Exceptions::NotAuthorized, 'Maintenance mode enabled!'
end
# check maintenance mode
def check_maintenance_only(user)
return false if Setting.get('maintenance_mode') != true return false if Setting.get('maintenance_mode') != true
return false if user.permissions?('admin.maintenance') return false if user.permissions?('admin.maintenance')

View file

@ -2,112 +2,32 @@
class SessionsController < ApplicationController class SessionsController < ApplicationController
prepend_before_action :authentication_check, only: %i[switch_to_user list delete] prepend_before_action :authentication_check, only: %i[switch_to_user list delete]
skip_before_action :verify_csrf_token, only: %i[show destroy create_omniauth failure_omniauth create_sso] skip_before_action :verify_csrf_token, only: %i[show destroy create_omniauth failure_omniauth]
# "Create" a login, aka "log the user in" # "Create" a login, aka "log the user in"
def create def create
user = authenticate_with_password
# in case, remove switched_from_user_id initiate_session_for(user)
session[:switched_from_user_id] = nil
# authenticate user
user = User.authenticate(params[:username], params[:password])
# check maintenance mode
check_maintenance(user)
# auth failed
raise Exceptions::NotAuthorized, 'Wrong Username or Password combination.' if !user
# remember me - set session cookie to expire later
expire_after = nil
if params[:remember_me]
expire_after = 1.year
end
request.env['rack.session.options'][:expire_after] = expire_after
# set session user
current_user_set(user)
# log device
return if !user_device_log(user, 'session')
# log new session
user.activity_stream_log('session started', user.id, true)
# add session user assets
assets = {}
assets = user.assets(assets)
# auto population of default collections
collections, assets = SessionHelper.default_collections(user, assets)
# get models
models = SessionHelper.models(user)
# sessions created via this
# controller are persistent
session[:persistent] = true
# return new session data # return new session data
render status: :created, render status: :created,
json: { json: SessionHelper.json_hash(user).merge(config: config_frontend)
session: user,
config: config_frontend,
models: models,
collections: collections,
assets: assets,
}
end end
def show def show
user = authentication_check_only || authenticate_with_sso
user_id = nil initiate_session_for(user)
# no valid sessions
if session[:user_id]
user_id = session[:user_id]
end
if !user_id || !User.exists?(user_id)
# get models
models = SessionHelper.models()
render json: {
error: 'no valid session',
config: config_frontend,
models: models,
collections: {
Locale.to_app_model => Locale.where(active: true)
},
}
return
end
# Save the user ID in the session so it can be used in
# subsequent requests
user = User.find(user_id)
# log device
return if !user_device_log(user, 'session')
# add session user assets
assets = {}
assets = user.assets(assets)
# auto population of default collections
collections, assets = SessionHelper.default_collections(user, assets)
# get models
models = SessionHelper.models(user)
# return current session # return current session
render json: SessionHelper.json_hash(user).merge(config: config_frontend)
rescue Exceptions::NotAuthorized => e
raise if e.message != 'no valid session'
render json: { render json: {
session: user, error: e.message,
config: config_frontend, config: config_frontend,
models: models, models: SessionHelper.models,
collections: collections, collections: { Locale.to_app_model => Locale.where(active: true) }
assets: assets,
} }
end end
@ -146,8 +66,7 @@ class SessionsController < ApplicationController
authorization = Authorization.create_from_hash(auth, current_user) authorization = Authorization.create_from_hash(auth, current_user)
end end
# check maintenance mode if in_maintenance_mode?(authorization.user)
if check_maintenance_only(authorization.user)
redirect_to '/#' redirect_to '/#'
return return
end end
@ -169,36 +88,6 @@ class SessionsController < ApplicationController
raise Exceptions::UnprocessableEntity, "Message from #{params[:strategy]}: #{params[:message]}" raise Exceptions::UnprocessableEntity, "Message from #{params[:strategy]}: #{params[:message]}"
end end
def create_sso
# in case, remove switched_from_user_id
session[:switched_from_user_id] = nil
user = User.sso(params)
# Log the authorizing user in.
if user
# check maintenance mode
if check_maintenance_only(user)
redirect_to '/#'
return
end
# set current session user
current_user_set(user)
# log new session
user.activity_stream_log('session started', user.id, true)
# remember last login date
user.update_last_login
end
# redirect to app
redirect_to '/#'
end
# "switch" to user # "switch" to user
def switch_to_user def switch_to_user
permission_check(['admin.session', 'admin.user']) permission_check(['admin.session', 'admin.user'])
@ -308,6 +197,12 @@ class SessionsController < ApplicationController
private private
def initiate_session_for(user)
request.env['rack.session.options'][:expire_after] = 1.year if params[:remember_me]
session[:persistent] = true
user.activity_stream_log('session started', user.id, true)
end
def config_frontend def config_frontend
# config # config
@ -333,4 +228,5 @@ class SessionsController < ApplicationController
config config
end end
end end

View file

@ -333,27 +333,6 @@ returns
=begin =begin
authenticate user again sso
result = User.sso(sso_params)
returns
result = user_model # user model if authentication was successfully
=end
def self.sso(params)
# try to login against configure auth backends
user_auth = Sso.check(params)
return if !user_auth
user_auth
end
=begin
create user from from omni auth hash create user from from omni auth hash
result = User.create_from_hash!(hash) result = User.create_from_hash!(hash)

View file

@ -5,9 +5,6 @@ Zammad::Application.routes.draw do
match '/auth/:provider/callback', to: 'sessions#create_omniauth', via: %i[post get puts delete] match '/auth/:provider/callback', to: 'sessions#create_omniauth', via: %i[post get puts delete]
match '/auth/failure', to: 'sessions#failure_omniauth', via: %i[post get] match '/auth/failure', to: 'sessions#failure_omniauth', via: %i[post get]
# sso
match '/auth/sso', to: 'sessions#create_sso', via: %i[post get]
# sessions # sessions
match api_path + '/signin', to: 'sessions#create', via: :post match api_path + '/signin', to: 'sessions#create', via: :post
match api_path + '/signshow', to: 'sessions#show', via: %i[get post] match api_path + '/signshow', to: 'sessions#show', via: %i[get post]

View file

@ -1,8 +1,20 @@
module SessionHelper module SessionHelper
def self.default_collections(user, assets = {}) def self.json_hash(user)
collections, assets = default_collections(user)
{
session: user,
models: models(user),
collections: collections,
assets: assets,
}
end
def self.default_collections(user)
# auto population collections, store all here # auto population collections, store all here
default_collection = {} default_collection = {}
assets = user.assets({})
# load collections to deliver from external files # load collections to deliver from external files
dir = File.expand_path('..', __dir__) dir = File.expand_path('..', __dir__)

View file

@ -1,53 +0,0 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
class Sso
include ApplicationLib
=begin
authenticate user via username and password
result = Sso.check( params )
returns
result = user_model # if authentication was successfully
=end
def self.check(params)
# use std. auth backends
config = [
{
adapter: 'Sso::Env',
},
]
# added configured backends
Setting.where( area: 'Security::SSO' ).each do |setting|
if setting.state_current[:value]
config.push setting.state_current[:value]
end
end
# try to login against configure auth backends
user_auth = nil
config.each do |config_item|
next if !config_item[:adapter]
user_auth = config_item[:adapter].constantize.check( params, config_item )
# auth not ok
next if !user_auth
Rails.logger.info "Authentication against #{config_item[:adapter]} for user #{user_auth.login} ok."
# remember last login date
user_auth.update_last_login
return user_auth
end
nil
end
end

View file

@ -1,19 +0,0 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
module Sso::Env
def self.check( _params, _config_item )
# try to find user based on login
if ENV['REMOTE_USER']
user = User.where( login: ENV['REMOTE_USER'], active: true ).first
return user if user
end
if ENV['HTTP_REMOTE_USER']
user = User.where( login: ENV['HTTP_REMOTE_USER'], active: true ).first
return user if user
end
false
end
end

View file

@ -0,0 +1,178 @@
require 'rails_helper'
RSpec.describe 'Sessions endpoints', type: :request do
# The frontend sends a device fingerprint in the request parameters during authentication
# (as part of App.Auth.loginCheck() and App.WebSocket.auth()).
#
# Without this parameter, the controller will raise a 422 Unprocessable Entity error
# (in ApplicationController::HandlesDevices#user_device_log).
let(:fingerprint) { { fingerprint: 'foo' } }
describe 'GET /api/v1/signshow (single sign-on)' do
context 'with invalid user login' do
let(:login) { User.pluck(:login).max.next }
context 'in "REMOTE_USER" request env var' do
let(:env) { { 'REMOTE_USER' => login } }
it 'returns invalid session response' do
get '/api/v1/signshow', as: :json, env: env, params: fingerprint
expect(response).to have_http_status(:ok)
expect(json_response)
.to include('error' => 'no valid session')
.and not_include('session')
end
end
context 'in "HTTP_REMOTE_USER" request env var' do
let(:env) { { 'HTTP_REMOTE_USER' => login } }
it 'returns invalid session response' do
get '/api/v1/signshow', as: :json, env: env, params: fingerprint
expect(response).to have_http_status(:ok)
expect(json_response)
.to include('error' => 'no valid session')
.and not_include('session')
end
end
context 'in "X-Forwarded-User" request header' do
let(:headers) { { 'X-Forwarded-User' => login } }
it 'returns invalid session response' do
get '/api/v1/signshow', as: :json, headers: headers, params: fingerprint
expect(response).to have_http_status(:ok)
expect(json_response)
.to include('error' => 'no valid session')
.and not_include('session')
end
end
end
context 'with valid user login' do
let(:user) { User.last }
let(:login) { user.login }
context 'in Maintenance Mode' do
before { Setting.set('maintenance_mode', true) }
context 'in "REMOTE_USER" request env var' do
let(:env) { { 'REMOTE_USER' => login } }
it 'returns 401 unauthorized' do
get '/api/v1/signshow', as: :json, env: env, params: fingerprint
expect(response).to have_http_status(:unauthorized)
expect(json_response).to include('error' => 'Maintenance mode enabled!')
end
end
context 'in "HTTP_REMOTE_USER" request env var' do
let(:env) { { 'HTTP_REMOTE_USER' => login } }
it 'returns 401 unauthorized' do
get '/api/v1/signshow', as: :json, env: env, params: fingerprint
expect(response).to have_http_status(:unauthorized)
expect(json_response).to include('error' => 'Maintenance mode enabled!')
end
end
context 'in "X-Forwarded-User" request header' do
let(:headers) { { 'X-Forwarded-User' => login } }
it 'returns 401 unauthorized' do
get '/api/v1/signshow', as: :json, headers: headers, params: fingerprint
expect(response).to have_http_status(:unauthorized)
expect(json_response).to include('error' => 'Maintenance mode enabled!')
end
end
end
context 'in "REMOTE_USER" request env var' do
let(:env) { { 'REMOTE_USER' => login } }
it 'returns a new user-session response' do
get '/api/v1/signshow', as: :json, env: env, params: fingerprint
expect(json_response)
.to include('session' => hash_including('login' => login))
.and not_include('error')
end
it 'sets the :user_id session parameter' do
expect { get '/api/v1/signshow', as: :json, env: env, params: fingerprint }
.to change { request&.session&.fetch(:user_id) }.to(user.id)
end
it 'sets the :persistent session parameter' do
expect { get '/api/v1/signshow', as: :json, env: env, params: fingerprint }
.to change { request&.session&.fetch(:persistent) }.to(true)
end
it 'adds an activity stream entry for the users session' do
expect { get '/api/v1/signshow', as: :json, env: env, params: fingerprint }
.to change(ActivityStream, :count).by(1)
end
end
context 'in "HTTP_REMOTE_USER" request env var' do
let(:env) { { 'HTTP_REMOTE_USER' => login } }
it 'returns a new user-session response' do
get '/api/v1/signshow', as: :json, env: env, params: fingerprint
expect(json_response)
.to include('session' => hash_including('login' => login))
.and not_include('error')
end
it 'sets the :user_id session parameter' do
expect { get '/api/v1/signshow', as: :json, env: env, params: fingerprint }
.to change { request&.session&.fetch(:user_id) }.to(user.id)
end
it 'sets the :persistent session parameter' do
expect { get '/api/v1/signshow', as: :json, env: env, params: fingerprint }
.to change { request&.session&.fetch(:persistent) }.to(true)
end
it 'adds an activity stream entry for the users session' do
expect { get '/api/v1/signshow', as: :json, env: env, params: fingerprint }
.to change(ActivityStream, :count).by(1)
end
end
context 'in "X-Forwarded-User" request header' do
let(:headers) { { 'X-Forwarded-User' => login } }
it 'returns a new user-session response' do
get '/api/v1/signshow', as: :json, headers: headers, params: fingerprint
expect(json_response)
.to include('session' => hash_including('login' => login))
.and not_include('error')
end
it 'sets the :user_id session parameter on the client' do
expect { get '/api/v1/signshow', as: :json, headers: headers, params: fingerprint }
.to change { request&.session&.fetch(:user_id) }.to(user.id)
end
it 'sets the :persistent session parameter' do
expect { get '/api/v1/signshow', as: :json, headers: headers, params: fingerprint }
.to change { request&.session&.fetch(:persistent) }.to(true)
end
it 'adds an activity stream entry for the users session' do
expect { get '/api/v1/signshow', as: :json, headers: headers, params: fingerprint }
.to change(ActivityStream, :count).by(1)
end
end
end
end
end