diff --git a/Dockerfile.frontend-base b/Dockerfile.frontend-base index add3446e6d9122ca0e70a1965b469a3cb71e9293..b57ead0e969a22f800264578ef01a31922ec584e 100644 --- a/Dockerfile.frontend-base +++ b/Dockerfile.frontend-base @@ -33,6 +33,7 @@ RUN apt-get update \ ruby-bundler \ ruby-ffi \ sphinxsearch \ + python3-yaml \ supervisor \ time \ tzdata diff --git a/docker/frontend-docker-entrypoint.sh b/docker/frontend-docker-entrypoint.sh index 5f1a1c008db47dcda1ee1e73ef1abb769e6d81a2..8fbbcfd0f4aa10ff6593b38cd68c4c879cd0b403 100755 --- a/docker/frontend-docker-entrypoint.sh +++ b/docker/frontend-docker-entrypoint.sh @@ -10,7 +10,7 @@ chmod a+rwxt /tmp /opt/configure-app.sh /opt/configure-db.sh -#/opt/configure-sso.py +/opt/configure-sso.py : ${OBS_FRONTEND_WORKERS:=4} export OBS_FRONTEND_WORKERS diff --git a/src/api/Gemfile b/src/api/Gemfile index c1a70df6ec04e6f64eacae1b19a5d48167a11c0d..ee8ce23fdd83945f08d0d7ace4bd5ca31ca5abcc 100644 --- a/src/api/Gemfile +++ b/src/api/Gemfile @@ -85,6 +85,13 @@ gem 'deep_cloneable', '~> 2.4.0' # Server-side datatables gem 'ajax-datatables-rails' +# SSO +gem 'omniauth' +gem 'omniauth-gitlab' +gem 'omniauth-github' +gem 'omniauth_openid_connect', '~> 0.4.0' +gem 'omniauth-rails_csrf_protection' + group :development, :production do # to have the delayed job daemon gem 'daemons' diff --git a/src/api/Gemfile.lock b/src/api/Gemfile.lock index 90e9c5b4212a04b580fa7d64d0c997714cf9b32a..b460054fb3c9713d7d880e16440d8563a1f534e7 100644 --- a/src/api/Gemfile.lock +++ b/src/api/Gemfile.lock @@ -52,6 +52,7 @@ GEM activerecord (>= 3.0.0) addressable (2.6.0) public_suffix (>= 2.0.2, < 4.0) + aes_key_wrap (1.1.0) airbrake (8.0.1) airbrake-ruby (~> 3.0) airbrake-ruby (3.1.0) @@ -62,9 +63,11 @@ GEM ansi (1.5.0) arel (9.0.0) ast (2.4.0) + attr_required (1.0.1) autoprefixer-rails (9.6.0) execjs bcrypt (3.1.13) + bindata (2.4.10) bootstrap (4.3.1) autoprefixer-rails (>= 9.1.0) popper_js (>= 1.14.3, < 2) @@ -154,6 +157,29 @@ GEM tty-pager (~> 0.12.0) tty-screen (~> 0.6.5) tty-tree (~> 0.3.0) + faraday (1.9.3) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.0.3) + multipart-post (>= 1.2, < 3) + faraday-net_http (1.0.1) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) feature (1.4.0) ffi (1.11.1) flot-rails (0.0.7) @@ -180,11 +206,13 @@ GEM rubocop (>= 0.50.0) sysexits (~> 1.1) hashdiff (0.4.0) + hashie (5.0.0) html2haml (2.2.0) erubis (~> 2.7.0) haml (>= 4.0, < 6) nokogiri (>= 1.6.0) ruby_parser (~> 3.5) + httpclient (2.8.3) i18n (1.8.11) concurrent-ruby (~> 1.0) influxdb (0.7.0) @@ -200,6 +228,11 @@ GEM jquery-ui-rails (4.2.1) railties (>= 3.2.16) json (2.5.1) + json-jwt (1.13.0) + activesupport (>= 4.2) + aes_key_wrap + bindata + jwt (2.3.0) kaminari (1.2.1) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.1) @@ -245,6 +278,9 @@ GEM momentjs-rails (2.20.1) railties (>= 3.1) mousetrap-rails (1.4.6) + multi_json (1.15.0) + multi_xml (0.6.0) + multipart-post (2.1.1) mysql2 (0.5.2) nio4r (2.5.8) nokogiri (1.11.7) @@ -252,6 +288,42 @@ GEM racc (~> 1.4) nokogumbo (2.0.1) nokogiri (~> 1.8, >= 1.8.4) + oauth2 (1.4.7) + faraday (>= 0.8, < 2.0) + jwt (>= 1.0, < 3.0) + multi_json (~> 1.3) + multi_xml (~> 0.5) + rack (>= 1.2, < 3) + omniauth (2.0.4) + hashie (>= 3.4.6) + rack (>= 1.6.2, < 3) + rack-protection + omniauth-github (2.0.0) + omniauth (~> 2.0) + omniauth-oauth2 (~> 1.7.1) + omniauth-gitlab (3.0.0) + omniauth (~> 2.0) + omniauth-oauth2 (~> 1.7.1) + omniauth-oauth2 (1.7.2) + oauth2 (~> 1.4) + omniauth (>= 1.9, < 3) + omniauth-rails_csrf_protection (1.0.0) + actionpack (>= 4.2) + omniauth (~> 2.0) + omniauth_openid_connect (0.4.0) + addressable (~> 2.5) + omniauth (>= 1.9, < 3) + openid_connect (~> 1.1) + openid_connect (1.3.0) + activemodel + attr_required (>= 1.0.0) + json-jwt (>= 1.5.0) + rack-oauth2 (>= 1.6.1) + swd (>= 1.0.0) + tzinfo + validate_email + validate_url + webfinger (>= 1.0.1) parallel (1.17.0) parser (2.6.3.0) ast (~> 2.4.0) @@ -290,6 +362,14 @@ GEM activesupport (>= 3.0.0) racc (1.5.2) rack (2.2.3) + rack-oauth2 (1.19.0) + activesupport + attr_required + httpclient + json-jwt (>= 1.11.0) + rack (>= 2.1.0) + rack-protection (2.1.0) + rack rack-test (1.1.0) rack (>= 1.0, < 3) rails (5.2.6.2) @@ -377,6 +457,7 @@ GEM rubocop (>= 0.60.0) ruby-ldap (0.9.20) ruby-progressbar (1.10.1) + ruby2_keywords (0.0.5) ruby_parser (3.13.1) sexp_processor (~> 4.9) rubyzip (2.0.0) @@ -420,6 +501,10 @@ GEM unicode-display_width (~> 1.5) unicode_utils (~> 1.4) strings-ansi (0.1.0) + swd (1.3.0) + activesupport (>= 3) + attr_required (>= 0.0.5) + httpclient (>= 2.4) sysexits (1.2.0) tdigest (0.1.1) rbtree (~> 0.4.2) @@ -455,9 +540,18 @@ GEM unicode-display_width (1.6.0) unicode_utils (1.4.0) uniform_notifier (1.12.1) + validate_email (0.1.6) + activemodel (>= 3.0) + mail (>= 2.2.5) + validate_url (1.0.13) + activemodel (>= 3.0.0) + public_suffix vcr (5.0.0) voight_kampff (1.1.3) rack (>= 1.4, < 3.0) + webfinger (1.2.0) + activesupport + httpclient (>= 2.4) webmock (3.6.0) addressable (>= 2.3.6) crack (>= 0.3.2) @@ -532,6 +626,11 @@ DEPENDENCIES mousetrap-rails mysql2 nokogiri + omniauth + omniauth-github + omniauth-gitlab + omniauth-rails_csrf_protection + omniauth_openid_connect peek peek-dalli peek-host diff --git a/src/api/app/controllers/webui/session_controller.rb b/src/api/app/controllers/webui/session_controller.rb index a6a0032a22b34ebd63ed10a984cd9b6d6aaeefa2..e602cf7fe06bf614f92f787908b1f1ae8ae2d8d3 100644 --- a/src/api/app/controllers/webui/session_controller.rb +++ b/src/api/app/controllers/webui/session_controller.rb @@ -1,7 +1,7 @@ class Webui::SessionController < Webui::WebuiController before_action :kerberos_auth, only: [:new] - skip_before_action :check_anonymous, only: [:create] + skip_before_action :check_anonymous, only: [:new, :create, :sso, :sso_callback, :sso_confirm, :do_sso_confirm] def new switch_to_webui2 @@ -40,10 +40,108 @@ class Webui::SessionController < Webui::WebuiController redirect_on_logout end + def sso + switch_to_webui2 + end + + def sso_callback + @auth_hash = request.env['omniauth.auth'] + user = User.find_with_omniauth(@auth_hash['info']) + + unless user + session[:auth] = @auth_hash['info'] + session[:auth]['provider'] = @auth_hash['provider'] + redirect_to(sso_confirm_path) + return + end + + unless user.is_active? + RabbitmqBus.send_to_bus('metrics', 'login,access_point=webui,failure=disabled value=1') + redirect_to(root_path, error: 'Your account is disabled. Please contact the administrator for details.') + return + end + + User.session = user + session[:login] = user.login + Rails.logger.debug "Authenticated user '#{user.login}'" + + redirect_on_login + end + + def sso_confirm + switch_to_webui2 + auth_info = session[:auth] + + if !auth_info + redirect_to sso_path + return + end + + # Try to derive a username from the information available, + # falling back to full name if nothing else works + @derived_username = auth_info['username'] || + auth_info['nickname'] || + auth_info['email'] || + auth_info['name'] + + # Some providers set username or nickname to an email address + # Derive the username from the local part of the email address, + # if possible. The full name with spaces replaced by underscores + # is the last resort fallback. + @derived_username = @derived_username.rpartition("@")[0] if @derived_username.include? "@" + @derived_username = @derived_username.gsub(' ', '_') + end + + def do_sso_confirm + required_parameters :login + auth_info = session[:auth] + + if !auth_info + redirect_to sso_path + return + end + + existing_user = User.find_by_login(params[:login]) + if existing_user + flash[:error] = "Username #{params[:login]} is already taken, choose a different one" + redirect_to sso_confirm_path + return + end + + begin + user = User.create_with_omniauth(auth_info, params[:login]) + rescue ActiveRecord::ActiveRecordError + flash[:error] = "Invalid username, please try a different one" + redirect_to sso_confirm_path + return + end + + unless user + flash[:error] = "Cannot create user" + redirect_to root_path + return + end + + unless user.is_active? + RabbitmqBus.send_to_bus('metrics', 'login,access_point=webui,failure=disabled value=1') + redirect_to(root_path, error: 'Your account needs to be confirmed by the administrator.') + return + end + + User.session = user + session[:login] = user.login + Rails.logger.debug "Authenticated user '#{user.login}'" + + redirect_on_login + end + + private def redirect_on_login - if referer_was_login? + if !referer_was_ours? + redirect_to root_path + elsif referer_was_login? redirect_to user_show_path(User.session!) else redirect_back(fallback_location: root_path) @@ -54,11 +152,24 @@ class Webui::SessionController < Webui::WebuiController if CONFIG['proxy_auth_mode'] == :on redirect_to CONFIG['proxy_auth_logout_page'] else - redirect_back(fallback_location: root_path) + redirect_to root_path end end + def referer_was_ours? + return false unless request.referer + + parsed = URI.parse(request.referer) + parsed.host == request.host and parsed.port == request.port + end + def referer_was_login? - request.referer && request.referer.end_with?(session_new_path) + return false unless request.referer + + parsed = URI.parse(request.referer) + return false unless parsed.host == request.host + return false unless parsed.port == request.port + + parsed.path == session_new_path or parsed.path.starts_with?(sso_path) end end diff --git a/src/api/app/controllers/webui/user_controller.rb b/src/api/app/controllers/webui/user_controller.rb index 6c143616b2a677a19b20c718d3e123983f37b661..aaa84aabadbe66ffe0a9d3f87c6a34c5164cdfd4 100644 --- a/src/api/app/controllers/webui/user_controller.rb +++ b/src/api/app/controllers/webui/user_controller.rb @@ -153,9 +153,10 @@ class Webui::UserController < Webui::WebuiController return end - if user.authenticate(params[:password]) + if user.authenticate(params[:password]) or user.password_invalid? user.password = params[:new_password] user.password_confirmation = params[:repeat_password] + user.deprecated_password_hash_type = nil if user.save flash[:success] = 'Your password has been changed successfully.' diff --git a/src/api/app/models/user.rb b/src/api/app/models/user.rb index e545dcbf6b39085aad1935cd392fdeab7a20e206..1156e291a755cc7bbd515bb48c693edcbeb97a79 100644 --- a/src/api/app/models/user.rb +++ b/src/api/app/models/user.rb @@ -164,12 +164,12 @@ class User < ApplicationRecord create!(attributes.merge(password: SecureRandom.base64(48))) end - def self.create_ldap_user(attributes = {}) - user = create_user_with_fake_pw!(attributes.merge(state: default_user_state, adminnote: 'User created via LDAP')) + def self.create_external_user(attributes = {}) + user = create_user_with_fake_pw!(attributes.merge(state: default_user_state)) return user if user.errors.empty? - logger.info("Cannot create ldap userid: '#{login}' on OBS. Full log: #{user.errors.full_messages.to_sentence}") + logger.info("Cannot create external userid: '#{login}' on OBS. Full log: #{user.errors.full_messages.to_sentence}") return end @@ -214,13 +214,43 @@ class User < ApplicationRecord logger.debug("Email: #{ldap_info[0]}") logger.debug("Name : #{ldap_info[1]}") - user = create_ldap_user(login: login, email: ldap_info[0], realname: ldap_info[1]) + user = create_external_user(login: login, + email: ldap_info[0], + realname: ldap_info[1], + adminnote: "User created via LDAP") end user.mark_login! user end + def self.find_with_omniauth(auth) + if auth + email = auth['email'] + user = find_by_email(email) + if user + user.mark_login! + + return user + end + end + end + + def self.create_with_omniauth(auth, login) + provider = CONFIG['sso_auth'][auth['provider']]['description'] + email = auth['email'] + logger.debug("Creating OmniAuth user for #{provider}") + logger.debug("Email: #{email}") + logger.debug("Name : #{auth['name']}") + + user = create_external_user(login: login, + email: email, + realname: auth['name'], + deprecated_password_hash_type: 'invalid', + adminnote: "User created via #{provider}") + user.mark_login! + user + end # Currently logged in user or nobody user if there is no user logged in. # Use this to check permissions, but don't treat it as logged in user. Check # is_nobody? on the returned object @@ -870,6 +900,10 @@ class User < ApplicationRecord end end + def password_invalid? + self.deprecated_password_hash_type == 'invalid' + end + private # The currently logged in user (might be nil). It's reset after diff --git a/src/api/app/views/layouts/webui2/_login_form.html.haml b/src/api/app/views/layouts/webui2/_login_form.html.haml index 981f9329bb694b67c4162b050cb0d881d03ef4b2..504bb75155067254a418423c9dc663a68a503481 100644 --- a/src/api/app/views/layouts/webui2/_login_form.html.haml +++ b/src/api/app/views/layouts/webui2/_login_form.html.haml @@ -6,7 +6,7 @@ Log In .dropdown-menu.dropdown-menu-right.shadow-lg.bg-dark{ 'aria-labelledby': 'dropdownMenuButton' } .px-4.py-3#login-form - = form_tag(form_url, options) do + = form_tag(form_url, options.merge({class: 'pb-3'})) do - if proxy = hidden_field_tag(:context, 'default') = hidden_field_tag(:proxypath, 'reserve') @@ -21,3 +21,9 @@ .float-right = submit_tag('Log In', class: 'btn btn-success') .clearfix + .form-group.border-top.pt-3 + %p{class: 'text-light'} Or sign in with: + .form-inline + - CONFIG['sso_auth'].each do |name, options| + = form_tag "#{sso_path}/#{name}", method: 'post', class: 'px-1' do + = button_tag options['description'], class: 'btn btn-light' diff --git a/src/api/app/views/webui/session/_form.html.haml b/src/api/app/views/webui/session/_form.html.haml index 95901a4a3b71b7c2bc5a4aca07993c052e06d002..2d0c15c1f27f478b58f9377967307b5254dc239b 100644 --- a/src/api/app/views/webui/session/_form.html.haml +++ b/src/api/app/views/webui/session/_form.html.haml @@ -9,6 +9,12 @@ %input#user-password{ name: "password", size: "30", type: "password" }/ %p %input.primary#log-in-button{ name: "login", type: "submit", value: "Log In" }/ +- if CONFIG['sso_auth'] + %p + Sign in using one of the following external services: + %p + - CONFIG['sso_auth'].each do |name, options| + = button_tag "Log in with #{options['description']}", formaction: "#{sso_path}/#{name}" %p - if CONFIG['proxy_auth_mode'] == :on Or diff --git a/src/api/app/views/webui2/webui/session/_sso.html.haml b/src/api/app/views/webui2/webui/session/_sso.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..32d4c98f25968ad2d7581212fb5394286fb1051a --- /dev/null +++ b/src/api/app/views/webui2/webui/session/_sso.html.haml @@ -0,0 +1,8 @@ +%h3= 'Sign in with SSO' + +.form-group + %p Sign in with your external account: + .form-inline + - CONFIG['sso_auth'].each do |name, options| + = form_tag "#{sso_path}/#{name}", method: 'post', class: 'px-1' do + = button_tag options['description'], class: 'btn btn-outline-dark' diff --git a/src/api/app/views/webui2/webui/session/new.html.haml b/src/api/app/views/webui2/webui/session/new.html.haml index 7ac24065438ff951059cb09d280eaf80815d148e..3d8e681d860a518343b2ff0da16b40418918d050 100644 --- a/src/api/app/views/webui2/webui/session/new.html.haml +++ b/src/api/app/views/webui2/webui/session/new.html.haml @@ -1,8 +1,8 @@ - @pagetitle = 'Please Log In' .card - .card-body#loginform - .col-lg-6.pl-0 + .card-body.row#loginform + .col-lg-6.pl-3 - if CONFIG['proxy_auth_mode'] == :on = render partial: 'form', locals: { form_url: CONFIG['proxy_auth_login_page'], options: { method: :post, enctype: 'application/x-www-form-urlencoded' }, @@ -17,3 +17,6 @@ options: { method: :post }, proxy: false, pagetitle: @pagetitle } + - if CONFIG['sso_auth'] + .col-lg-6.pl-3.border-left + = render partial: 'sso' diff --git a/src/api/app/views/webui2/webui/session/sso.html.haml b/src/api/app/views/webui2/webui/session/sso.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..f78e2a98c6da234fa4ca5850addd0075e59220e9 --- /dev/null +++ b/src/api/app/views/webui2/webui/session/sso.html.haml @@ -0,0 +1,6 @@ +- @pagetitle = 'Sign In with SSO' + +.card + .card-body#loginform + .col-lg-6.pl-0 + = render partial: 'sso' diff --git a/src/api/app/views/webui2/webui/session/sso_confirm.html.haml b/src/api/app/views/webui2/webui/session/sso_confirm.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..04ea69daac387c75cafd12692bf4f086d0cce996 --- /dev/null +++ b/src/api/app/views/webui2/webui/session/sso_confirm.html.haml @@ -0,0 +1,17 @@ +- @pagetitle = 'First Login' + +.card + .card-body#loginform + .col-lg-6.pl-0 + - if can_register + %h3= @pagetitle + %p Since this is your first time you sign in, you need to choose your username. + = form_tag({ controller: 'session', action: 'sso_confirm', method: :post }, class: 'sign-up', autocomplete: 'off') do + .form-group + = label_tag 'login', 'Username:' + %abbr.text-danger{ title: 'required' } * + = text_field_tag 'login', @derived_username, placeholder: 'Username', autocomplete: 'off', class: 'form-control', required: true + = submit_tag('Confirm and Log In', class: 'btn btn-primary') + - else + %p Sorry, only existing users can sign in. + diff --git a/src/api/app/views/webui2/webui/user/_password_dialog.html.haml b/src/api/app/views/webui2/webui/user/_password_dialog.html.haml index e3b100b3092b1127e79d4ca92d306bf60f682ec7..534300e03ad038b9c0234634da715375b9098239 100644 --- a/src/api/app/views/webui2/webui/user/_password_dialog.html.haml +++ b/src/api/app/views/webui2/webui/user/_password_dialog.html.haml @@ -5,9 +5,11 @@ %h5.modal-title#branch-modal-label Change Your Password = form_tag(action: 'change_password') do .modal-body - .form-group - = label_tag :password, 'Current Password:' - = text_field_tag :password, nil, type: 'password', required: 'true', class: 'form-control' + - if User.session + - unless User.session.password_invalid? + .form-group + = label_tag :password, 'Current Password:' + = text_field_tag :password, nil, type: 'password', required: 'true', class: 'form-control' .form-group = label_tag :new_password, 'New Password:' = text_field_tag :new_password, nil, type: 'password', autocomplete: 'off', required: 'true', class: 'form-control' diff --git a/src/api/config/auth.yml.example b/src/api/config/auth.yml.example new file mode 100644 index 0000000000000000000000000000000000000000..c5a9ce375e591e97f097d45384a1bdfbfc8b2a16 --- /dev/null +++ b/src/api/config/auth.yml.example @@ -0,0 +1,8 @@ +fdo-gitlab: + strategy: gitlab + description: Freedesktop.org GitLab + scope: read_user openid profile email + client_id: hexhexhexhex + client_secret: hexhexhexhex + client_options: + site: https://gitlab.freedesktop.org/api/v4 diff --git a/src/api/config/initializers/omniauth.rb b/src/api/config/initializers/omniauth.rb new file mode 100644 index 0000000000000000000000000000000000000000..5d8f90823c8e0f9d72f08c17bf453e0614bb3479 --- /dev/null +++ b/src/api/config/initializers/omniauth.rb @@ -0,0 +1,21 @@ +OmniAuth.config.path_prefix = '/session/sso' + +path = Rails.root.join("config", "auth.yml") + +CONFIG['sso_auth'] = Hash.new + +if File.exist? path + begin + CONFIG['sso_auth'] = YAML.load_file(path) + rescue Exception + puts "Error while parsing config file #{path}" + end + + Rails.application.config.middleware.use OmniAuth::Builder do + CONFIG['sso_auth'].each do |name, options| + options[:name] = name + provider (options['strategy'] || name), options + options['description'] ||= OmniAuth::Utils.camelize(name) + end + end +end diff --git a/src/api/config/routes.rb b/src/api/config/routes.rb index d1b40af86d7b61e85982fc98aa5be74a6aea7eb8..7b0b648a0739297d7fd96195b55430f97707a0b9 100644 --- a/src/api/config/routes.rb +++ b/src/api/config/routes.rb @@ -424,6 +424,10 @@ OBSApi::Application.routes.draw do controller 'webui/session' do get 'session/new' => :new post 'session/create' => :create + get 'session/sso' => :sso, as: 'sso' + get 'session/sso/:provider/callback' => :sso_callback + get 'session/sso/:provider/confirm' => :sso_confirm, as: 'sso_confirm' + post 'session/sso/:provider/confirm' => :do_sso_confirm delete 'session/destroy' => :destroy end @@ -501,7 +505,6 @@ OBSApi::Application.routes.draw do match 'build/:project/:repository' => 'build#index', constraints: cons, via: [:get, :post] match 'build/:project' => 'build#project_index', constraints: cons, via: [:get, :post, :put] get 'build' => 'source#index' - ### /published # :arch can be also a ymp for a pattern :/