From e3e73a309185552fe70c3dce6def50665c7a94be Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Wed, 11 Oct 2023 14:36:54 -0400 Subject: [PATCH] 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 --- app/controllers/session_controller.rb | 31 +- app/controllers/users_controller.rb | 118 ++++++-- app/serializers/user_serializer.rb | 13 + config/routes.rb | 10 + config/site_settings.yml | 4 + .../authentication_service.rb | 2 +- spec/requests/session_controller_spec.rb | 141 +++++++++ spec/requests/users_controller_spec.rb | 271 ++++++++++++++++++ spec/serializers/user_serializer_spec.rb | 21 ++ 9 files changed, 592 insertions(+), 19 deletions(-) diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb index 1f7cdc81659..44f40080f38 100644 --- a/app/controllers/session_controller.rb +++ b/app/controllers/session_controller.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true 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] skip_before_action :redirect_to_login_if_required skip_before_action :preload_json, @@ -332,6 +333,34 @@ class SessionController < ApplicationController 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 token = params[:token] matched_token = EmailToken.confirmable(token, scope: EmailToken.scopes[:email_login]) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 0b1f1e62b50..1392384f66a 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -17,6 +17,8 @@ class UsersController < ApplicationController enable_second_factor_totp disable_second_factor list_second_factors + confirm_session + trusted_session update_second_factor create_second_factor_backup select_avatar @@ -24,6 +26,10 @@ class UsersController < ApplicationController revoke_auth_token register_second_factor_security_key create_second_factor_security_key + create_passkey + register_passkey + rename_passkey + delete_passkey feature_topic clear_featured_topic bookmarks @@ -60,7 +66,7 @@ class UsersController < ApplicationController user_menu_messages ] - before_action :second_factor_check_confirmed_password, + before_action :check_confirmed_session, only: %i[ create_second_factor_totp enable_second_factor_totp @@ -69,6 +75,8 @@ class UsersController < ApplicationController create_second_factor_backup register_second_factor_security_key create_second_factor_security_key + register_passkey + delete_passkey ] before_action :respond_to_suspicious_request, only: [:create] @@ -1490,28 +1498,34 @@ class UsersController < ApplicationController 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 if SiteSetting.enable_discourse_connect || !SiteSetting.enable_local_logins raise Discourse::NotFound end - unless params[:password].empty? - 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! - unless current_user.confirm_password?(params[:password]) + if params[:password].present? + if !confirm_secure_session return render json: failed_json.merge(error: I18n.t("login.incorrect_password")) end - confirm_secure_session end if secure_session_confirmed? @@ -1589,6 +1603,62 @@ class UsersController < ApplicationController render json: failed_json.merge(error: err.message) 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 user_security_key = current_user.security_keys.find_by(id: params[:id].to_i) raise Discourse::InvalidParameters unless user_security_key @@ -1671,7 +1741,7 @@ class UsersController < ApplicationController render json: success_json end - def second_factor_check_confirmed_password + def check_confirmed_session if SiteSetting.enable_discourse_connect || !SiteSetting.enable_local_logins raise Discourse::NotFound end @@ -2100,6 +2170,20 @@ class UsersController < ApplicationController end 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" end diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index 2da59c7e787..38e21e9ac73 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -59,6 +59,7 @@ class UserSerializer < UserCardSerializer :can_change_website, :can_change_tracking_preferences, :user_api_keys, + :user_passkeys, :user_auth_tokens, :user_notification_schedule, :use_logo_small_as_avatar, @@ -164,6 +165,18 @@ class UserSerializer < UserCardSerializer ) 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 object.user_profile.bio_raw end diff --git a/config/routes.rb b/config/routes.rb index 5e680586795..0931b0538a7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -429,6 +429,8 @@ Discourse::Application.routes.draw do if Rails.env.test? post "session/2fa/test-action" => "session#test_second_factor_restricted_route" end + get "session/passkey/challenge" => "session#passkey_challenge" + post "session/passkey/auth" => "session#passkey_login" get "session/scopes" => "session#scopes" get "composer/mentions" => "composer#mentions" get "composer_messages" => "composer_messages#index" @@ -466,6 +468,9 @@ Discourse::Application.routes.draw do 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" 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" + 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" post "#{root_path}/email-login" => "users#email_login" get "#{root_path}/admin-login" => "users#admin_login" diff --git a/config/site_settings.yml b/config/site_settings.yml index 0156656b768..bcec4218bf0 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -2158,6 +2158,10 @@ developer: experimental_topics_filter: client: true default: false + experimental_passkeys: + client: true + default: false + hidden: true experimental_search_menu_groups: type: group_list list_type: compact diff --git a/lib/discourse_webauthn/authentication_service.rb b/lib/discourse_webauthn/authentication_service.rb index 15ed1236979..b6eabef1ab7 100644 --- a/lib/discourse_webauthn/authentication_service.rb +++ b/lib/discourse_webauthn/authentication_service.rb @@ -33,7 +33,7 @@ module DiscourseWebauthn # verify that response.userHandle is present. Verify that the user account identified by response.userHandle # contains a credential record whose id equals credential.rawId 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")) end diff --git a/spec/requests/session_controller_spec.rb b/spec/requests/session_controller_spec.rb index c9056efe849..095ae4e9634 100644 --- a/spec/requests/session_controller_spec.rb +++ b/spec/requests/session_controller_spec.rb @@ -117,6 +117,7 @@ RSpec.describe SessionController do [user_security_key.credential_id], ) secure_session = SecureSession.new(session["secure_session_id"]) + expect(response_body_parsed["challenge"]).to eq( DiscourseWebauthn.challenge(user, secure_session), ) @@ -3009,6 +3010,146 @@ RSpec.describe SessionController do 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 context "when not a valid api request" do it "returns 404" do diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb index 179b897d88b..868fde5826d 100644 --- a/spec/requests/users_controller_spec.rb +++ b/spec/requests/users_controller_spec.rb @@ -5862,6 +5862,7 @@ RSpec.describe UsersController do sign_in(user1) stub_secure_session_confirmed end + context "when user has a registered totp and security key" do before do _totp_second_factor = Fabricate(:user_second_factor_totp, user: user1) @@ -5899,6 +5900,194 @@ RSpec.describe UsersController do 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 it "errors for unauthorised users" do post "/u/#{user1.username}/preferences/revoke-account.json", @@ -6139,6 +6328,88 @@ RSpec.describe UsersController do 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 fab!(:topic) { Fabricate(:topic) } fab!(:other_topic) { Fabricate(:topic) } diff --git a/spec/serializers/user_serializer_spec.rb b/spec/serializers/user_serializer_spec.rb index bfa4ebde243..6d74f3b680d 100644 --- a/spec/serializers/user_serializer_spec.rb +++ b/spec/serializers/user_serializer_spec.rb @@ -440,6 +440,27 @@ RSpec.describe UserSerializer do 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 include_examples "User Sidebar Serializer Attributes", described_class