SECURITY: Improve second factor auth logic

This commit is contained in:
Martin Brennan
2020-01-10 10:45:56 +10:00
parent dd52291fb7
commit cb660ef952
15 changed files with 595 additions and 131 deletions

View File

@@ -0,0 +1,29 @@
# frozen_string_literal: true
require 'rails_helper'
describe Webauthn::ChallengeGenerator do
it "generates a Webauthn::ChallengeGenerator::ChallengeSession with correct params" do
session = Webauthn::ChallengeGenerator.generate
expect(session).to be_a(Webauthn::ChallengeGenerator::ChallengeSession)
expect(session.challenge).not_to eq(nil)
expect(session.rp_id).to eq(Discourse.current_hostname)
expect(session.rp_name).to eq(SiteSetting.title)
end
describe "ChallengeSession" do
describe "#commit_to_session" do
let(:user) { Fabricate(:user) }
it "stores the challenge, rpid, and name in the provided session object" do
secure_session = {}
generated_session = Webauthn::ChallengeGenerator.generate
generated_session.commit_to_session(secure_session, user)
expect(secure_session["staged-webauthn-challenge-#{user&.id}"]).to eq(generated_session.challenge)
expect(secure_session["staged-webauthn-rp-id-#{user&.id}"]).to eq(generated_session.rp_id)
expect(secure_session["staged-webauthn-rp-name-#{user&.id}"]).to eq(generated_session.rp_name)
end
end
end
end

View File

@@ -160,6 +160,7 @@ RSpec.configure do |config|
config.include MessageBus
config.include RSpecHtmlMatchers
config.include IntegrationHelpers, type: :request
config.include WebauthnIntegrationHelpers, type: :request
config.include SiteSettingsHelpers
config.mock_framework = :mocha
config.order = 'random'

View File

@@ -61,9 +61,10 @@ RSpec.describe SessionController do
it "includes that information in the response" do
get "/session/email-login/#{email_token.token}.json"
expect(JSON.parse(response.body)["can_login"]).to eq(true)
expect(JSON.parse(response.body)["second_factor_required"]).to eq(true)
expect(JSON.parse(response.body)["backup_codes_enabled"]).to eq(true)
response_body_parsed = JSON.parse(response.body)
expect(response_body_parsed["can_login"]).to eq(true)
expect(response_body_parsed["second_factor_required"]).to eq(true)
expect(response_body_parsed["backup_codes_enabled"]).to eq(true)
end
end
@@ -73,14 +74,15 @@ RSpec.describe SessionController do
it "includes that information in the response" do
get "/session/email-login/#{email_token.token}.json"
expect(JSON.parse(response.body)["can_login"]).to eq(true)
expect(JSON.parse(response.body)["security_key_required"]).to eq(true)
expect(JSON.parse(response.body)["second_factor_required"]).to eq(nil)
expect(JSON.parse(response.body)["backup_codes_enabled"]).to eq(nil)
expect(JSON.parse(response.body)["allowed_credential_ids"]).to eq([user_security_key.credential_id])
response_body_parsed = JSON.parse(response.body)
expect(response_body_parsed["can_login"]).to eq(true)
expect(response_body_parsed["security_key_required"]).to eq(true)
expect(response_body_parsed["second_factor_required"]).to eq(nil)
expect(response_body_parsed["backup_codes_enabled"]).to eq(nil)
expect(response_body_parsed["allowed_credential_ids"]).to eq([user_security_key.credential_id])
secure_session = SecureSession.new(session["secure_session_id"])
expect(JSON.parse(response.body)["challenge"]).to eq(secure_session["staged-webauthn-challenge-#{user.id}"])
expect(secure_session["staged-webauthn-rp-id-#{user.id}"]).to eq(Discourse.current_hostname)
expect(response_body_parsed["challenge"]).to eq(Webauthn.challenge(user, secure_session))
expect(Webauthn.rp_id(user, secure_session)).to eq(Discourse.current_hostname)
end
end
end
@@ -284,6 +286,80 @@ RSpec.describe SessionController do
end
end
end
context "user has only security key enabled" do
let!(:user_security_key) do
Fabricate(
:user_security_key,
user: user,
credential_id: valid_security_key_data[:credential_id],
public_key: valid_security_key_data[:public_key]
)
end
before do
simulate_localhost_webauthn_challenge
# store challenge in secure session by visiting the email login page
get "/session/email-login/#{email_token.token}.json"
end
context "when the security key params are blank and a random second factor token is provided" do
it "shows an error message and denies login" do
post "/session/email-login/#{email_token.token}.json", params: {
second_factor_token: "XXXXXXX",
second_factor_method: UserSecondFactor.methods[:totp]
}
expect(response.status).to eq(200)
expect(session[:current_user_id]).to eq(nil)
response_body = JSON.parse(response.body)
expect(response_body['error']).to eq(I18n.t(
'login.invalid_second_factor_code'
))
end
end
context "when the security key params are invalid" do
it" shows an error message and denies login" do
post "/session/email-login/#{email_token.token}.json", params: {
security_key_credential: {
signature: 'bad_sig',
clientData: 'bad_clientData',
credentialId: 'bad_credential_id',
authenticatorData: 'bad_authenticator_data'
},
second_factor_method: UserSecondFactor.methods[:security_key]
}
expect(response.status).to eq(200)
expect(session[:current_user_id]).to eq(nil)
response_body = JSON.parse(response.body)
expect(response_body["failed"]).to eq("FAILED")
expect(response_body['error']).to eq(I18n.t(
'webauthn.validation.not_found_error'
))
end
end
context "when the security key params are valid" do
it "logs the user in" do
post "/session/email-login/#{email_token.token}.json", params: {
login: user.username,
password: 'myawesomepassword',
security_key_credential: valid_security_key_auth_post_data,
second_factor_method: UserSecondFactor.methods[:security_key]
}
expect(response.status).to eq(200)
user.reload
expect(session[:current_user_id]).to eq(user.id)
expect(user.user_auth_tokens.count).to eq(1)
end
end
end
end
end
@@ -1061,7 +1137,114 @@ RSpec.describe SessionController do
end
end
context 'when user has 2-factor logins' do
context "when a user has security key-only 2FA login" do
let!(:user_security_key) do
Fabricate(
:user_security_key,
user: user,
credential_id: valid_security_key_data[:credential_id],
public_key: valid_security_key_data[:public_key]
)
end
before do
simulate_localhost_webauthn_challenge
# store challenge in secure session by failing login once
post "/session.json", params: {
login: user.username,
password: 'myawesomepassword'
}
end
context "when the security key params are blank and a random second factor token is provided" do
it "shows an error message and denies login" do
post "/session.json", params: {
login: user.username,
password: 'myawesomepassword',
security_key_credential: {},
second_factor_token: '99999999',
second_factor_method: UserSecondFactor.methods[:totp]
}
expect(response.status).to eq(200)
expect(session[:current_user_id]).to eq(nil)
response_body = JSON.parse(response.body)
expect(response_body["failed"]).to eq("FAILED")
expect(response_body['error']).to eq(I18n.t(
'login.invalid_second_factor_code'
))
end
end
context "when the security key params are invalid" do
it" shows an error message and denies login" do
post "/session.json", params: {
login: user.username,
password: 'myawesomepassword',
security_key_credential: {
signature: 'bad_sig',
clientData: 'bad_clientData',
credentialId: 'bad_credential_id',
authenticatorData: 'bad_authenticator_data'
},
second_factor_method: UserSecondFactor.methods[:security_key]
}
expect(response.status).to eq(200)
expect(session[:current_user_id]).to eq(nil)
response_body = JSON.parse(response.body)
expect(response_body["failed"]).to eq("FAILED")
expect(response_body['error']).to eq(I18n.t(
'webauthn.validation.not_found_error'
))
end
end
context "when the security key params are valid" do
it "logs the user in" do
post "/session.json", params: {
login: user.username,
password: 'myawesomepassword',
security_key_credential: valid_security_key_auth_post_data,
second_factor_method: UserSecondFactor.methods[:security_key]
}
expect(response.status).to eq(200)
user.reload
expect(session[:current_user_id]).to eq(user.id)
expect(user.user_auth_tokens.count).to eq(1)
end
end
context "when the security key is disabled in the background by the user and TOTP is enabled" do
before do
user_security_key.destroy!
Fabricate(:user_second_factor_totp, user: user)
end
it "shows an error message and denies login" do
post "/session.json", params: {
login: user.username,
password: 'myawesomepassword',
security_key_credential: valid_security_key_auth_post_data,
second_factor_method: UserSecondFactor.methods[:security_key]
}
expect(response.status).to eq(200)
expect(session[:current_user_id]).to eq(nil)
response_body = JSON.parse(response.body)
expect(response_body["failed"]).to eq("FAILED")
expect(JSON.parse(response.body)['error']).to eq(I18n.t(
'login.invalid_second_factor_code'
))
end
end
end
context 'when user has TOTP-only 2FA login' do
let!(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) }
let!(:user_second_factor_backup) { Fabricate(:user_second_factor_backup, user: user) }

View File

@@ -384,47 +384,43 @@ describe UsersController do
end
context 'security key authentication required' do
let!(:security_key) { Fabricate(:user_security_key, user: user, factor_type: UserSecurityKey.factor_types[:second_factor]) }
let!(:user_security_key) do
Fabricate(
:user_security_key,
user: user,
credential_id: valid_security_key_data[:credential_id],
public_key: valid_security_key_data[:public_key]
)
end
let(:token) { user.email_tokens.create!(email: user.email).token }
before do
simulate_localhost_webauthn_challenge
# store challenge in secure session by visiting the email login page
get "/u/password-reset/#{token}"
end
it 'preloads with a security key challenge and allowed credential ids' do
token = user.email_tokens.create!(email: user.email).token
get "/u/password-reset/#{token}"
expect(response.body).to have_tag("div#data-preloaded") do |element|
json = JSON.parse(element.current_scope.attribute('data-preloaded').value)
password_reset = JSON.parse(json['password_reset'])
expect(password_reset['challenge']).not_to eq(nil)
expect(password_reset['allowed_credential_ids']).to eq([security_key.credential_id])
expect(password_reset['allowed_credential_ids']).to eq([user_security_key.credential_id])
expect(password_reset['security_key_required']).to eq(true)
end
end
it 'stages a webauthn challenge and rp-id for the user' do
token = user.email_tokens.create!(email: user.email).token
get "/u/password-reset/#{token}"
secure_session = SecureSession.new(session["secure_session_id"])
expect(secure_session["staged-webauthn-challenge-#{user.id}"]).not_to eq(nil)
expect(secure_session["staged-webauthn-rp-id-#{user.id}"]).to eq(Discourse.current_hostname)
expect(Webauthn.challenge(user, secure_session)).not_to eq(nil)
expect(Webauthn.rp_id(user, secure_session)).to eq(Discourse.current_hostname)
end
it 'changes password with valid security key challenge and authentication' do
token = user.email_tokens.create(email: user.email).token
get "/u/password-reset/#{token}"
::Webauthn::SecurityKeyAuthenticationService.any_instance.stubs(:authenticate_security_key).returns(true)
put "/u/password-reset/#{token}", params: {
put "/u/password-reset/#{token}.json", params: {
password: 'hg9ow8yHG32O',
security_key_credential: {
signature: 'test',
clientData: 'test',
authenticatorData: 'test',
credentialId: 'test'
},
security_key_credential: valid_security_key_auth_post_data,
second_factor_method: UserSecondFactor.methods[:security_key]
}
@@ -433,6 +429,26 @@ describe UsersController do
expect(user.confirm_password?('hg9ow8yHG32O')).to eq(true)
expect(user.user_auth_tokens.count).to eq(1)
end
context "when security key authentication fails" do
it 'shows an error message and does not change password' do
put "/u/password-reset/#{token}", params: {
password: 'hg9ow8yHG32O',
security_key_credential: {
signature: 'bad',
clientData: 'bad',
authenticatorData: 'bad',
credentialId: 'bad'
},
second_factor_method: UserSecondFactor.methods[:security_key]
}
user.reload
expect(user.confirm_password?('hg9ow8yHG32O')).to eq(false)
expect(response.status).to eq(200)
expect(JSON.parse(response.body)['errors']).to include(I18n.t("webauthn.validation.not_found_error"))
end
end
end
end
@@ -590,12 +606,24 @@ describe UsersController do
end
describe 'when security key authentication required' do
fab!(:security_key) { Fabricate(:user_security_key, user: admin) }
fab!(:email_token) { Fabricate(:email_token, user: admin) }
let!(:security_key) do
Fabricate(
:user_security_key,
user: admin,
credential_id: valid_security_key_data[:credential_id],
public_key: valid_security_key_data[:public_key]
)
end
before do
simulate_localhost_webauthn_challenge
# store challenge in secure session by visiting the admin login page
get "/u/admin-login/#{email_token.token}"
end
it 'does not log in when token required' do
security_key
get "/u/admin-login/#{email_token.token}"
expect(response).not_to redirect_to('/')
expect(session[:current_user_id]).not_to eq(admin.id)
expect(response.body).to include(I18n.t('login.security_key_authenticate'))
@@ -603,33 +631,24 @@ describe UsersController do
describe 'invalid security key' do
it 'should display the right error' do
::Webauthn::SecurityKeyAuthenticationService.any_instance.stubs(:authenticate_security_key).returns(false)
put "/u/admin-login/#{email_token.token}", params: {
security_key_credential: {
signature: 'test',
clientData: 'test',
authenticatorData: 'test',
credentialId: 'test'
signature: 'bad',
clientData: 'bad',
authenticatorData: 'bad',
credentialId: 'bad'
}.to_json,
second_factor_method: UserSecondFactor.methods[:security_key]
}
expect(response.status).to eq(200)
expect(response.body).to include(I18n.t('login.security_key_invalid'))
expect(response.body).to include(I18n.t('webauthn.validation.not_found_error'))
end
end
it 'logs in when a valid security key is given' do
::Webauthn::SecurityKeyAuthenticationService.any_instance.stubs(:authenticate_security_key).returns(true)
put "/u/admin-login/#{email_token.token}", params: {
security_key_credential: {
signature: 'test',
clientData: 'test',
authenticatorData: 'test',
credentialId: 'test'
}.to_json,
security_key_credential: valid_security_key_auth_post_data.to_json,
second_factor_method: UserSecondFactor.methods[:security_key]
}
@@ -3642,6 +3661,77 @@ describe UsersController do
end
end
describe "#create_second_factor_security_key" do
it "stores the challenge in the session and returns challenge data, user id, and supported algorithms" do
create_second_factor_security_key
secure_session = read_secure_session
response_parsed = JSON.parse(response.body)
expect(response_parsed["challenge"]).to eq(
Webauthn.challenge(user, secure_session)
)
expect(response_parsed["rp_id"]).to eq(
Webauthn.rp_id(user, secure_session)
)
expect(response_parsed["rp_name"]).to eq(
Webauthn.rp_name(user, secure_session)
)
expect(response_parsed["user_secure_id"]).to eq(
user.reload.create_or_fetch_secure_identifier
)
expect(response_parsed["supported_algoriths"]).to eq(
::Webauthn::SUPPORTED_ALGORITHMS
)
end
context "if the user has security key credentials already" do
let!(:user_security_key) { Fabricate(:user_security_key_with_random_credential, user: user) }
it "returns those existing active credentials" do
create_second_factor_security_key
response_parsed = JSON.parse(response.body)
expect(response_parsed["existing_active_credential_ids"]).to eq(
[user_security_key.credential_id]
)
end
end
end
describe "#register_second_factor_security_key" do
context "when creation parameters are valid" do
it "creates a security key for the user" do
simulate_localhost_webauthn_challenge
create_second_factor_security_key
response_parsed = JSON.parse(response.body)
post "/u/register_second_factor_security_key.json", params: valid_security_key_create_post_data
expect(user.security_keys.count).to eq(1)
expect(user.security_keys.last.credential_id).to eq(valid_security_key_create_post_data[:rawId])
expect(user.security_keys.last.name).to eq(valid_security_key_create_post_data[:name])
end
end
context "when the creation parameters are invalid" do
it "shows a security key error and does not create a key" do
stub_as_dev_localhost
create_second_factor_security_key
response_parsed = JSON.parse(response.body)
post "/u/register_second_factor_security_key.json", params: {
id: "bad id",
rawId: "bad rawId",
type: "public-key",
attestation: "bad attestation",
clientData: Base64.encode64('{"bad": "json"}'),
name: "My Bad Key"
}
expect(user.security_keys.count).to eq(0)
expect(JSON.parse(response.body)["error"]).to eq(I18n.t("webauthn.validation.invalid_type_error"))
end
end
end
describe '#revoke_account' do
fab!(:other_user) { Fabricate(:user) }
it 'errors for unauthorised users' do
@@ -3949,4 +4039,10 @@ describe UsersController do
expect(user.user_profile.featured_topic).to eq nil
end
end
def create_second_factor_security_key
sign_in(user)
UsersController.any_instance.stubs(:secure_session_confirmed?).returns(true)
post "/u/create_second_factor_security_key.json"
end
end

View File

@@ -29,4 +29,8 @@ module IntegrationHelpers
get "/session/#{user.username}/become"
user
end
def read_secure_session
SecureSession.new(session[:secure_session_id])
end
end

View File

@@ -0,0 +1,69 @@
# frozen_string_literal: true
module WebauthnIntegrationHelpers
##
# Usage notes:
#
# The valid_security_key_auth_post_data is derived from an actual YubiKey login
# attempt that is successful. No security risk is posed by this; this YubiKey
# has only ever been used for local credentials.
#
# To make this all work together you need to
# create a UserSecurityKey for a user using valid_security_key_data,
# and you override Webauthn::ChallengeGenerator.generate to return
# a Webauthn::ChallengeGenerator::ChallengeSession object using
# valid_security_key_challenge_data.
#
# This is because the challenge is embedded
# in the post data's authenticatorData and must match up. See
# simulate_localhost_webautn_challenge for a real example.
def valid_security_key_data
{
credential_id: "9GiFosW50+s+juyJlyxKEVAsk3gZLo9XWIhX47eC4gHfDsldF3TWR43Tcl/+3gLTL5t1TjpmcbKA2DUV2eKrBw==".freeze,
public_key: "pQECAyYgASFYIPMGM1OpSuCU5uks+BulAdfVxdlJiYcgGac5Y+LnLXC9Ilgghy0BKvRvptmQdtWz33Jjnf8Y6+HD85XdRiqmo1KMGPE=".freeze
}
end
def valid_security_key_auth_post_data
{
signature: "MEYCIQC5xyUQvF4qTPZ2yX7crp/IEs1E/4wqhXgxC1EVAumhfgIhAIC/7w4BVEy+ew6vMYISahtnnIqbqsPZosBeTUSI8Y4j".freeze,
clientData: "eyJjaGFsbGVuZ2UiOiJOR1UzWW1Zek0yWTBNelkyWkdFM05EVTNZak5qWldVNFpUWTNOakJoTm1NMFlqVTVORFptTlRrd016Vm1ZMlZpTURVd01UZzJOemcxTW1RMSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6MzAwMCIsInR5cGUiOiJ3ZWJhdXRobi5nZXQifQ==".freeze,
authenticatorData: "SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2MBAAAA2Q==".freeze,
credentialId: valid_security_key_data[:credential_id]
}
end
def valid_security_key_challenge_data
{
challenge: "4e7bf33f4366da7457b3cee8e6760a6c4b5946f59035fceb0501867852d5".freeze
}
end
def valid_security_key_create_post_data
{
id: "hg7Ojg9H4urf9UlT99T2yr-FQtEGCWnRNdkI5QKEqDxlSjsLHhUcQxeTPelC26cy9XQ_qIg1Nq88PNVDlZvxHA",
rawId: "hg7Ojg9H4urf9UlT99T2yr+FQtEGCWnRNdkI5QKEqDxlSjsLHhUcQxeTPelC26cy9XQ/qIg1Nq88PNVDlZvxHA==",
type: "public-key",
attestation: "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjESZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2NBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQIYOzo4PR+Lq3/VJU/fU9sq/hULRBglp0TXZCOUChKg8ZUo7Cx4VHEMXkz3pQtunMvV0P6iINTavPDzVQ5Wb8RylAQIDJiABIVggJI3i7Svv1+Hu8pGYIQ6XEIeWHxjr+qKVXPmXSQswGysiWCDs0ZRoPXkajl+Mpvc16BPVFrKRxl06V+XTKdKffiMzZQ==",
clientData: "eyJjaGFsbGVuZ2UiOiJOR1UzWW1Zek0yWTBNelkyWkdFM05EVTNZak5qWldVNFpUWTNOakJoTm1NMFlqVTVORFptTlRrd016Vm1ZMlZpTURVd01UZzJOemcxTW1RMSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6MzAwMCIsInR5cGUiOiJ3ZWJhdXRobi5jcmVhdGUifQ==",
name: "My Security Key"
}
end
# all of the valid security key data is sourced from a localhost
# login, if this is not set the specs for webauthn WILL NOT WORK
def stub_as_dev_localhost
Discourse.stubs(:current_hostname).returns('localhost')
Discourse.stubs(:base_url).returns('http://localhost:3000')
end
def simulate_localhost_webauthn_challenge
stub_as_dev_localhost
Webauthn::ChallengeGenerator.stubs(:generate).returns(
Webauthn::ChallengeGenerator::ChallengeSession.new(
challenge: valid_security_key_challenge_data[:challenge],
rp_id: Discourse.current_hostname
)
)
end
end