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:
Penar Musaraj 2023-10-11 14:36:54 -04:00 committed by GitHub
parent 90be6f304f
commit e3e73a3091
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 592 additions and 19 deletions

View File

@ -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])

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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) }

View File

@ -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