Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ gem 'newrelic_rpm', '~> 9.11'
gem 'pdfkit', '~> 0.8.7.3', require: false
gem 'pg', '~> 1.5', '>= 1.5.6'
gem 'pghero', '~> 3.5'
gem 'rack-attack', '~> 6.7', require: false
gem 'rack-attack', '~> 6.7'
gem 'rack-cors', '~> 2.0', '>= 2.0.2'
gem 'rack-timeout', '~> 0.7.0'
gem 'rack-mini-profiler', '~> 3.3', '>= 3.3.1'
Expand Down
4 changes: 1 addition & 3 deletions app/controllers/api/beta/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,7 @@ def present(hash)

private
def authenticate_user!
if doorkeeper_token
@current_user = User.find(doorkeeper_token.resource_owner_id)
end
@current_user = AccessTokenAuthentication.user_for(doorkeeper_token)

return if @current_user

Expand Down
1 change: 1 addition & 0 deletions app/controllers/api/v8/apidocs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ class ApidocsController < ActionController::Base
Api::V8::Core::SubmissionsController,
Api::V8::Core::Submissions::FeedbackController,
Api::V8::Core::Submissions::ReviewsController,
Api::V8::Application::UserAccessTokensController,
AvailablePoint,
AwardedPoint,
Course,
Expand Down
110 changes: 110 additions & 0 deletions app/controllers/api/v8/application/user_access_tokens_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# frozen_string_literal: true

module Api
module V8
module Application
# Issues a login token for a user to a trusted backend after a sign-in handoff verified
# elsewhere. Callable only by allow-listed client_credentials apps with the issuer scope.
class UserAccessTokensController < Api::V8::BaseController
include Swagger::Blocks

TOKEN_LIFETIME = 1.day

swagger_path '/api/v8/application/user_access_tokens' do
operation :post do
key :description, "Issues an access token for the given user, owned by the configured target application. Requires a client_credentials token of an allow-listed application with the #{AccessTokenAuthentication::ISSUER_SCOPE} scope. Administrators, teachers and assistants are refused. The token cannot change the user's email or password or delete the account. It expires after #{TOKEN_LIFETIME.to_i} seconds."
key :operationId, 'issueUserAccessToken'
key :produces, ['application/json']
key :tags, ['user']
parameter do
key :name, :user_id
key :in, :body
key :description, "The user's id"
key :required, true
schema do
key :'$ref', :UserAccessTokenInput
end
end
response 200 do
key :description, 'The issued access token'
schema do
key :'$ref', :UserAccessToken
end
end
response 400, '$ref': '#/responses/error'
response 401, '$ref': '#/responses/error'
response 403, '$ref': '#/responses/error'
response 404, '$ref': '#/responses/error'
end
end

swagger_schema :UserAccessTokenInput do
key :required, [:user_id]
property :user_id, type: :integer, example: 1
end

swagger_schema :UserAccessToken do
key :required, %i[access_token token_type created_at expires_in]
property :access_token, type: :string
property :token_type, type: :string, example: 'bearer'
property :created_at, type: :integer, description: 'Unix timestamp'
property :expires_in, type: :integer, description: 'Seconds from created_at', example: 86_400
end

skip_before_action :authenticate_user!
skip_authorization_check

def create
return respond_unauthorized unless doorkeeper_token&.accessible?
return respond_forbidden unless authorized_issuer?

return respond_with_error('user_id must be an integer', 400) unless params[:user_id].to_s.match?(/\A\d+\z/)

user = User.find_by(id: params[:user_id].to_i)
return respond_not_found('User not found') unless user
return respond_forbidden('Tokens are not issued for administrators, teachers or assistants') if user.privileged?

target_application = Doorkeeper::Application.find_by(uid: Rails.configuration.x.user_access_token_target_application_uid)
return respond_with_error('Target application is not configured', 500) unless target_application

token = Doorkeeper::AccessToken.create_for(
application: target_application,
resource_owner: user,
scopes: "public #{AccessTokenAuthentication::HANDOFF_SCOPE}",
expires_in: TOKEN_LIFETIME.to_i
)
Rails.logger.info("Issued user access token: issuer application uid=#{doorkeeper_token.application.uid} user_id=#{user.id}")

response.headers['Cache-Control'] = 'no-store'
response.headers['Pragma'] = 'no-cache'

render json: {
access_token: token.plaintext_token,
token_type: 'bearer',
created_at: token.created_at.to_i,
expires_in: token.expires_in
}
end

private
# Their credentials are served publicly by the credentials endpoint.
def public_client_names
Array(SiteSetting.value('valid_clients')).filter_map { |client| client['name'] }
end

def authorized_issuer?
issuer_uids = Rails.configuration.x.user_access_token_issuer_application_uids
# resource_owner_id is set on user tokens, which never qualify even from an allowed application.
return false unless AccessTokenAuthentication.issuer_token?(doorkeeper_token)

application = doorkeeper_token.application
# The target is a frontend client, so its credentials must not be usable to issue tokens.
issuer_uids.include?(application&.uid) &&
application.confidential? &&
application.uid != Rails.configuration.x.user_access_token_target_application_uid &&
public_client_names.exclude?(application.name)
end
end
end
end
end
5 changes: 3 additions & 2 deletions app/controllers/api/v8/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@ def present(hash)
def authenticate_user!
return @current_user if @current_user
if doorkeeper_token
@current_user ||= User.find_by(id: doorkeeper_token.resource_owner_id)
raise 'Invalid token' unless @current_user
@current_user = AccessTokenAuthentication.user_for(doorkeeper_token)
return respond_unauthorized('Invalid token') unless @current_user
@auth_source = :handoff_token if AccessTokenAuthentication.handoff?(doorkeeper_token)
elsif Rails.configuration.x.accept_courses_mooc_fi_tokens
@current_user = CoursesMoocFiAuthentication.user_for(request)
@auth_source = :courses_mooc_fi_token if @current_user
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/api/v8/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ def update_email
if new_email.blank?
@user.errors.add(:email, 'needed')
elsif @user.email.casecmp(new_email) != 0
authorize! :update_credentials, @user
@user.email = new_email
@user.email_verified = false
UserMailer.email_confirmation(@user, params[:origin], params[:language]).deliver_now
Expand All @@ -327,6 +328,7 @@ def set_password

def maybe_update_password
if params[:old_password].present? && params[:password].present?
authorize! :update_credentials, @user
if @user.managed_externally?
unless @user.update_password_via_courses_mooc_fi(params[:old_password], params[:password])
@user.errors.add(:password, 'could not be changed. Please check your current password and try again.')
Expand Down
8 changes: 3 additions & 5 deletions app/controllers/auths_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,9 @@ class AuthsController < ApplicationController

def show
if params[:username].present? && params[:session_id].present?
user = User.find_by(login: params[:username])
user ||= User.find_by('lower(email) = ?', params[:username].downcase)
user = User.find_by_login_or_email(params[:username])
# Allows using oauth2 tokens of the new api for authenticating
res = if user && Doorkeeper::AccessToken.find_by(resource_owner_id: user.id, token: params[:session_id])
res = if user && AccessTokenAuthentication.user_for(Doorkeeper::AccessToken.by_token(params[:session_id])) == user
OK_MESSAGE
elsif user && find_session_by_id(params[:session_id])&.belongs_to?(user)
OK_MESSAGE
Expand All @@ -23,8 +22,7 @@ def show
return render plain: res
end

user = User.find_by(login: params[:username])
user ||= User.find_by('lower(email) = ?', params[:username].downcase)
user = User.find_by_login_or_email(params[:username])
msg = if user && params[:password].present? && user.has_password?(params[:password])
OK_MESSAGE
else
Expand Down
6 changes: 3 additions & 3 deletions app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def update

def confirm_email
language = params[:language]
VerificationToken.unscoped.email.find_by!(user_id: params[:user_id], token: params[:id])
VerificationToken.unscoped.email.find_by!(user_id: params[:user_id], token: params[:token])
redirect_path = root_url
redirect_path = "https://course.elementsofai.com/#{language && language != "en" ? "#{language}/" : ''}email-verification" if params[:origin] && params[:origin].start_with?('elements_of_ai')
redirect_path = "https://buildingai.elementsofai.com/#{language && language != "en" ? "#{language}/" : ''}email-verified" if params[:origin] && params[:origin].start_with?('building_ai')
Expand All @@ -119,7 +119,7 @@ def send_verification_email

def verify_destroying_user
@user = authenticate_current_user_destroy
VerificationToken.delete_user.find_by!(user: @user, token: params[:id])
VerificationToken.delete_user.find_by!(user: @user, token: params[:token])
end

def destroy_user
Expand All @@ -134,7 +134,7 @@ def destroy_user
redirect_to verify_destroying_user_url, alert: failed_authentication_alert(status)
return
end
VerificationToken.delete_user.find_by!(user: user, token: params[:id])
VerificationToken.delete_user.find_by!(user: user, token: params[:token])
sign_out if current_user == user
email = user.email
username = user.login
Expand Down
2 changes: 1 addition & 1 deletion app/helpers/sessions_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def user_from_session
end

def user_from_basic_auth
if request&.authorization
if request && ActionController::HttpAuthentication::Basic.has_basic_credentials?(request)
username, password = ActionController::HttpAuthentication::Basic.user_name_and_password(request)
User.authenticate(username, password) if username && password
end
Expand Down
20 changes: 13 additions & 7 deletions app/models/ability.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
class Ability
include CanCan::Ability

# +auth_source+ is :courses_mooc_fi_token when the user authenticated with a courses.mooc.fi
# access token; account changes are then denied to everyone, administrators included.
# +auth_source+ restricts account changes for tokens that are not the user's own login:
# :courses_mooc_fi_token denies all, administrators included; :handoff_token allows profile
# edits but no +:update_credentials+ (email, password) and no account deletion.
def initialize(user, auth_source: nil)
alias_action :update_credentials, to: :update

if user.administrator?
can :manage, :all
can :create, Course
Expand Down Expand Up @@ -291,10 +294,13 @@ def initialize(user, auth_source: nil)
end
end

return unless auth_source == :courses_mooc_fi_token

# The token is scoped to exercise services; it must not take over or delete the account.
cannot :update, User
cannot :destroy, User
case auth_source
when :courses_mooc_fi_token
# The token is scoped to exercise services; it must not take over or delete the account.
cannot :update, User
cannot :destroy, User
when :handoff_token
cannot %i[update_credentials destroy], User
end
end
end
18 changes: 14 additions & 4 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,14 @@ def has_password?(submitted_password)
result
end

# The user a typed username names, matched against login exactly and email case-insensitively.
def self.find_by_login_or_email(username)
username = username&.strip
return nil if username.blank?

find_by(login: username) || find_by('lower(email) = ?', username.downcase)
end

# The user when the password is accepted, nil otherwise. Use .authenticate_with_status when the
# outcome is shown to the user, so that a delegation failure is not reported as a wrong password.
def self.authenticate(login, submitted_password)
Expand All @@ -179,10 +187,7 @@ def self.authenticate(login, submitted_password)
# cannot be delegated at all and only an admin can repair it. A found user is returned whatever
# the status, so sign in only on :accepted.
def self.authenticate_with_status(login, submitted_password)
return [nil, :rejected] unless login
login = login.strip
user = find_by(login: login)
user ||= find_by('lower(email) = ?', login.downcase)
user = find_by_login_or_email(login)
return [nil, :rejected] if user.nil?

return [user, user.courses_mooc_fi_authentication_status(submitted_password)] if user.managed_externally?
Expand Down Expand Up @@ -480,6 +485,11 @@ def <=>(other)
login.downcase <=> other.login.downcase
end

# Administrators, teachers and assistants, whose accounts handoff tokens must never act as.
def privileged?
administrator? || teacherships.exists? || assistantships.exists?
end

def teacher?(organization)
organizations.include? organization
end
Expand Down
28 changes: 28 additions & 0 deletions app/services/access_token_authentication.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

# Resolves the user behind a Doorkeeper access token, for every endpoint that accepts one.
module AccessTokenAuthentication
# Marks tokens issued by Api::V8::Application::UserAccessTokensController.
HANDOFF_SCOPE = 'handoff'
# Lets a client_credentials token call Api::V8::Application::UserAccessTokensController.
ISSUER_SCOPE = 'issue_user_access_tokens'

# The token's owner, or nil when the token is revoked or expired, has no owner, or is a handoff
# token of a user who became privileged after it was issued.
def self.user_for(token)
return nil unless token&.accessible?

user = User.find_by(id: token.resource_owner_id)
user unless user.nil? || (handoff?(token) && user.privileged?)
end

# Whether the token lets its client call UserAccessTokensController; the client still has to be
# an allow-listed issuer.
def self.issuer_token?(token)
token.resource_owner_id.nil? && token.acceptable?([ISSUER_SCOPE])
end

def self.handoff?(token)
token.includes_scope?(HANDOFF_SCOPE)
end
end
2 changes: 1 addition & 1 deletion app/views/password_reset_keys/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<p>Resetting password for <%= @user.email %></p>

<%= form_tag reset_password_path(key: @key.token), method: :delete do %>
<%= form_tag reset_password_path(@key.token), method: :delete do %>
<%= labeled_field('New password: ', password_field_tag(:password)) %>
<%= labeled_field('Confirm new password: ', password_field_tag(:password_confirmation)) %>
<%= submit_tag 'Set password' %>
Expand Down
4 changes: 3 additions & 1 deletion config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,16 @@ class Application < Rails::Application
# config.i18n.default_locale = :de

# Configure sensitive parameters which will be filtered from the log file.
config.filter_parameters += %i[password session_id api_password submission_file return_file test_output stdout stderr vm_log]
config.filter_parameters += %i[password session_id api_password submission_file return_file test_output stdout stderr vm_log client_secret access_token refresh_token bearer_token id_token id_token_hint code_verifier] + [/\A(token|code)\z/]

# Do not swallow errors in after_commit/after_rollback callbacks.
# config.eager_load_paths << Rails.root.join('lib')

config.relative_url_root = SiteSetting.value('base_path')

config.x.accept_courses_mooc_fi_tokens = ENV['ACCEPT_COURSES_MOOC_FI_TOKENS'] == 'true'
config.x.user_access_token_issuer_application_uids = ENV.fetch('USER_ACCESS_TOKEN_ISSUER_APPLICATION_UIDS', '').split(',').map(&:strip).reject(&:empty?)
config.x.user_access_token_target_application_uid = ENV['USER_ACCESS_TOKEN_TARGET_APPLICATION_UID'].presence

config.middleware.insert_before 0, Rack::Cors, debug: true, logger: (-> { Rails.logger }) do
allow do
Expand Down
6 changes: 6 additions & 0 deletions config/initializers/doorkeeper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@
# access_token_expires_in 2.hours
access_token_expires_in nil

# Issuer tokens are re-fetched by their clients; every other token stays non-expiring.
custom_access_token_expires_in do |context|
1.hour.to_i if context.grant_type == Doorkeeper::OAuth::CLIENT_CREDENTIALS && context.scopes&.exists?(AccessTokenAuthentication::ISSUER_SCOPE)
end

# Assign a custom TTL for implicit grants.
# custom_access_token_expires_in do |oauth_client|
# oauth_client.application.additional_settings.implicit_oauth_expiration
Expand Down Expand Up @@ -66,6 +71,7 @@
optional_scopes %I[
internal_beta_all
openid
issue_user_access_tokens
]

# Change the way client credentials are retrieved from the request object.
Expand Down
Loading
Loading