mirror of
https://github.com/discourse/discourse.git
synced 2024-11-25 08:43:25 +08:00
DEV: Add routes and controller actions for passkeys (2/3) (#23587)
This is part 2 (of 3) for passkeys support. This adds a hidden site setting plus routes and controller actions. 1. registering passkeys Passkeys are registered in a two-step process. First, `create_passkey` returns details for the browser to create a passkey. This includes - a challenge - the relying party ID and Origin - the user's secure identifier - the supported algorithms - the user's existing passkeys (if any) Then the browser creates a key with this information, and submits it to the server via `register_passkey`. 2. authenticating passkeys A similar process happens here as well. First, a challenge is created and sent to the browser. Then the browser makes a public key credential and submits it to the server via `passkey_auth_perform`. 3. renaming/deleting passkeys These routes allow changing the name of a key and deleting it. 4. checking if session is trusted for sensitive actions Since a passkey is a password replacement, we want to make sure to confirm the user's identity before allowing adding/deleting passkeys. The u/trusted-session GET route returns success if user has confirmed their session (and failed if user hasn't). In the frontend (in the next PR), we're using these routes to show the password confirmation screen. The `/u/confirm-session` route allows the user to confirm their session with a password. The latter route's functionality already existed in core, under the 2FA flow, but it has been abstracted into its own here so it can be used independently. Co-authored-by: Alan Guo Xiang Tan <gxtan1990@gmail.com>
This commit is contained in:
parent
90be6f304f
commit
e3e73a3091
|
@ -1,7 +1,8 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class SessionController < ApplicationController
|
class SessionController < ApplicationController
|
||||||
before_action :check_local_login_allowed, only: %i[create forgot_password]
|
before_action :check_local_login_allowed,
|
||||||
|
only: %i[create forgot_password passkey_challenge passkey_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,
|
skip_before_action :preload_json,
|
||||||
|
@ -332,6 +333,34 @@ class SessionController < ApplicationController
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def passkey_challenge
|
||||||
|
render json: DiscourseWebauthn.stage_challenge(current_user, secure_session)
|
||||||
|
end
|
||||||
|
|
||||||
|
def passkey_login
|
||||||
|
raise Discourse::NotFound unless SiteSetting.experimental_passkeys
|
||||||
|
|
||||||
|
params.require(:publicKeyCredential)
|
||||||
|
|
||||||
|
security_key =
|
||||||
|
::DiscourseWebauthn::AuthenticationService.new(
|
||||||
|
nil,
|
||||||
|
params[:publicKeyCredential],
|
||||||
|
session: secure_session,
|
||||||
|
factor_type: UserSecurityKey.factor_types[:first_factor],
|
||||||
|
).authenticate_security_key
|
||||||
|
|
||||||
|
user = User.where(id: security_key.user_id, active: true).first
|
||||||
|
|
||||||
|
if user.email_confirmed?
|
||||||
|
login(user, false)
|
||||||
|
else
|
||||||
|
not_activated(user)
|
||||||
|
end
|
||||||
|
rescue ::DiscourseWebauthn::SecurityKeyError => err
|
||||||
|
render_json_error(err.message, status: 401)
|
||||||
|
end
|
||||||
|
|
||||||
def email_login_info
|
def email_login_info
|
||||||
token = params[:token]
|
token = params[:token]
|
||||||
matched_token = EmailToken.confirmable(token, scope: EmailToken.scopes[:email_login])
|
matched_token = EmailToken.confirmable(token, scope: EmailToken.scopes[:email_login])
|
||||||
|
|
|
@ -17,6 +17,8 @@ class UsersController < ApplicationController
|
||||||
enable_second_factor_totp
|
enable_second_factor_totp
|
||||||
disable_second_factor
|
disable_second_factor
|
||||||
list_second_factors
|
list_second_factors
|
||||||
|
confirm_session
|
||||||
|
trusted_session
|
||||||
update_second_factor
|
update_second_factor
|
||||||
create_second_factor_backup
|
create_second_factor_backup
|
||||||
select_avatar
|
select_avatar
|
||||||
|
@ -24,6 +26,10 @@ class UsersController < ApplicationController
|
||||||
revoke_auth_token
|
revoke_auth_token
|
||||||
register_second_factor_security_key
|
register_second_factor_security_key
|
||||||
create_second_factor_security_key
|
create_second_factor_security_key
|
||||||
|
create_passkey
|
||||||
|
register_passkey
|
||||||
|
rename_passkey
|
||||||
|
delete_passkey
|
||||||
feature_topic
|
feature_topic
|
||||||
clear_featured_topic
|
clear_featured_topic
|
||||||
bookmarks
|
bookmarks
|
||||||
|
@ -60,7 +66,7 @@ class UsersController < ApplicationController
|
||||||
user_menu_messages
|
user_menu_messages
|
||||||
]
|
]
|
||||||
|
|
||||||
before_action :second_factor_check_confirmed_password,
|
before_action :check_confirmed_session,
|
||||||
only: %i[
|
only: %i[
|
||||||
create_second_factor_totp
|
create_second_factor_totp
|
||||||
enable_second_factor_totp
|
enable_second_factor_totp
|
||||||
|
@ -69,6 +75,8 @@ class UsersController < ApplicationController
|
||||||
create_second_factor_backup
|
create_second_factor_backup
|
||||||
register_second_factor_security_key
|
register_second_factor_security_key
|
||||||
create_second_factor_security_key
|
create_second_factor_security_key
|
||||||
|
register_passkey
|
||||||
|
delete_passkey
|
||||||
]
|
]
|
||||||
|
|
||||||
before_action :respond_to_suspicious_request, only: [:create]
|
before_action :respond_to_suspicious_request, only: [:create]
|
||||||
|
@ -1490,28 +1498,34 @@ class UsersController < ApplicationController
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def confirm_session
|
||||||
|
# TODO(pmusaraj): add support for confirming via passkey, 2FA
|
||||||
|
params.require(:password)
|
||||||
|
|
||||||
|
if SiteSetting.enable_discourse_connect || !SiteSetting.enable_local_logins
|
||||||
|
raise Discourse::NotFound
|
||||||
|
end
|
||||||
|
|
||||||
|
if confirm_secure_session
|
||||||
|
render json: success_json
|
||||||
|
else
|
||||||
|
render json: failed_json.merge(error: I18n.t("login.incorrect_password"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def trusted_session
|
||||||
|
render json: secure_session_confirmed? ? success_json : failed_json
|
||||||
|
end
|
||||||
|
|
||||||
def list_second_factors
|
def list_second_factors
|
||||||
if SiteSetting.enable_discourse_connect || !SiteSetting.enable_local_logins
|
if SiteSetting.enable_discourse_connect || !SiteSetting.enable_local_logins
|
||||||
raise Discourse::NotFound
|
raise Discourse::NotFound
|
||||||
end
|
end
|
||||||
|
|
||||||
unless params[:password].empty?
|
if params[:password].present?
|
||||||
RateLimiter.new(
|
if !confirm_secure_session
|
||||||
nil,
|
|
||||||
"login-hr-#{request.remote_ip}",
|
|
||||||
SiteSetting.max_logins_per_ip_per_hour,
|
|
||||||
1.hour,
|
|
||||||
).performed!
|
|
||||||
RateLimiter.new(
|
|
||||||
nil,
|
|
||||||
"login-min-#{request.remote_ip}",
|
|
||||||
SiteSetting.max_logins_per_ip_per_minute,
|
|
||||||
1.minute,
|
|
||||||
).performed!
|
|
||||||
unless current_user.confirm_password?(params[:password])
|
|
||||||
return render json: failed_json.merge(error: I18n.t("login.incorrect_password"))
|
return render json: failed_json.merge(error: I18n.t("login.incorrect_password"))
|
||||||
end
|
end
|
||||||
confirm_secure_session
|
|
||||||
end
|
end
|
||||||
|
|
||||||
if secure_session_confirmed?
|
if secure_session_confirmed?
|
||||||
|
@ -1589,6 +1603,62 @@ class UsersController < ApplicationController
|
||||||
render json: failed_json.merge(error: err.message)
|
render json: failed_json.merge(error: err.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def create_passkey
|
||||||
|
raise Discourse::NotFound unless SiteSetting.experimental_passkeys
|
||||||
|
|
||||||
|
challenge_session = DiscourseWebauthn.stage_challenge(current_user, secure_session)
|
||||||
|
render json:
|
||||||
|
success_json.merge(
|
||||||
|
challenge: challenge_session.challenge,
|
||||||
|
rp_id: DiscourseWebauthn.rp_id,
|
||||||
|
rp_name: DiscourseWebauthn.rp_name,
|
||||||
|
supported_algorithms: ::DiscourseWebauthn::SUPPORTED_ALGORITHMS,
|
||||||
|
user_secure_id: current_user.create_or_fetch_secure_identifier,
|
||||||
|
existing_passkey_credential_ids: current_user.passkey_credential_ids,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def register_passkey
|
||||||
|
raise Discourse::NotFound unless SiteSetting.experimental_passkeys
|
||||||
|
|
||||||
|
params.require(:name)
|
||||||
|
params.require(:attestation)
|
||||||
|
params.require(:clientData)
|
||||||
|
|
||||||
|
key =
|
||||||
|
::DiscourseWebauthn::RegistrationService.new(
|
||||||
|
current_user,
|
||||||
|
params,
|
||||||
|
session: secure_session,
|
||||||
|
factor_type: UserSecurityKey.factor_types[:first_factor],
|
||||||
|
).register_security_key
|
||||||
|
|
||||||
|
render json: success_json.merge(id: key.id, name: key.name)
|
||||||
|
rescue ::DiscourseWebauthn::SecurityKeyError => err
|
||||||
|
render_json_error(err.message, status: 401)
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_passkey
|
||||||
|
raise Discourse::NotFound unless SiteSetting.experimental_passkeys
|
||||||
|
|
||||||
|
current_user.security_keys.find_by(id: params[:id].to_i)&.destroy!
|
||||||
|
|
||||||
|
render json: success_json
|
||||||
|
end
|
||||||
|
|
||||||
|
def rename_passkey
|
||||||
|
raise Discourse::NotFound unless SiteSetting.experimental_passkeys
|
||||||
|
|
||||||
|
params.require(:id)
|
||||||
|
params.require(:name)
|
||||||
|
|
||||||
|
passkey = current_user.security_keys.find_by(id: params[:id].to_i)
|
||||||
|
raise Discourse::InvalidParameters.new(:id) unless passkey
|
||||||
|
|
||||||
|
passkey.update!(name: params[:name])
|
||||||
|
render json: success_json
|
||||||
|
end
|
||||||
|
|
||||||
def update_security_key
|
def update_security_key
|
||||||
user_security_key = current_user.security_keys.find_by(id: params[:id].to_i)
|
user_security_key = current_user.security_keys.find_by(id: params[:id].to_i)
|
||||||
raise Discourse::InvalidParameters unless user_security_key
|
raise Discourse::InvalidParameters unless user_security_key
|
||||||
|
@ -1671,7 +1741,7 @@ class UsersController < ApplicationController
|
||||||
render json: success_json
|
render json: success_json
|
||||||
end
|
end
|
||||||
|
|
||||||
def second_factor_check_confirmed_password
|
def check_confirmed_session
|
||||||
if SiteSetting.enable_discourse_connect || !SiteSetting.enable_local_logins
|
if SiteSetting.enable_discourse_connect || !SiteSetting.enable_local_logins
|
||||||
raise Discourse::NotFound
|
raise Discourse::NotFound
|
||||||
end
|
end
|
||||||
|
@ -2100,6 +2170,20 @@ class UsersController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def confirm_secure_session
|
def confirm_secure_session
|
||||||
|
RateLimiter.new(
|
||||||
|
nil,
|
||||||
|
"login-hr-#{request.remote_ip}",
|
||||||
|
SiteSetting.max_logins_per_ip_per_hour,
|
||||||
|
1.hour,
|
||||||
|
).performed!
|
||||||
|
RateLimiter.new(
|
||||||
|
nil,
|
||||||
|
"login-min-#{request.remote_ip}",
|
||||||
|
SiteSetting.max_logins_per_ip_per_minute,
|
||||||
|
1.minute,
|
||||||
|
).performed!
|
||||||
|
return false if !current_user.confirm_password?(params[:password])
|
||||||
|
|
||||||
secure_session["confirmed-password-#{current_user.id}"] = "true"
|
secure_session["confirmed-password-#{current_user.id}"] = "true"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -59,6 +59,7 @@ class UserSerializer < UserCardSerializer
|
||||||
:can_change_website,
|
:can_change_website,
|
||||||
:can_change_tracking_preferences,
|
:can_change_tracking_preferences,
|
||||||
:user_api_keys,
|
:user_api_keys,
|
||||||
|
:user_passkeys,
|
||||||
:user_auth_tokens,
|
:user_auth_tokens,
|
||||||
:user_notification_schedule,
|
:user_notification_schedule,
|
||||||
:use_logo_small_as_avatar,
|
:use_logo_small_as_avatar,
|
||||||
|
@ -164,6 +165,18 @@ class UserSerializer < UserCardSerializer
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def user_passkeys
|
||||||
|
UserSecurityKey
|
||||||
|
.where(user_id: object.id, factor_type: UserSecurityKey.factor_types[:first_factor])
|
||||||
|
.map do |usk|
|
||||||
|
{ id: usk.id, name: usk.name, last_used: usk.last_used, created_at: usk.created_at }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def include_user_passkeys?
|
||||||
|
SiteSetting.experimental_passkeys?
|
||||||
|
end
|
||||||
|
|
||||||
def bio_raw
|
def bio_raw
|
||||||
object.user_profile.bio_raw
|
object.user_profile.bio_raw
|
||||||
end
|
end
|
||||||
|
|
|
@ -429,6 +429,8 @@ Discourse::Application.routes.draw do
|
||||||
if Rails.env.test?
|
if Rails.env.test?
|
||||||
post "session/2fa/test-action" => "session#test_second_factor_restricted_route"
|
post "session/2fa/test-action" => "session#test_second_factor_restricted_route"
|
||||||
end
|
end
|
||||||
|
get "session/passkey/challenge" => "session#passkey_challenge"
|
||||||
|
post "session/passkey/auth" => "session#passkey_login"
|
||||||
get "session/scopes" => "session#scopes"
|
get "session/scopes" => "session#scopes"
|
||||||
get "composer/mentions" => "composer#mentions"
|
get "composer/mentions" => "composer#mentions"
|
||||||
get "composer_messages" => "composer_messages#index"
|
get "composer_messages" => "composer_messages#index"
|
||||||
|
@ -466,6 +468,9 @@ Discourse::Application.routes.draw do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
get "#{root_path}/trusted-session" => "users#trusted_session"
|
||||||
|
post "#{root_path}/confirm-session" => "users#confirm_session"
|
||||||
|
|
||||||
post "#{root_path}/second_factors" => "users#list_second_factors"
|
post "#{root_path}/second_factors" => "users#list_second_factors"
|
||||||
put "#{root_path}/second_factor" => "users#update_second_factor"
|
put "#{root_path}/second_factor" => "users#update_second_factor"
|
||||||
|
|
||||||
|
@ -480,6 +485,11 @@ Discourse::Application.routes.draw do
|
||||||
|
|
||||||
put "#{root_path}/second_factors_backup" => "users#create_second_factor_backup"
|
put "#{root_path}/second_factors_backup" => "users#create_second_factor_backup"
|
||||||
|
|
||||||
|
post "#{root_path}/create_passkey" => "users#create_passkey"
|
||||||
|
post "#{root_path}/register_passkey" => "users#register_passkey"
|
||||||
|
put "#{root_path}/rename_passkey/:id" => "users#rename_passkey"
|
||||||
|
delete "#{root_path}/delete_passkey/:id" => "users#delete_passkey"
|
||||||
|
|
||||||
put "#{root_path}/update-activation-email" => "users#update_activation_email"
|
put "#{root_path}/update-activation-email" => "users#update_activation_email"
|
||||||
post "#{root_path}/email-login" => "users#email_login"
|
post "#{root_path}/email-login" => "users#email_login"
|
||||||
get "#{root_path}/admin-login" => "users#admin_login"
|
get "#{root_path}/admin-login" => "users#admin_login"
|
||||||
|
|
|
@ -2158,6 +2158,10 @@ developer:
|
||||||
experimental_topics_filter:
|
experimental_topics_filter:
|
||||||
client: true
|
client: true
|
||||||
default: false
|
default: false
|
||||||
|
experimental_passkeys:
|
||||||
|
client: true
|
||||||
|
default: false
|
||||||
|
hidden: true
|
||||||
experimental_search_menu_groups:
|
experimental_search_menu_groups:
|
||||||
type: group_list
|
type: group_list
|
||||||
list_type: compact
|
list_type: compact
|
||||||
|
|
|
@ -33,7 +33,7 @@ module DiscourseWebauthn
|
||||||
# verify that response.userHandle is present. Verify that the user account identified by response.userHandle
|
# verify that response.userHandle is present. Verify that the user account identified by response.userHandle
|
||||||
# contains a credential record whose id equals credential.rawId
|
# contains a credential record whose id equals credential.rawId
|
||||||
if @factor_type == UserSecurityKey.factor_types[:first_factor] &&
|
if @factor_type == UserSecurityKey.factor_types[:first_factor] &&
|
||||||
Base64.decode64(@params[:userHandle]) != @current_user.secure_identifier
|
Base64.decode64(@params[:userHandle]) != security_key.user.secure_identifier
|
||||||
raise(OwnershipError, I18n.t("webauthn.validation.ownership_error"))
|
raise(OwnershipError, I18n.t("webauthn.validation.ownership_error"))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -117,6 +117,7 @@ RSpec.describe SessionController do
|
||||||
[user_security_key.credential_id],
|
[user_security_key.credential_id],
|
||||||
)
|
)
|
||||||
secure_session = SecureSession.new(session["secure_session_id"])
|
secure_session = SecureSession.new(session["secure_session_id"])
|
||||||
|
|
||||||
expect(response_body_parsed["challenge"]).to eq(
|
expect(response_body_parsed["challenge"]).to eq(
|
||||||
DiscourseWebauthn.challenge(user, secure_session),
|
DiscourseWebauthn.challenge(user, secure_session),
|
||||||
)
|
)
|
||||||
|
@ -3009,6 +3010,146 @@ RSpec.describe SessionController do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "#passkey_challenge" do
|
||||||
|
it "returns a challenge for an anonymous user" do
|
||||||
|
get "/session/passkey/challenge.json"
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(response.parsed_body["challenge"]).not_to eq(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns a challenge for an authenticated user" do
|
||||||
|
sign_in(user)
|
||||||
|
get "/session/passkey/challenge.json"
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(response.parsed_body["challenge"]).not_to eq(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "reset challenge on subsequent calls" do
|
||||||
|
get "/session/passkey/challenge.json"
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
challenge1 = response.parsed_body["challenge"]
|
||||||
|
|
||||||
|
get "/session/passkey/challenge.json"
|
||||||
|
expect(response.parsed_body["challenge"]).not_to eq(challenge1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "fails if local logins are not allowed" do
|
||||||
|
SiteSetting.enable_local_logins = false
|
||||||
|
|
||||||
|
get "/session/passkey/challenge.json"
|
||||||
|
expect(response.status).to eq(403)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#passkey_login" do
|
||||||
|
it "returns 404 if feature is not enabled" do
|
||||||
|
SiteSetting.experimental_passkeys = false
|
||||||
|
|
||||||
|
post "/session/passkey/auth.json"
|
||||||
|
expect(response.status).to eq(404)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when experimental_passkeys is enabled" do
|
||||||
|
before { SiteSetting.experimental_passkeys = true }
|
||||||
|
|
||||||
|
it "fails if public key param is missing" do
|
||||||
|
post "/session/passkey/auth.json"
|
||||||
|
expect(response.status).to eq(400)
|
||||||
|
|
||||||
|
json = response.parsed_body
|
||||||
|
expect(json["errors"][0]).to include("param is missing")
|
||||||
|
expect(json["errors"][0]).to include("publicKeyCredential")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "fails on malformed credentials" do
|
||||||
|
post "/session/passkey/auth.json", params: { publicKeyCredential: "someboringstring" }
|
||||||
|
expect(response.status).to eq(401)
|
||||||
|
|
||||||
|
json = response.parsed_body
|
||||||
|
expect(json["errors"][0]).to eq(
|
||||||
|
I18n.t("webauthn.validation.malformed_public_key_credential_error"),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "fails on invalid credentials" do
|
||||||
|
post "/session/passkey/auth.json",
|
||||||
|
params: {
|
||||||
|
# creds are well-formed but security key is not registered
|
||||||
|
publicKeyCredential: {
|
||||||
|
signature:
|
||||||
|
"MEYCIQDYtbfkTGHOfizXHBHltn5KOq1eC3EM6Uq4peZ0L+3wMwIhAMgzm88qOOZ7SPYh5M6zvKMjVsUAne7n9RKdN/4Bb6z8",
|
||||||
|
clientData:
|
||||||
|
"eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiWmpJMk16UmxNMlV3TkRSaFl6QmhNemczTURjMlpUaGhaR1l5T1dGaU5qSXpNamMxWmpCaU9EVmxNVFUzTURaaVpEaGpNVEUwTVdJeU1qRXkiLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2x9",
|
||||||
|
authenticatorData: "SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2MFAAAAAA==",
|
||||||
|
credentialId: "humAArAAAiZZuwFE/F9Gi4BAVTsRL/FowuzQsYTPKIk=",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response.status).to eq(401)
|
||||||
|
json = response.parsed_body
|
||||||
|
expect(json["errors"][0]).to eq(I18n.t("webauthn.validation.not_found_error"))
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when user has a valid registered passkey" do
|
||||||
|
let!(:passkey) do
|
||||||
|
Fabricate(
|
||||||
|
:user_security_key,
|
||||||
|
credential_id: valid_passkey_data[:credential_id],
|
||||||
|
public_key: valid_passkey_data[:public_key],
|
||||||
|
user: user,
|
||||||
|
factor_type: UserSecurityKey.factor_types[:first_factor],
|
||||||
|
last_used: nil,
|
||||||
|
name: "A key",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "fails if local logins are not allowed" do
|
||||||
|
SiteSetting.enable_local_logins = false
|
||||||
|
|
||||||
|
post "/session/passkey/auth.json",
|
||||||
|
params: {
|
||||||
|
publicKeyCredential: valid_passkey_auth_data,
|
||||||
|
}
|
||||||
|
expect(response.status).to eq(403)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "fails when the key is registered to another user" do
|
||||||
|
simulate_localhost_passkey_challenge
|
||||||
|
user.activate
|
||||||
|
user.create_or_fetch_secure_identifier
|
||||||
|
post "/session/passkey/auth.json",
|
||||||
|
params: {
|
||||||
|
publicKeyCredential:
|
||||||
|
valid_passkey_auth_data.merge(
|
||||||
|
{ userHandle: Base64.strict_encode64(SecureRandom.hex(20)) },
|
||||||
|
),
|
||||||
|
}
|
||||||
|
expect(response.status).to eq(401)
|
||||||
|
json = response.parsed_body
|
||||||
|
expect(json["errors"][0]).to eq(I18n.t("webauthn.validation.ownership_error"))
|
||||||
|
expect(session[:current_user_id]).to eq(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "logs the user in" do
|
||||||
|
simulate_localhost_passkey_challenge
|
||||||
|
user.activate
|
||||||
|
user.create_or_fetch_secure_identifier
|
||||||
|
post "/session/passkey/auth.json",
|
||||||
|
params: {
|
||||||
|
publicKeyCredential:
|
||||||
|
valid_passkey_auth_data.merge(
|
||||||
|
{ userHandle: Base64.strict_encode64(user.secure_identifier) },
|
||||||
|
),
|
||||||
|
}
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(response.parsed_body["error"]).not_to be_present
|
||||||
|
|
||||||
|
expect(session[:current_user_id]).to eq(user.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "#scopes" do
|
describe "#scopes" do
|
||||||
context "when not a valid api request" do
|
context "when not a valid api request" do
|
||||||
it "returns 404" do
|
it "returns 404" do
|
||||||
|
|
|
@ -5862,6 +5862,7 @@ RSpec.describe UsersController do
|
||||||
sign_in(user1)
|
sign_in(user1)
|
||||||
stub_secure_session_confirmed
|
stub_secure_session_confirmed
|
||||||
end
|
end
|
||||||
|
|
||||||
context "when user has a registered totp and security key" do
|
context "when user has a registered totp and security key" do
|
||||||
before do
|
before do
|
||||||
_totp_second_factor = Fabricate(:user_second_factor_totp, user: user1)
|
_totp_second_factor = Fabricate(:user_second_factor_totp, user: user1)
|
||||||
|
@ -5899,6 +5900,194 @@ RSpec.describe UsersController do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "#create_passkey" do
|
||||||
|
before do
|
||||||
|
SiteSetting.experimental_passkeys = true
|
||||||
|
stub_secure_session_confirmed
|
||||||
|
end
|
||||||
|
|
||||||
|
it "fails if user is not logged in" do
|
||||||
|
post "/u/create_passkey.json"
|
||||||
|
|
||||||
|
expect(response.status).to eq(403)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "stores the challenge in the session and returns challenge data, user id, and supported algorithms" do
|
||||||
|
sign_in(user1)
|
||||||
|
post "/u/create_passkey.json"
|
||||||
|
|
||||||
|
secure_session = read_secure_session
|
||||||
|
response_parsed = response.parsed_body
|
||||||
|
expect(response_parsed["challenge"]).to eq(DiscourseWebauthn.challenge(user1, secure_session))
|
||||||
|
expect(response_parsed["rp_id"]).to eq(DiscourseWebauthn.rp_id)
|
||||||
|
expect(response_parsed["rp_name"]).to eq(DiscourseWebauthn.rp_name)
|
||||||
|
expect(response_parsed["user_secure_id"]).to eq(user1.reload.secure_identifier)
|
||||||
|
expect(response_parsed["supported_algorithms"]).to eq(
|
||||||
|
::DiscourseWebauthn::SUPPORTED_ALGORITHMS,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when user has a passkey" do
|
||||||
|
fab!(:user_security_key) { Fabricate(:passkey_with_random_credential, user: user1) }
|
||||||
|
|
||||||
|
it "returns existing active credentials" do
|
||||||
|
sign_in(user1)
|
||||||
|
post "/u/create_passkey.json"
|
||||||
|
|
||||||
|
response_parsed = response.parsed_body
|
||||||
|
expect(response_parsed["existing_passkey_credential_ids"]).to eq(
|
||||||
|
[user_security_key.credential_id],
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#rename_passkey" do
|
||||||
|
before { SiteSetting.experimental_passkeys = true }
|
||||||
|
|
||||||
|
it "fails if no user is logged in" do
|
||||||
|
put "/u/rename_passkey/NONE.json"
|
||||||
|
|
||||||
|
expect(response.status).to eq(403)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "fails if no name parameter is provided" do
|
||||||
|
sign_in(user1)
|
||||||
|
put "/u/rename_passkey/ID.json"
|
||||||
|
|
||||||
|
expect(response.status).to eq(400)
|
||||||
|
expect(response.parsed_body["errors"][0]).to eq(
|
||||||
|
"param is missing or the value is empty: name",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "fails if key is invalid" do
|
||||||
|
sign_in(user1)
|
||||||
|
put "/u/rename_passkey/ID.json", params: { name: "new name" }
|
||||||
|
|
||||||
|
expect(response.status).to eq(400)
|
||||||
|
expect(response.parsed_body["errors"][0]).to include(
|
||||||
|
"You supplied invalid parameters to the request: id",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with an existing passkey" do
|
||||||
|
fab!(:passkey) do
|
||||||
|
Fabricate(:passkey_with_random_credential, user: user1, name: "original name")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "renames the key" do
|
||||||
|
sign_in(user1)
|
||||||
|
put "/u/rename_passkey/#{passkey.id}.json", params: { name: "new name" }
|
||||||
|
response_parsed = response.parsed_body
|
||||||
|
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(passkey.reload.name).to eq("new name")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not let an admin delete a passkey associated with user1" do
|
||||||
|
sign_in(admin)
|
||||||
|
|
||||||
|
put "/u/rename_passkey/#{passkey.id}.json", params: { name: "new name" }
|
||||||
|
|
||||||
|
expect(passkey.reload.name).to eq("original name")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#delete_passkey" do
|
||||||
|
before { SiteSetting.experimental_passkeys = true }
|
||||||
|
fab!(:passkey) { Fabricate(:passkey_with_random_credential, user: user1) }
|
||||||
|
|
||||||
|
it "fails if user does not have a confirmed session" do
|
||||||
|
sign_in(user1)
|
||||||
|
delete "/u/delete_passkey/#{passkey.id}.json"
|
||||||
|
expect(response.status).to eq(403)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with a confirmed session" do
|
||||||
|
before { stub_secure_session_confirmed }
|
||||||
|
|
||||||
|
it "fails if user is not logged in" do
|
||||||
|
delete "/u/delete_passkey/#{passkey.id}.json"
|
||||||
|
expect(response.status).to eq(403)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "deletes the key" do
|
||||||
|
sign_in(user1)
|
||||||
|
delete "/u/delete_passkey/#{passkey.id}.json"
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(user1.passkey_credential_ids).to eq([])
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not let an admin delete a passkey associated with user1" do
|
||||||
|
sign_in(admin)
|
||||||
|
delete "/u/delete_passkey/#{passkey.id}.json"
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
|
||||||
|
expect(user1.passkey_credential_ids[0]).to eq(passkey.credential_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#register_passkey" do
|
||||||
|
before { SiteSetting.experimental_passkeys = true }
|
||||||
|
|
||||||
|
it "fails if user is not logged in" do
|
||||||
|
stub_secure_session_confirmed
|
||||||
|
post "/u/register_passkey.json"
|
||||||
|
|
||||||
|
expect(response.status).to eq(403)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "fails if session is not confirmed" do
|
||||||
|
sign_in(user1)
|
||||||
|
post "/u/register_passkey.json"
|
||||||
|
expect(response.status).to eq(403)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with a valid key" do
|
||||||
|
let(:attestation) do
|
||||||
|
"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikSZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2NFAAAAAK3OAAI1vMYKZIsLJfHwVQMAICRXq4sFZ9XpWZOzfJ8EguJmoEPMzNVyFMUWQfT5u1QzpQECAyYgASFYILjOiAHAwNrXkCk/tmyYRiE87QyV/15wUvhcXhr1JfwtIlggClQywgQvSxTsqV/FSK0cNHTTmuwfzzREqE6eLDmPxmI="
|
||||||
|
end
|
||||||
|
let(:valid_client_param) { passkey_client_data_param("webauthn.create") }
|
||||||
|
let(:invalid_client_param) { passkey_client_data_param("webauthn.get") }
|
||||||
|
|
||||||
|
before do
|
||||||
|
sign_in(user1)
|
||||||
|
stub_secure_session_confirmed
|
||||||
|
simulate_localhost_passkey_challenge
|
||||||
|
end
|
||||||
|
|
||||||
|
it "registers the passkey" do
|
||||||
|
post "/u/register_passkey.json",
|
||||||
|
params: {
|
||||||
|
name: "My Passkey",
|
||||||
|
attestation: attestation,
|
||||||
|
clientData: Base64.encode64(valid_client_param.to_json),
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(response.parsed_body["name"]).to eq("My Passkey")
|
||||||
|
expect(user1.passkey_credential_ids).to eq([valid_passkey_data[:credential_id]])
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not register a passkey with the wrong webauthn type" do
|
||||||
|
post "/u/register_passkey.json",
|
||||||
|
params: {
|
||||||
|
name: "My Passkey",
|
||||||
|
attestation: attestation,
|
||||||
|
clientData: Base64.encode64(invalid_client_param.to_json),
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response.status).to eq(401)
|
||||||
|
expect(response.parsed_body["errors"][0]).to eq(
|
||||||
|
I18n.t("webauthn.validation.invalid_type_error"),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "#revoke_account" do
|
describe "#revoke_account" do
|
||||||
it "errors for unauthorised users" do
|
it "errors for unauthorised users" do
|
||||||
post "/u/#{user1.username}/preferences/revoke-account.json",
|
post "/u/#{user1.username}/preferences/revoke-account.json",
|
||||||
|
@ -6139,6 +6328,88 @@ RSpec.describe UsersController do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "#confirm_session" do
|
||||||
|
let(:user) { user1 }
|
||||||
|
let(:password) { "test" }
|
||||||
|
|
||||||
|
before { sign_in(user) }
|
||||||
|
|
||||||
|
context "when SSO is enabled" do
|
||||||
|
before do
|
||||||
|
SiteSetting.discourse_connect_url = "https://discourse.test/sso"
|
||||||
|
SiteSetting.enable_discourse_connect = true
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not allow access" do
|
||||||
|
post "/u/confirm-session.json", params: { password: password }
|
||||||
|
expect(response.status).to eq(404)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when local logins are not enabled" do
|
||||||
|
before { SiteSetting.enable_local_logins = false }
|
||||||
|
|
||||||
|
it "does not allow access" do
|
||||||
|
post "/u/confirm-session.json", params: { password: password }
|
||||||
|
expect(response.status).to eq(404)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the site settings allow second factors" do
|
||||||
|
before do
|
||||||
|
SiteSetting.enable_local_logins = true
|
||||||
|
SiteSetting.enable_discourse_connect = false
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the password is wrong" do
|
||||||
|
it "returns incorrect password response" do
|
||||||
|
post "/u/confirm-session.json", params: { password: password }
|
||||||
|
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(response.parsed_body["error"]).to eq("Incorrect password")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when the password is correct" do
|
||||||
|
fab!(:user2) { Fabricate(:user, password: "8555039dd212cc66ec68") }
|
||||||
|
|
||||||
|
it "returns a successful response" do
|
||||||
|
sign_in(user2)
|
||||||
|
post "/u/confirm-session.json", params: { password: "8555039dd212cc66ec68" }
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(response.parsed_body["error"]).to eq(nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#trusted_session" do
|
||||||
|
it "returns 403 for anons" do
|
||||||
|
get "/u/trusted-session.json"
|
||||||
|
expect(response.status).to eq(403)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "resopnds with a 'failed' result by default" do
|
||||||
|
sign_in(user1)
|
||||||
|
|
||||||
|
get "/u/trusted-session.json"
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(response.parsed_body["failed"]).to eq("FAILED")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "response with 'success' on a confirmed session" do
|
||||||
|
user2 = Fabricate(:user, password: "8555039dd212cc66ec68")
|
||||||
|
sign_in(user2)
|
||||||
|
|
||||||
|
post "/u/confirm-session.json", params: { password: "8555039dd212cc66ec68" }
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
|
||||||
|
get "/u/trusted-session.json"
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(response.parsed_body["success"]).to eq("OK")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "#feature_topic" do
|
describe "#feature_topic" do
|
||||||
fab!(:topic) { Fabricate(:topic) }
|
fab!(:topic) { Fabricate(:topic) }
|
||||||
fab!(:other_topic) { Fabricate(:topic) }
|
fab!(:other_topic) { Fabricate(:topic) }
|
||||||
|
|
|
@ -440,6 +440,27 @@ RSpec.describe UserSerializer do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context "with user_passkeys" do
|
||||||
|
fab!(:user) { Fabricate(:user) }
|
||||||
|
fab!(:passkey) { Fabricate(:passkey_with_random_credential, user: user) }
|
||||||
|
|
||||||
|
it "does not include them if feature is disabled" do
|
||||||
|
json = UserSerializer.new(user, scope: Guardian.new(user), root: false).as_json
|
||||||
|
|
||||||
|
expect(json[:user_passkeys]).to eq(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "includes passkeys if feature is enabled" do
|
||||||
|
SiteSetting.experimental_passkeys = true
|
||||||
|
|
||||||
|
json = UserSerializer.new(user, scope: Guardian.new(user), root: false).as_json
|
||||||
|
|
||||||
|
expect(json[:user_passkeys][0][:id]).to eq(passkey.id)
|
||||||
|
expect(json[:user_passkeys][0][:name]).to eq(passkey.name)
|
||||||
|
expect(json[:user_passkeys][0][:last_used]).to eq(passkey.last_used)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context "for user sidebar attributes" do
|
context "for user sidebar attributes" do
|
||||||
include_examples "User Sidebar Serializer Attributes", described_class
|
include_examples "User Sidebar Serializer Attributes", described_class
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user