Commit 24fdd6fa authored by o@immerda.ch's avatar o@immerda.ch
Browse files

rip out login functionality and do auth via saml

TODO: some initial session information missing, since we do not
auth ourselves anymore.

TODO2: store and pass sso ticken to the backend
parent 306a071c
......@@ -39,6 +39,8 @@ gem 'rotp'
gem 'rucaptcha'
gem 'jsobfu'
gem 'ruby-saml', '~> 1.9.0'
group :development, :test do
# Call 'byebug' anywhere in the code to stop execution and get a debugger console
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
......
......@@ -157,6 +157,8 @@ GEM
rotp (3.3.1)
rqrcode (0.10.1)
chunky_png (~> 1.0)
ruby-saml (1.9.0)
nokogiri (>= 1.5.10)
ruby_dep (1.5.0)
rubyzip (1.2.1)
rucaptcha (2.3.0)
......@@ -227,6 +229,7 @@ DEPENDENCIES
rest-client
rotp
rqrcode
ruby-saml (~> 1.9.0)
rucaptcha
sass-rails (~> 5.0)
selenium-webdriver
......
......@@ -65,7 +65,7 @@ class ApplicationController < ActionController::Base
helper_method :recovery_email_set?
def resource_enabled?(resource)
session[:possible_resources][resource].present?
(session[:possible_resources]||{})[resource].present?
end
helper_method :resource_enabled?
......
# This controller expects you to use the URLs /saml/init and /saml/consume in your OneLogin application.
class SamlController < ApplicationController
skip_before_action :verify_authenticity_token, :only => [:consume]
def init
request = OneLogin::RubySaml::Authrequest.new
redirect_to(request.create(saml_settings))
end
def consume
response = OneLogin::RubySaml::Response.new(params[:SAMLResponse],
settings: saml_settings)
# We validate the SAML Response and check if the user already exists in the system
if response.is_valid?
# authorize_success, log the user
session[:user_id] = response.name_id
update_session_expiry
session[:saml_attributes] = response.attributes
else
flash[:notice] = :login_failed
end
redirect_to '/'
end
def authorize
end
private
def saml_settings
settings = OneLogin::RubySaml::Settings.new
settings.issuer = "#{request.base_url}"
settings.assertion_consumer_service_url = "#{request.base_url}/saml/consume"
settings.idp_sso_target_url = SamlConfig.idp_sso
settings.idp_cert = SamlConfig.idp_cert
settings.certificate = SamlConfig.cert
settings.private_key = SamlConfig.private_key
settings.name_identifier_format = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
# Optional for most SAML IdPs
#settings.authn_context = "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"
# Optional. Describe according to IdP specification (if supported) which attributes the SP desires to receive in SAMLResponse.
#settings.attributes_index = 5
## Optional. Describe an attribute consuming service for support of additional attributes.
#settings.attribute_consuming_service.configure do
# service_name "Service"
# service_index 5
# add_attribute :name => "Name", :name_format => "Name Format", :friendly_name => "Friendly Name"
#end
settings
end
end
......@@ -3,187 +3,6 @@ require 'base64'
require 'jsobfu'
class SessionsController < ApplicationController
CountIpFails = false
def client_auth_key
ip = "#{request.remote_ip} #{request.headers['X-Forwarded-For']}"
"auth_fail_#{Digest::SHA256::hexdigest(ip)}"
end
def horde_handoff_auth
str = "#{@user}#{Config['horde_shared_secret']}#{Time.now.to_i/100}"
Digest::SHA256.hexdigest(str)
end
helper_method :horde_handoff_auth
def pre_auth
u = params[:user_id]
return nil unless EmailValidation.immerda_email_conform(u)
session[:pre_auth] ||= ApiBackend::pre_auth(u)
rescue
nil
end
def security_level
u = pre_auth || {'auth_failures' => 0, 'locked' => false}
client_fails =
if CountIpFails
Rails.cache.read(client_auth_key) || 0
else
0
end
session_fails = session[:auth_failures] || 0
total_fails = client_fails + session_fails + u['auth_failures']
captcha = u['locked'] || total_fails > 5
wait = [4, total_fails/4.0].min
wait += rand()*3 if wait > 0.5
redirects = [2, total_fails/2].min
redirects += [0,1].sample if redirects > 0
{
# how many pow do we want from the client
redirects: redirects,
# how many seconds to throttle between pow
wait: wait,
# how hard are the pow
pow_factor: [3, (total_fails/4)+1].min + [0,1].sample,
# do we want an additional captcha
enable_captcha: captcha
}
end
def create
session[:ping_pong] ||= security_level[:redirects]
login_user_id =
session[:pre_auth_id] ||= (EmailValidation.immerda_email_conform(params[:user_id]) || nil)
unless session[:pre_auth_id] == login_user_id
sleep 5
return login_failed
end
unless allowed_user?(login_user_id)
return login_failed
end
unless session[:tfa_query]
if !params[:pow].present?
return login_failed
end
# client gives us a captach, let's verify it
unless check_pow
return login_failed
end
end
# username is a honeypot field
if params[:username].present?
sleep 5
return login_failed
end
if !login_user_id.present? || !params[:password].present?
return new_login_session
end
if session[:ping_pong] > 0
load_params
load_pow(false)
sleep security_level[:wait]
flash[:notice] = nil
session[:ping_pong] -= 1
return render 'captcha'
end
h = params[:handoff]
if h && allowed_handoff.include?(h)
@handoff = h
end
begin
res = ApiBackend::auth(login_user_id,
params[:password],
!!@handoff,
unlock: params[:unlock],
totp: params[:totp])
if res
# Login ok, start user session
login_user_id = nil
reset_user_session
unless @handoff
session[:user_id] = res['email']
update_session_expiry
end
session[:mail_crypt_recovery_token] = nil
session[:mail_crypt_enabled] = res['mail_crypt_enabled']
session[:recovery_email_set] = res['recovery_email_set']
session[:possible_resources] = Hash(res['possible_resources'])
if CountIpFails
Rails.cache.write(client_auth_key, 0, expires_in: 10.minutes)
end
session[:auth_failures] = 0
if mail_crypt_enabled? && res['mail_crypt_recovery_token_present']
flash[:notice] = :recovery_token_hint
elsif res['locked']
flash[:notice] = :locked_account_hint
elsif Zxcvbn.test(params[:password]).score < 2
flash[:notice] = :weak_password_hint
else
flash[:notice] = nil
end
if @handoff
load_params
@user = res['email']
@pw = if res['temp_pw'] then res['temp_pw'] else params[:password] end
return render 'handoff'
else
load_page
if @page && !(@page =~ /login/ || @page =~ /logout/)
redirect_to @page
else
redirect_to '/'
end
end
# successful login
return
end
rescue ApiBackend::ApiError => e
if e.api_msg == 'missing_2fa'
flash[:notice] = nil
session[:tfa_query] = true
load_params
return render '2fa'
end
end
return login_failed
end
def new_login_session
f = flash[:notice]
fails = session[:auth_failures] || 0
reset_user_session
params.delete(:user_id)
session[:auth_failures] = fails
flash[:notice] = f
load_params
load_pow(true)
render 'new'
end
def login_failed
flash[:notice] = :login_failed
if CountIpFails
client_fails = Rails.cache.read(client_auth_key) || 0
logger.error "Auth failed. This client has now #{client_fails+1} failed logins"
Rails.cache.write(client_auth_key, client_fails+1, expires_in: 60.minutes)
end
new_login_session
session[:auth_failures] += 1
end
def destroy
reset_user_session
redirect_to '/login'
......@@ -191,27 +10,8 @@ class SessionsController < ApplicationController
def new
flash[:notice] = nil
new_login_session
end
private
# We require the client to do some work, to discourage bruteforceing the pw
# using this form. The client is expected to return a proof such that
# sha256(nonce,email,password,proof) starts with proof_factor times '1'.
# For every failed login we increase the factor. In the maximum case of
# 4, this takes a modern browser just under a second to compute.
# The idea is, that the client basically has to randomly search for a
# matching proof, thus roughly has to calculate 256^proof_factor hashes.
# The client side implementation is in proofofwork.js.
def check_pow
proof = Digest::SHA256.hexdigest(
"#{session[:pow_nonce]}#{params[:user_id]}#{params[:password]}#{params[:pow]}")
f = session[:pow_factor]
proof[0...f] == "1"*f
end
def allowed_handoff
['webmail', 'webmail-dev']
reset_user_session
redirect_to '/saml/init'
end
def authorize
......@@ -232,93 +32,4 @@ class SessionsController < ApplicationController
@page = page
end
end
def news_frame
Rails.cache.fetch("immerda_news_frame", expires_in: 5.minutes) do
begin
Net::HTTP.get(
URI.parse('https://www.immerda.ch/archive/news_inline.html'))
.force_encoding('UTF-8').html_safe
rescue
''
end
end
end
def load_params
@handoff = params[:handoff]
@unlock = params[:unlock]
if params[:mobile_view_override]
@horde_select_view = (if params['mobile_view'].present? then "smartmobile" else "dynamic" end)
else
@horde_select_view = params[:horde_select_view]
end
@horde_lang = params[:horde_lang]
unless load_page.present?
@news_frame = news_frame
end
end
def load_pow(initial_page)
@pow_factor = session[:pow_factor] = security_level[:pow_factor]
if !initial_page && session[:ping_pong] == 1 && security_level[:enable_captcha]
RuCaptcha.config.strikethrough = false
RuCaptcha.config.cache_store = nil
RuCaptcha.config.outline = [true, false].sample
res = RuCaptcha.generate()
@captcha = Base64.encode64(res[1])
session[:pow_nonce] = res[0]
else
nonce = session[:pow_nonce] = SecureRandom.urlsafe_base64(20)
# Add some obfuscated script to the page that will fill in the nonce
# field, compute the pow and submit it. The obfuscation makes it
# harder for an attacker to get the nonce without executing this
# javascript (thus running a browser) first.
autosubmit_str =
if !initial_page
"brf();document.forms[0].submit();"
else
""
end
script = <<EOF
window.addEventListener('load', function() {
//function d() { eval("debugger"); setTimeout(d, 40);};
setTimeout(d, 10);
try {
console.log('login');
if (!navigator.webdriver) {
document.getElementById('pow_nonce').value = '#{nonce}';
#{autosubmit_str}
}
} catch(e){ }
})
EOF
@pow_nonce_script = JSObfu.new(script).obfuscate.to_s.html_safe
end
end
def handoff_url
has_instance = !!params[:handoff_instance]
instance = params[:handoff_instance].to_i
case @handoff
when 'webmail'
if request.host =~ /ysp4gfuhnmj6b4mb\.onion/
'https://webmail.ysp4gfuhnmj6b4mb.onion/'
else
if has_instance
"https://horde-prod-#{instance}.immerda.ch/"
else
'https://webmail.immerda.ch/'
end
end
when 'webmail-dev'
if has_instance
"https://horde-dev-#{instance}.immerda.ch/"
else
'https://horde-dev.immerda.ch/'
end
end
end
helper_method :handoff_url
end
module SamlConfig
class << self
def the_config
::Config['saml'] || {}
end
def idp_cert
@@idp_cert ||= OpenSSL::X509::Certificate.new(File.read(the_config['idp_cert'])).to_pem
end
def idp_sso
the_config['idp_sso']
end
def private_key
@@private_key ||= OpenSSL::PKey::RSA.new(File.read(the_config['key'])).to_pem
end
def cert
@@cert ||= OpenSSL::X509::Certificate.new(File.read(the_config['cert'])).to_pem
end
end
end
Rails.application.routes.draw do
get '/saml/init', to: 'saml#init'
post '/saml/consume', to: 'saml#consume'
get '/login', to: 'sessions#new'
post '/login', to: 'sessions#create'
get '/logout', to: 'sessions#destroy'
if !Admin::Enabled
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment