FEATURE: Delegated authentication via user api keys (#7272)
This commit is contained in:
parent
25feb287b8
commit
fdf4145d4b
|
@ -0,0 +1,30 @@
|
||||||
|
export default {
|
||||||
|
name: "strip-mobile-app-url-params",
|
||||||
|
|
||||||
|
initialize() {
|
||||||
|
let queryStrings = window.location.search;
|
||||||
|
|
||||||
|
if (queryStrings.indexOf("user_api_public_key") !== -1) {
|
||||||
|
let params = queryStrings.startsWith("?")
|
||||||
|
? queryStrings.substr(1).split("&")
|
||||||
|
: [];
|
||||||
|
|
||||||
|
params = params.filter(param => {
|
||||||
|
return (
|
||||||
|
!param.startsWith("user_api_public_key=") &&
|
||||||
|
!param.startsWith("auth_redirect=")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
queryStrings = params.length > 0 ? `?${params.join("&")}` : "";
|
||||||
|
|
||||||
|
if (window.history && window.history.replaceState) {
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
`${location.pathname}${queryStrings}${location.hash}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
|
@ -731,6 +731,26 @@ class ApplicationController < ActionController::Base
|
||||||
redirect_to path(redirect_path)
|
redirect_to path(redirect_path)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Used by clients authenticated via user API.
|
||||||
|
# Redirects to provided URL scheme if
|
||||||
|
# - request uses a valid public key and auth_redirect scheme
|
||||||
|
# - one_time_password scope is allowed
|
||||||
|
if !current_user &&
|
||||||
|
params.has_key?(:user_api_public_key) &&
|
||||||
|
params.has_key?(:auth_redirect)
|
||||||
|
begin
|
||||||
|
OpenSSL::PKey::RSA.new(params[:user_api_public_key])
|
||||||
|
rescue OpenSSL::PKey::RSAError
|
||||||
|
return render plain: I18n.t("user_api_key.invalid_public_key")
|
||||||
|
end
|
||||||
|
|
||||||
|
if UserApiKey.invalid_auth_redirect?(params[:auth_redirect])
|
||||||
|
return render plain: I18n.t("user_api_key.invalid_auth_redirect")
|
||||||
|
end
|
||||||
|
redirect_to("#{params[:auth_redirect]}?otp=true") if UserApiKey.allowed_scopes.superset?(Set.new(["one_time_password"]))
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def block_if_readonly_mode
|
def block_if_readonly_mode
|
||||||
|
|
|
@ -12,7 +12,7 @@ class SessionController < ApplicationController
|
||||||
before_action :check_local_login_allowed, only: %i(create forgot_password email_login)
|
before_action :check_local_login_allowed, only: %i(create forgot_password email_login)
|
||||||
before_action :rate_limit_login, only: %i(create email_login)
|
before_action :rate_limit_login, only: %i(create email_login)
|
||||||
skip_before_action :redirect_to_login_if_required
|
skip_before_action :redirect_to_login_if_required
|
||||||
skip_before_action :preload_json, :check_xhr, only: %i(sso sso_login sso_provider destroy email_login)
|
skip_before_action :preload_json, :check_xhr, only: %i(sso sso_login sso_provider destroy email_login one_time_password)
|
||||||
|
|
||||||
ACTIVATE_USER_KEY = "activate_user"
|
ACTIVATE_USER_KEY = "activate_user"
|
||||||
|
|
||||||
|
@ -321,6 +321,20 @@ class SessionController < ApplicationController
|
||||||
render layout: 'no_ember'
|
render layout: 'no_ember'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def one_time_password
|
||||||
|
otp_username = $redis.get "otp_#{params[:token]}"
|
||||||
|
|
||||||
|
if otp_username && user = User.find_by_username(otp_username)
|
||||||
|
log_on_user(user)
|
||||||
|
$redis.del "otp_#{params[:token]}"
|
||||||
|
return redirect_to path("/")
|
||||||
|
else
|
||||||
|
@error = I18n.t('user_api_key.invalid_token')
|
||||||
|
end
|
||||||
|
|
||||||
|
render layout: 'no_ember'
|
||||||
|
end
|
||||||
|
|
||||||
def forgot_password
|
def forgot_password
|
||||||
params.require(:login)
|
params.require(:login)
|
||||||
|
|
||||||
|
|
|
@ -2,11 +2,11 @@ class UserApiKeysController < ApplicationController
|
||||||
|
|
||||||
layout 'no_ember'
|
layout 'no_ember'
|
||||||
|
|
||||||
requires_login only: [:create, :revoke, :undo_revoke]
|
requires_login only: [:create, :create_otp, :revoke, :undo_revoke]
|
||||||
skip_before_action :redirect_to_login_if_required, only: [:new]
|
skip_before_action :redirect_to_login_if_required, only: [:new, :otp]
|
||||||
skip_before_action :check_xhr, :preload_json
|
skip_before_action :check_xhr, :preload_json
|
||||||
|
|
||||||
AUTH_API_VERSION ||= 3
|
AUTH_API_VERSION ||= 4
|
||||||
|
|
||||||
def new
|
def new
|
||||||
|
|
||||||
|
@ -51,17 +51,15 @@ class UserApiKeysController < ApplicationController
|
||||||
|
|
||||||
require_params
|
require_params
|
||||||
|
|
||||||
if params.key?(:auth_redirect) && SiteSetting.allowed_user_api_auth_redirects
|
if params.key?(:auth_redirect)
|
||||||
.split('|')
|
raise Discourse::InvalidAccess if UserApiKey.invalid_auth_redirect?(params[:auth_redirect])
|
||||||
.none? { |u| WildcardUrlChecker.check_url(u, params[:auth_redirect]) }
|
|
||||||
|
|
||||||
raise Discourse::InvalidAccess
|
|
||||||
end
|
end
|
||||||
|
|
||||||
raise Discourse::InvalidAccess unless meets_tl?
|
raise Discourse::InvalidAccess unless meets_tl?
|
||||||
|
|
||||||
validate_params
|
validate_params
|
||||||
@application_name = params[:application_name]
|
@application_name = params[:application_name]
|
||||||
|
scopes = params[:scopes].split(",")
|
||||||
|
|
||||||
# destroy any old keys we had
|
# destroy any old keys we had
|
||||||
UserApiKey.where(user_id: current_user.id, client_id: params[:client_id]).destroy_all
|
UserApiKey.where(user_id: current_user.id, client_id: params[:client_id]).destroy_all
|
||||||
|
@ -72,7 +70,7 @@ class UserApiKeysController < ApplicationController
|
||||||
user_id: current_user.id,
|
user_id: current_user.id,
|
||||||
push_url: params[:push_url],
|
push_url: params[:push_url],
|
||||||
key: SecureRandom.hex,
|
key: SecureRandom.hex,
|
||||||
scopes: params[:scopes].split(",")
|
scopes: scopes
|
||||||
)
|
)
|
||||||
|
|
||||||
# we keep the payload short so it encrypts easily with public key
|
# we keep the payload short so it encrypts easily with public key
|
||||||
|
@ -87,8 +85,15 @@ class UserApiKeysController < ApplicationController
|
||||||
public_key = OpenSSL::PKey::RSA.new(params[:public_key])
|
public_key = OpenSSL::PKey::RSA.new(params[:public_key])
|
||||||
@payload = Base64.encode64(public_key.public_encrypt(@payload))
|
@payload = Base64.encode64(public_key.public_encrypt(@payload))
|
||||||
|
|
||||||
|
if scopes.include?("one_time_password")
|
||||||
|
# encrypt one_time_password separately to bypass 128 chars encryption limit
|
||||||
|
otp_payload = one_time_password(public_key, current_user.username)
|
||||||
|
end
|
||||||
|
|
||||||
if params[:auth_redirect]
|
if params[:auth_redirect]
|
||||||
redirect_to("#{params[:auth_redirect]}?payload=#{CGI.escape(@payload)}")
|
redirect_path = "#{params[:auth_redirect]}?payload=#{CGI.escape(@payload)}"
|
||||||
|
redirect_path << "&oneTimePassword=#{CGI.escape(otp_payload)}" if scopes.include?("one_time_password")
|
||||||
|
redirect_to(redirect_path)
|
||||||
else
|
else
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html { render :show }
|
format.html { render :show }
|
||||||
|
@ -100,6 +105,38 @@ class UserApiKeysController < ApplicationController
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def otp
|
||||||
|
require_params_otp
|
||||||
|
|
||||||
|
unless current_user
|
||||||
|
cookies[:destination_url] = request.fullpath
|
||||||
|
|
||||||
|
if SiteSetting.enable_sso?
|
||||||
|
redirect_to path('/session/sso')
|
||||||
|
else
|
||||||
|
redirect_to path('/login')
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
@application_name = params[:application_name]
|
||||||
|
@public_key = params[:public_key]
|
||||||
|
@auth_redirect = params[:auth_redirect]
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_otp
|
||||||
|
require_params_otp
|
||||||
|
|
||||||
|
raise Discourse::InvalidAccess if UserApiKey.invalid_auth_redirect?(params[:auth_redirect])
|
||||||
|
raise Discourse::InvalidAccess unless meets_tl?
|
||||||
|
|
||||||
|
public_key = OpenSSL::PKey::RSA.new(params[:public_key])
|
||||||
|
otp_payload = one_time_password(public_key, current_user.username)
|
||||||
|
|
||||||
|
redirect_path = "#{params[:auth_redirect]}?oneTimePassword=#{CGI.escape(otp_payload)}"
|
||||||
|
redirect_to(redirect_path)
|
||||||
|
end
|
||||||
|
|
||||||
def revoke
|
def revoke
|
||||||
revoke_key = find_key if params[:id]
|
revoke_key = find_key if params[:id]
|
||||||
|
|
||||||
|
@ -141,15 +178,30 @@ class UserApiKeysController < ApplicationController
|
||||||
|
|
||||||
def validate_params
|
def validate_params
|
||||||
requested_scopes = Set.new(params[:scopes].split(","))
|
requested_scopes = Set.new(params[:scopes].split(","))
|
||||||
|
|
||||||
raise Discourse::InvalidAccess unless UserApiKey.allowed_scopes.superset?(requested_scopes)
|
raise Discourse::InvalidAccess unless UserApiKey.allowed_scopes.superset?(requested_scopes)
|
||||||
|
|
||||||
# our pk has got to parse
|
# our pk has got to parse
|
||||||
OpenSSL::PKey::RSA.new(params[:public_key])
|
OpenSSL::PKey::RSA.new(params[:public_key])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def require_params_otp
|
||||||
|
[
|
||||||
|
:public_key,
|
||||||
|
:auth_redirect,
|
||||||
|
:application_name
|
||||||
|
].each { |p| params.require(p) }
|
||||||
|
end
|
||||||
|
|
||||||
def meets_tl?
|
def meets_tl?
|
||||||
current_user.staff? || current_user.trust_level >= SiteSetting.min_trust_level_for_user_api_key
|
current_user.staff? || current_user.trust_level >= SiteSetting.min_trust_level_for_user_api_key
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def one_time_password(public_key, username)
|
||||||
|
raise Discourse::InvalidAccess unless UserApiKey.allowed_scopes.superset?(Set.new(["one_time_password"]))
|
||||||
|
|
||||||
|
otp = SecureRandom.hex
|
||||||
|
$redis.setex "otp_#{otp}", 10.minutes, username
|
||||||
|
|
||||||
|
Base64.encode64(public_key.public_encrypt(otp))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,6 +5,7 @@ class UserApiKey < ActiveRecord::Base
|
||||||
write: [:get, :post, :patch, :put, :delete],
|
write: [:get, :post, :patch, :put, :delete],
|
||||||
message_bus: [[:post, 'message_bus']],
|
message_bus: [[:post, 'message_bus']],
|
||||||
push: nil,
|
push: nil,
|
||||||
|
one_time_password: nil,
|
||||||
notifications: [[:post, 'message_bus'], [:get, 'notifications#index'], [:put, 'notifications#mark_read']],
|
notifications: [[:post, 'message_bus'], [:get, 'notifications#index'], [:put, 'notifications#mark_read']],
|
||||||
session_info: [
|
session_info: [
|
||||||
[:get, 'session#current'],
|
[:get, 'session#current'],
|
||||||
|
@ -63,6 +64,11 @@ class UserApiKey < ActiveRecord::Base
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.invalid_auth_redirect?(auth_redirect)
|
||||||
|
return SiteSetting.allowed_user_api_auth_redirects
|
||||||
|
.split('|')
|
||||||
|
.none? { |u| WildcardUrlChecker.check_url(u, auth_redirect) }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# == Schema Information
|
# == Schema Information
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
<%if @error%>
|
||||||
|
<div class='alert alert-error'>
|
||||||
|
<%= @error %>
|
||||||
|
</div>
|
||||||
|
<%end%>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<h1><%= t("user_api_key.otp_description", application_name: @application_name) %></h1>
|
||||||
|
<div class='authorize-api-key'>
|
||||||
|
<%= form_tag(user_api_key_otp_path) do %>
|
||||||
|
<%= hidden_field_tag 'application_name', @application_name %>
|
||||||
|
<%= hidden_field_tag 'public_key', @public_key%>
|
||||||
|
<%= hidden_field_tag('auth_redirect', @auth_redirect) %>
|
||||||
|
<%= submit_tag t('user_api_key.authorize'), class: 'btn btn-danger' %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
|
@ -912,6 +912,7 @@ en:
|
||||||
read_write: "read/write"
|
read_write: "read/write"
|
||||||
description: '"%{application_name}" is requesting the following access to your account:'
|
description: '"%{application_name}" is requesting the following access to your account:'
|
||||||
instructions: 'We just generated a new user API key for you to use with "%{application_name}", please paste the following key into your application:'
|
instructions: 'We just generated a new user API key for you to use with "%{application_name}", please paste the following key into your application:'
|
||||||
|
otp_description: 'Would you like to allow "%{application_name}" to access this site?'
|
||||||
no_trust_level: "Sorry, you do not have the required trust level to access the user API"
|
no_trust_level: "Sorry, you do not have the required trust level to access the user API"
|
||||||
generic_error: "Sorry, we are unable to issue user API keys, this feature may be disabled by the site admin"
|
generic_error: "Sorry, we are unable to issue user API keys, this feature may be disabled by the site admin"
|
||||||
scopes:
|
scopes:
|
||||||
|
@ -921,7 +922,10 @@ en:
|
||||||
session_info: "Read user session info"
|
session_info: "Read user session info"
|
||||||
read: "Read all"
|
read: "Read all"
|
||||||
write: "Write all"
|
write: "Write all"
|
||||||
|
one_time_password: "Create a one-time login token"
|
||||||
|
invalid_public_key: "Sorry, the public key is invalid."
|
||||||
|
invalid_auth_redirect: "Sorry, this auth_redirect host is not allowed."
|
||||||
|
invalid_token: "Missing, invalid or expired token."
|
||||||
flags:
|
flags:
|
||||||
errors:
|
errors:
|
||||||
already_handled: "Flag was already handled"
|
already_handled: "Flag was already handled"
|
||||||
|
|
|
@ -331,6 +331,7 @@ Discourse::Application.routes.draw do
|
||||||
get "session/csrf" => "session#csrf"
|
get "session/csrf" => "session#csrf"
|
||||||
get "session/email-login/:token" => "session#email_login"
|
get "session/email-login/:token" => "session#email_login"
|
||||||
post "session/email-login/:token" => "session#email_login"
|
post "session/email-login/:token" => "session#email_login"
|
||||||
|
get "session/otp/:token" => "session#one_time_password", constraints: { token: /[0-9a-f]+/ }
|
||||||
get "composer_messages" => "composer_messages#index"
|
get "composer_messages" => "composer_messages#index"
|
||||||
post "composer/parse_html" => "composer#parse_html"
|
post "composer/parse_html" => "composer#parse_html"
|
||||||
|
|
||||||
|
@ -841,6 +842,8 @@ Discourse::Application.routes.draw do
|
||||||
post "/user-api-key" => "user_api_keys#create"
|
post "/user-api-key" => "user_api_keys#create"
|
||||||
post "/user-api-key/revoke" => "user_api_keys#revoke"
|
post "/user-api-key/revoke" => "user_api_keys#revoke"
|
||||||
post "/user-api-key/undo-revoke" => "user_api_keys#undo_revoke"
|
post "/user-api-key/undo-revoke" => "user_api_keys#undo_revoke"
|
||||||
|
get "/user-api-key/otp" => "user_api_keys#otp"
|
||||||
|
post "/user-api-key/otp" => "user_api_keys#create_otp"
|
||||||
|
|
||||||
get "/safe-mode" => "safe_mode#index"
|
get "/safe-mode" => "safe_mode#index"
|
||||||
post "/safe-mode" => "safe_mode#enter", as: "safe_mode_enter"
|
post "/safe-mode" => "safe_mode#enter", as: "safe_mode_enter"
|
||||||
|
|
|
@ -1109,7 +1109,7 @@ files:
|
||||||
regex: '^((https?:)?\/)?\/.+[^\/]'
|
regex: '^((https?:)?\/)?\/.+[^\/]'
|
||||||
shadowed_by_global: true
|
shadowed_by_global: true
|
||||||
restrict_letter_avatar_colors:
|
restrict_letter_avatar_colors:
|
||||||
default: ''
|
default: ""
|
||||||
type: list
|
type: list
|
||||||
list_type: compact
|
list_type: compact
|
||||||
validator: "ColorListValidator"
|
validator: "ColorListValidator"
|
||||||
|
@ -1231,11 +1231,11 @@ security:
|
||||||
enforce_second_factor:
|
enforce_second_factor:
|
||||||
client: true
|
client: true
|
||||||
type: enum
|
type: enum
|
||||||
default: 'no'
|
default: "no"
|
||||||
choices:
|
choices:
|
||||||
- 'no'
|
- "no"
|
||||||
- 'staff'
|
- "staff"
|
||||||
- 'all'
|
- "all"
|
||||||
force_https:
|
force_https:
|
||||||
default: false
|
default: false
|
||||||
shadowed_by_global: true
|
shadowed_by_global: true
|
||||||
|
@ -1919,7 +1919,7 @@ user_api:
|
||||||
allow_user_api_keys:
|
allow_user_api_keys:
|
||||||
default: true
|
default: true
|
||||||
allow_user_api_key_scopes:
|
allow_user_api_key_scopes:
|
||||||
default: "read|write|message_bus|push|notifications|session_info"
|
default: "read|write|message_bus|push|notifications|session_info|one_time_password"
|
||||||
type: list
|
type: list
|
||||||
max_api_keys_per_user:
|
max_api_keys_per_user:
|
||||||
default: 10
|
default: 10
|
||||||
|
|
|
@ -345,6 +345,61 @@ RSpec.describe ApplicationController do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'Delegated auth' do
|
||||||
|
let :public_key do
|
||||||
|
<<~TXT
|
||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDh7BS7Ey8hfbNhlNAW/47pqT7w
|
||||||
|
IhBz3UyBYzin8JurEQ2pY9jWWlY8CH147KyIZf1fpcsi7ZNxGHeDhVsbtUKZxnFV
|
||||||
|
p16Op3CHLJnnJKKBMNdXMy0yDfCAHZtqxeBOTcCo1Vt/bHpIgiK5kmaekyXIaD0n
|
||||||
|
w0z/BYpOgZ8QwnI5ZwIDAQAB
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
|
TXT
|
||||||
|
end
|
||||||
|
|
||||||
|
let :args do
|
||||||
|
{
|
||||||
|
auth_redirect: 'http://no-good.com',
|
||||||
|
user_api_public_key: "not-a-valid-public-key"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'disallows invalid public_key param' do
|
||||||
|
args[:auth_redirect] = "discourse://auth_redirect"
|
||||||
|
get "/latest", params: args
|
||||||
|
|
||||||
|
expect(response.body).to eq(I18n.t("user_api_key.invalid_public_key"))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not allow invalid auth_redirect' do
|
||||||
|
args[:user_api_public_key] = public_key
|
||||||
|
get "/latest", params: args
|
||||||
|
|
||||||
|
expect(response.body).to eq(I18n.t("user_api_key.invalid_auth_redirect"))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not redirect if one_time_password scope is disallowed' do
|
||||||
|
SiteSetting.allow_user_api_key_scopes = "read|write"
|
||||||
|
args[:user_api_public_key] = public_key
|
||||||
|
args[:auth_redirect] = "discourse://auth_redirect"
|
||||||
|
|
||||||
|
get "/latest", params: args
|
||||||
|
|
||||||
|
expect(response.status).to_not eq(302)
|
||||||
|
expect(response).to_not redirect_to("#{args[:auth_redirect]}?otp=true")
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'redirects correctly with valid params' do
|
||||||
|
args[:user_api_public_key] = public_key
|
||||||
|
args[:auth_redirect] = "discourse://auth_redirect"
|
||||||
|
|
||||||
|
get "/categories", params: args
|
||||||
|
|
||||||
|
expect(response.status).to eq(302)
|
||||||
|
expect(response).to redirect_to("#{args[:auth_redirect]}?otp=true")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe 'Content Security Policy' do
|
describe 'Content Security Policy' do
|
||||||
it 'is enabled by SiteSettings' do
|
it 'is enabled by SiteSettings' do
|
||||||
SiteSetting.content_security_policy = false
|
SiteSetting.content_security_policy = false
|
||||||
|
|
|
@ -1288,6 +1288,45 @@ RSpec.describe SessionController do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '#one_time_password' do
|
||||||
|
context 'missing token' do
|
||||||
|
it 'returns the right response' do
|
||||||
|
get "/session/otp"
|
||||||
|
expect(response.status).to eq(404)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'invalid token' do
|
||||||
|
it 'returns the right response' do
|
||||||
|
get "/session/otp/asd1231dasd123"
|
||||||
|
|
||||||
|
expect(response.status).to eq(404)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when token is valid' do
|
||||||
|
it 'should authenticate user and delete token' do
|
||||||
|
user = Fabricate(:user)
|
||||||
|
|
||||||
|
get "/session/current.json"
|
||||||
|
expect(response.status).to eq(404)
|
||||||
|
|
||||||
|
token = SecureRandom.hex
|
||||||
|
$redis.setex "otp_#{token}", 10.minutes, user.username
|
||||||
|
|
||||||
|
get "/session/otp/#{token}"
|
||||||
|
|
||||||
|
expect(response.status).to eq(302)
|
||||||
|
expect(response).to redirect_to("/")
|
||||||
|
expect($redis.get("otp_#{token}")).to eq(nil)
|
||||||
|
|
||||||
|
get "/session/current.json"
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
describe '#forgot_password' do
|
describe '#forgot_password' do
|
||||||
it 'raises an error without a username parameter' do
|
it 'raises an error without a username parameter' do
|
||||||
post "/session/forgot_password.json"
|
post "/session/forgot_password.json"
|
||||||
|
|
|
@ -48,7 +48,7 @@ describe UserApiKeysController do
|
||||||
it "supports a head request cleanly" do
|
it "supports a head request cleanly" do
|
||||||
head "/user-api-key/new"
|
head "/user-api-key/new"
|
||||||
expect(response.status).to eq(200)
|
expect(response.status).to eq(200)
|
||||||
expect(response.headers["Auth-Api-Version"]).to eq("3")
|
expect(response.headers["Auth-Api-Version"]).to eq("4")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -156,7 +156,7 @@ describe UserApiKeysController do
|
||||||
|
|
||||||
expect(parsed["nonce"]).to eq(args[:nonce])
|
expect(parsed["nonce"]).to eq(args[:nonce])
|
||||||
expect(parsed["push"]).to eq(false)
|
expect(parsed["push"]).to eq(false)
|
||||||
expect(parsed["api"]).to eq(3)
|
expect(parsed["api"]).to eq(4)
|
||||||
|
|
||||||
key = user.user_api_keys.first
|
key = user.user_api_keys.first
|
||||||
expect(key.scopes).to include("push")
|
expect(key.scopes).to include("push")
|
||||||
|
@ -168,7 +168,7 @@ describe UserApiKeysController do
|
||||||
SiteSetting.allowed_user_api_auth_redirects = args[:auth_redirect]
|
SiteSetting.allowed_user_api_auth_redirects = args[:auth_redirect]
|
||||||
SiteSetting.allowed_user_api_push_urls = "https://push.it/here"
|
SiteSetting.allowed_user_api_push_urls = "https://push.it/here"
|
||||||
|
|
||||||
args[:scopes] = "push,notifications,message_bus,session_info"
|
args[:scopes] = "push,notifications,message_bus,session_info,one_time_password"
|
||||||
args[:push_url] = "https://push.it/here"
|
args[:push_url] = "https://push.it/here"
|
||||||
|
|
||||||
user = Fabricate(:user, trust_level: 0)
|
user = Fabricate(:user, trust_level: 0)
|
||||||
|
@ -193,7 +193,7 @@ describe UserApiKeysController do
|
||||||
api_key = UserApiKey.find_by(key: parsed["key"])
|
api_key = UserApiKey.find_by(key: parsed["key"])
|
||||||
|
|
||||||
expect(api_key.user_id).to eq(user.id)
|
expect(api_key.user_id).to eq(user.id)
|
||||||
expect(api_key.scopes.sort).to eq(["push", "message_bus", "notifications", "session_info"].sort)
|
expect(api_key.scopes.sort).to eq(["push", "message_bus", "notifications", "session_info", "one_time_password"].sort)
|
||||||
expect(api_key.push_url).to eq("https://push.it/here")
|
expect(api_key.push_url).to eq("https://push.it/here")
|
||||||
|
|
||||||
uri.query = ""
|
uri.query = ""
|
||||||
|
@ -204,6 +204,14 @@ describe UserApiKeysController do
|
||||||
post "/user-api-key.json", params: args
|
post "/user-api-key.json", params: args
|
||||||
|
|
||||||
expect(response.status).to eq(302)
|
expect(response.status).to eq(302)
|
||||||
|
|
||||||
|
one_time_password = query.split("oneTimePassword=")[1]
|
||||||
|
encrypted_otp = Base64.decode64(CGI.unescape(one_time_password))
|
||||||
|
|
||||||
|
parsed_otp = key.private_decrypt(encrypted_otp)
|
||||||
|
redis_key = "otp_#{parsed_otp}"
|
||||||
|
|
||||||
|
expect($redis.get(redis_key)).to eq(user.username)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "will just show the payload if no redirect" do
|
it "will just show the payload if no redirect" do
|
||||||
|
@ -251,4 +259,78 @@ describe UserApiKeysController do
|
||||||
expect(response.status).to eq(302)
|
expect(response.status).to eq(302)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context '#create-one-time-password' do
|
||||||
|
let :otp_args do
|
||||||
|
{
|
||||||
|
auth_redirect: 'http://somewhere.over.the/rainbow',
|
||||||
|
application_name: 'foo',
|
||||||
|
public_key: public_key
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not allow anon" do
|
||||||
|
post "/user-api-key/otp", params: otp_args
|
||||||
|
expect(response.status).to eq(403)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "refuses to redirect to disallowed place" do
|
||||||
|
sign_in(Fabricate(:user))
|
||||||
|
post "/user-api-key/otp", params: otp_args
|
||||||
|
expect(response.status).to eq(403)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "will allow one-time-password for staff without TL" do
|
||||||
|
SiteSetting.min_trust_level_for_user_api_key = 2
|
||||||
|
SiteSetting.allowed_user_api_auth_redirects = otp_args[:auth_redirect]
|
||||||
|
|
||||||
|
user = Fabricate(:user, trust_level: 1, moderator: true)
|
||||||
|
|
||||||
|
sign_in(user)
|
||||||
|
|
||||||
|
post "/user-api-key/otp", params: otp_args
|
||||||
|
expect(response.status).to eq(302)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "will not allow one-time-password unless TL is met" do
|
||||||
|
SiteSetting.min_trust_level_for_user_api_key = 2
|
||||||
|
SiteSetting.allowed_user_api_auth_redirects = otp_args[:auth_redirect]
|
||||||
|
|
||||||
|
user = Fabricate(:user, trust_level: 1)
|
||||||
|
sign_in(user)
|
||||||
|
|
||||||
|
post "/user-api-key/otp", params: otp_args
|
||||||
|
expect(response.status).to eq(403)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "will not allow one-time-password if one_time_password scope is disallowed" do
|
||||||
|
SiteSetting.allow_user_api_key_scopes = "read|write"
|
||||||
|
SiteSetting.allowed_user_api_auth_redirects = otp_args[:auth_redirect]
|
||||||
|
user = Fabricate(:user)
|
||||||
|
sign_in(user)
|
||||||
|
|
||||||
|
post "/user-api-key/otp", params: otp_args
|
||||||
|
expect(response.status).to eq(403)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "will return one-time-password when args are valid" do
|
||||||
|
SiteSetting.allowed_user_api_auth_redirects = otp_args[:auth_redirect]
|
||||||
|
user = Fabricate(:user)
|
||||||
|
sign_in(user)
|
||||||
|
|
||||||
|
post "/user-api-key/otp", params: otp_args
|
||||||
|
expect(response.status).to eq(302)
|
||||||
|
|
||||||
|
uri = URI.parse(response.redirect_url)
|
||||||
|
|
||||||
|
query = uri.query
|
||||||
|
payload = query.split("oneTimePassword=")[1]
|
||||||
|
encrypted = Base64.decode64(CGI.unescape(payload))
|
||||||
|
key = OpenSSL::PKey::RSA.new(private_key)
|
||||||
|
|
||||||
|
parsed = key.private_decrypt(encrypted)
|
||||||
|
|
||||||
|
expect($redis.get("otp_#{parsed}")).to eq(user.username)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in New Issue