Add ability to skip sign-in token authentication for specific users (#16427)

Remove "active within last two weeks" exception for sign in token requirement

Change admin reset password to lock access until the password is reset
master
Eugen Rochko 4 years ago committed by GitHub
parent 2e0eac71dd
commit 771c9d4ba8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      app/controllers/admin/resets_controller.rb
  2. 27
      app/controllers/admin/sign_in_token_authentications_controller.rb
  3. 2
      app/controllers/admin/two_factor_authentications_controller.rb
  4. 25
      app/models/user.rb
  5. 8
      app/policies/user_policy.rb
  6. 24
      app/views/admin/accounts/show.html.haml
  7. 42
      config/locales/en.yml
  8. 1
      config/routes.rb
  9. 5
      db/migrate/20210621221010_add_skip_sign_in_token_to_users.rb
  10. 1
      db/schema.rb
  11. 15
      lib/mastodon/accounts_cli.rb
  12. 2
      spec/controllers/admin/resets_controller_spec.rb
  13. 8
      spec/controllers/admin/two_factor_authentications_controller_spec.rb
  14. 28
      spec/models/user_spec.rb

@ -6,9 +6,9 @@ module Admin
def create def create
authorize @user, :reset_password? authorize @user, :reset_password?
@user.send_reset_password_instructions @user.reset_password!
log_action :reset_password, @user log_action :reset_password, @user
redirect_to admin_accounts_path redirect_to admin_account_path(@user.account_id)
end end
end end
end end

@ -0,0 +1,27 @@
# frozen_string_literal: true
module Admin
class SignInTokenAuthenticationsController < BaseController
before_action :set_target_user
def create
authorize @user, :enable_sign_in_token_auth?
@user.update(skip_sign_in_token: false)
log_action :enable_sign_in_token_auth, @user
redirect_to admin_account_path(@user.account_id)
end
def destroy
authorize @user, :disable_sign_in_token_auth?
@user.update(skip_sign_in_token: true)
log_action :disable_sign_in_token_auth, @user
redirect_to admin_account_path(@user.account_id)
end
private
def set_target_user
@user = User.find(params[:user_id])
end
end
end

@ -9,7 +9,7 @@ module Admin
@user.disable_two_factor! @user.disable_two_factor!
log_action :disable_2fa, @user log_action :disable_2fa, @user
UserMailer.two_factor_disabled(@user).deliver_later! UserMailer.two_factor_disabled(@user).deliver_later!
redirect_to admin_accounts_path redirect_to admin_account_path(@user.account_id)
end end
private private

@ -42,6 +42,7 @@
# sign_in_token_sent_at :datetime # sign_in_token_sent_at :datetime
# webauthn_id :string # webauthn_id :string
# sign_up_ip :inet # sign_up_ip :inet
# skip_sign_in_token :boolean
# #
class User < ApplicationRecord class User < ApplicationRecord
@ -200,7 +201,7 @@ class User < ApplicationRecord
end end
def suspicious_sign_in?(ip) def suspicious_sign_in?(ip)
!otp_required_for_login? && current_sign_in_at.present? && current_sign_in_at < 2.weeks.ago && !recent_ip?(ip) !otp_required_for_login? && !skip_sign_in_token? && current_sign_in_at.present? && !recent_ip?(ip)
end end
def functional? def functional?
@ -329,12 +330,32 @@ class User < ApplicationRecord
super super
end end
def reset_password!(new_password, new_password_confirmation) def reset_password(new_password, new_password_confirmation)
return false if encrypted_password.blank? return false if encrypted_password.blank?
super super
end end
def reset_password!
# First, change password to something random, invalidate the remember-me token,
# and deactivate all sessions
transaction do
update(remember_token: nil, remember_created_at: nil, password: SecureRandom.hex)
session_activations.destroy_all
end
# Then, remove all authorized applications and connected push subscriptions
Doorkeeper::AccessGrant.by_resource_owner(self).in_batches.update_all(revoked_at: Time.now.utc)
Doorkeeper::AccessToken.by_resource_owner(self).in_batches do |batch|
batch.update_all(revoked_at: Time.now.utc)
Web::PushSubscription.where(access_token_id: batch).delete_all
end
# Finally, send a reset password prompt to the user
send_reset_password_instructions
end
def show_all_media? def show_all_media?
setting_display_media == 'show_all' setting_display_media == 'show_all'
end end

@ -13,6 +13,14 @@ class UserPolicy < ApplicationPolicy
admin? && !record.staff? admin? && !record.staff?
end end
def disable_sign_in_token_auth?
staff?
end
def enable_sign_in_token_auth?
staff?
end
def confirm? def confirm?
staff? && !record.confirmed? staff? && !record.confirmed?
end end

@ -129,6 +129,27 @@
- else - else
= t('admin.accounts.confirming') = t('admin.accounts.confirming')
%td= table_link_to 'refresh', t('admin.accounts.resend_confirmation.send'), resend_admin_account_confirmation_path(@account.id), method: :post if can?(:confirm, @account.user) %td= table_link_to 'refresh', t('admin.accounts.resend_confirmation.send'), resend_admin_account_confirmation_path(@account.id), method: :post if can?(:confirm, @account.user)
%tr
%th{ rowspan: can?(:reset_password, @account.user) ? 2 : 1 }= t('admin.accounts.security')
%td{ rowspan: can?(:reset_password, @account.user) ? 2 : 1 }
- if @account.user&.two_factor_enabled?
= t 'admin.accounts.security_measures.password_and_2fa'
- elsif @account.user&.skip_sign_in_token?
= t 'admin.accounts.security_measures.only_password'
- else
= t 'admin.accounts.security_measures.password_and_sign_in_token'
%td
- if @account.user&.two_factor_enabled?
= table_link_to 'unlock', t('admin.accounts.disable_two_factor_authentication'), admin_user_two_factor_authentication_path(@account.user.id), method: :delete if can?(:disable_2fa, @account.user)
- elsif @account.user&.skip_sign_in_token?
= table_link_to 'lock', t('admin.accounts.enable_sign_in_token_auth'), admin_user_sign_in_token_authentication_path(@account.user.id), method: :post if can?(:enable_sign_in_token_auth, @account.user)
- else
= table_link_to 'unlock', t('admin.accounts.disable_sign_in_token_auth'), admin_user_sign_in_token_authentication_path(@account.user.id), method: :delete if can?(:disable_sign_in_token_auth, @account.user)
- if can?(:reset_password, @account.user)
%tr
%td
= table_link_to 'key', t('admin.accounts.reset_password'), admin_account_reset_path(@account.id), method: :create, data: { confirm: t('admin.accounts.are_you_sure') }
%tr %tr
%th= t('simple_form.labels.defaults.locale') %th= t('simple_form.labels.defaults.locale')
@ -221,9 +242,6 @@
%div %div
- if @account.local? - if @account.local?
= link_to t('admin.accounts.reset_password'), admin_account_reset_path(@account.id), method: :create, class: 'button' if can?(:reset_password, @account.user)
- if @account.user&.otp_required_for_login?
= link_to t('admin.accounts.disable_two_factor_authentication'), admin_user_two_factor_authentication_path(@account.user.id), method: :delete, class: 'button' if can?(:disable_2fa, @account.user)
- if !@account.memorial? && @account.user_approved? - if !@account.memorial? && @account.user_approved?
= link_to t('admin.accounts.memorialize'), memorialize_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive' if can?(:memorialize, @account) = link_to t('admin.accounts.memorialize'), memorialize_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive' if can?(:memorialize, @account)
- else - else

@ -44,7 +44,7 @@ en:
rejecting_media: 'Media files from these servers will not be processed or stored, and no thumbnails will be displayed, requiring manual click-through to the original file:' rejecting_media: 'Media files from these servers will not be processed or stored, and no thumbnails will be displayed, requiring manual click-through to the original file:'
rejecting_media_title: Filtered media rejecting_media_title: Filtered media
silenced: 'Posts from these servers will be hidden in public timelines and conversations, and no notifications will be generated from their users interactions, unless you are following them:' silenced: 'Posts from these servers will be hidden in public timelines and conversations, and no notifications will be generated from their users interactions, unless you are following them:'
silenced_title: Silenced servers silenced_title: Limited servers
suspended: 'No data from these servers will be processed, stored or exchanged, making any interaction or communication with users from these servers impossible:' suspended: 'No data from these servers will be processed, stored or exchanged, making any interaction or communication with users from these servers impossible:'
suspended_title: Suspended servers suspended_title: Suspended servers
unavailable_content_html: Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server. unavailable_content_html: Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server.
@ -119,6 +119,7 @@ en:
demote: Demote demote: Demote
destroyed_msg: "%{username}'s data is now queued to be deleted imminently" destroyed_msg: "%{username}'s data is now queued to be deleted imminently"
disable: Freeze disable: Freeze
disable_sign_in_token_auth: Disable e-mail token authentication
disable_two_factor_authentication: Disable 2FA disable_two_factor_authentication: Disable 2FA
disabled: Frozen disabled: Frozen
display_name: Display name display_name: Display name
@ -127,6 +128,7 @@ en:
email: Email email: Email
email_status: Email status email_status: Email status
enable: Unfreeze enable: Unfreeze
enable_sign_in_token_auth: Enable e-mail token authentication
enabled: Enabled enabled: Enabled
enabled_msg: Successfully unfroze %{username}'s account enabled_msg: Successfully unfroze %{username}'s account
followers: Followers followers: Followers
@ -151,7 +153,7 @@ en:
active: Active active: Active
all: All all: All
pending: Pending pending: Pending
silenced: Silenced silenced: Limited
suspended: Suspended suspended: Suspended
title: Moderation title: Moderation
moderation_notes: Moderation notes moderation_notes: Moderation notes
@ -191,8 +193,12 @@ en:
search: Search search: Search
search_same_email_domain: Other users with the same e-mail domain search_same_email_domain: Other users with the same e-mail domain
search_same_ip: Other users with the same IP search_same_ip: Other users with the same IP
sensitive: Sensitive security_measures:
sensitized: marked as sensitive only_password: Only password
password_and_2fa: Password and 2FA
password_and_sign_in_token: Password and e-mail token
sensitive: Force-sensitive
sensitized: Marked as sensitive
shared_inbox_url: Shared inbox URL shared_inbox_url: Shared inbox URL
show: show:
created_reports: Made reports created_reports: Made reports
@ -207,10 +213,10 @@ en:
time_in_queue: Waiting in queue %{time} time_in_queue: Waiting in queue %{time}
title: Accounts title: Accounts
unconfirmed_email: Unconfirmed email unconfirmed_email: Unconfirmed email
undo_sensitized: Undo sensitive undo_sensitized: Undo force-sensitive
undo_silenced: Undo silence undo_silenced: Undo limit
undo_suspension: Undo suspension undo_suspension: Undo suspension
unsilenced_msg: Successfully unlimited %{username}'s account unsilenced_msg: Successfully undid limit of %{username}'s account
unsubscribe: Unsubscribe unsubscribe: Unsubscribe
unsuspended_msg: Successfully unsuspended %{username}'s account unsuspended_msg: Successfully unsuspended %{username}'s account
username: Username username: Username
@ -236,14 +242,16 @@ en:
destroy_custom_emoji: Delete Custom Emoji destroy_custom_emoji: Delete Custom Emoji
destroy_domain_allow: Delete Domain Allow destroy_domain_allow: Delete Domain Allow
destroy_domain_block: Delete Domain Block destroy_domain_block: Delete Domain Block
destroy_email_domain_block: Delete e-mail domain block destroy_email_domain_block: Delete E-mail Domain Block
destroy_ip_block: Delete IP rule destroy_ip_block: Delete IP rule
destroy_status: Delete Post destroy_status: Delete Post
destroy_unavailable_domain: Delete Unavailable Domain destroy_unavailable_domain: Delete Unavailable Domain
disable_2fa_user: Disable 2FA disable_2fa_user: Disable 2FA
disable_custom_emoji: Disable Custom Emoji disable_custom_emoji: Disable Custom Emoji
disable_sign_in_token_auth_user: Disable E-mail Token Authentication for User
disable_user: Disable User disable_user: Disable User
enable_custom_emoji: Enable Custom Emoji enable_custom_emoji: Enable Custom Emoji
enable_sign_in_token_auth_user: Enable E-mail Token Authentication for User
enable_user: Enable User enable_user: Enable User
memorialize_account: Memorialize Account memorialize_account: Memorialize Account
promote_user: Promote User promote_user: Promote User
@ -251,12 +259,12 @@ en:
reopen_report: Reopen Report reopen_report: Reopen Report
reset_password_user: Reset Password reset_password_user: Reset Password
resolve_report: Resolve Report resolve_report: Resolve Report
sensitive_account: Mark the media in your account as sensitive sensitive_account: Force-Sensitive Account
silence_account: Silence Account silence_account: Limit Account
suspend_account: Suspend Account suspend_account: Suspend Account
unassigned_report: Unassign Report unassigned_report: Unassign Report
unsensitive_account: Unmark the media in your account as sensitive unsensitive_account: Undo Force-Sensitive Account
unsilence_account: Unsilence Account unsilence_account: Undo Limit Account
unsuspend_account: Unsuspend Account unsuspend_account: Unsuspend Account
update_announcement: Update Announcement update_announcement: Update Announcement
update_custom_emoji: Update Custom Emoji update_custom_emoji: Update Custom Emoji
@ -285,8 +293,10 @@ en:
destroy_unavailable_domain_html: "%{name} resumed delivery to domain %{target}" destroy_unavailable_domain_html: "%{name} resumed delivery to domain %{target}"
disable_2fa_user_html: "%{name} disabled two factor requirement for user %{target}" disable_2fa_user_html: "%{name} disabled two factor requirement for user %{target}"
disable_custom_emoji_html: "%{name} disabled emoji %{target}" disable_custom_emoji_html: "%{name} disabled emoji %{target}"
disable_sign_in_token_auth_user_html: "%{name} disabled e-mail token authentication for %{target}"
disable_user_html: "%{name} disabled login for user %{target}" disable_user_html: "%{name} disabled login for user %{target}"
enable_custom_emoji_html: "%{name} enabled emoji %{target}" enable_custom_emoji_html: "%{name} enabled emoji %{target}"
enable_sign_in_token_auth_user_html: "%{name} enabled e-mail token authentication for %{target}"
enable_user_html: "%{name} enabled login for user %{target}" enable_user_html: "%{name} enabled login for user %{target}"
memorialize_account_html: "%{name} turned %{target}'s account into a memoriam page" memorialize_account_html: "%{name} turned %{target}'s account into a memoriam page"
promote_user_html: "%{name} promoted user %{target}" promote_user_html: "%{name} promoted user %{target}"
@ -295,11 +305,11 @@ en:
reset_password_user_html: "%{name} reset password of user %{target}" reset_password_user_html: "%{name} reset password of user %{target}"
resolve_report_html: "%{name} resolved report %{target}" resolve_report_html: "%{name} resolved report %{target}"
sensitive_account_html: "%{name} marked %{target}'s media as sensitive" sensitive_account_html: "%{name} marked %{target}'s media as sensitive"
silence_account_html: "%{name} silenced %{target}'s account" silence_account_html: "%{name} limited %{target}'s account"
suspend_account_html: "%{name} suspended %{target}'s account" suspend_account_html: "%{name} suspended %{target}'s account"
unassigned_report_html: "%{name} unassigned report %{target}" unassigned_report_html: "%{name} unassigned report %{target}"
unsensitive_account_html: "%{name} unmarked %{target}'s media as sensitive" unsensitive_account_html: "%{name} unmarked %{target}'s media as sensitive"
unsilence_account_html: "%{name} unsilenced %{target}'s account" unsilence_account_html: "%{name} undid limit of %{target}'s account"
unsuspend_account_html: "%{name} unsuspended %{target}'s account" unsuspend_account_html: "%{name} unsuspended %{target}'s account"
update_announcement_html: "%{name} updated announcement %{target}" update_announcement_html: "%{name} updated announcement %{target}"
update_custom_emoji_html: "%{name} updated emoji %{target}" update_custom_emoji_html: "%{name} updated emoji %{target}"
@ -421,14 +431,14 @@ en:
rejecting_media: rejecting media files rejecting_media: rejecting media files
rejecting_reports: rejecting reports rejecting_reports: rejecting reports
severity: severity:
silence: silenced silence: limited
suspend: suspended suspend: suspended
show: show:
affected_accounts: affected_accounts:
one: One account in the database affected one: One account in the database affected
other: "%{count} accounts in the database affected" other: "%{count} accounts in the database affected"
retroactive: retroactive:
silence: Unsilence existing affected accounts from this domain silence: Undo limit of existing affected accounts from this domain
suspend: Unsuspend existing affected accounts from this domain suspend: Unsuspend existing affected accounts from this domain
title: Undo domain block for %{domain} title: Undo domain block for %{domain}
undo: Undo undo: Undo

@ -283,6 +283,7 @@ Rails.application.routes.draw do
resources :users, only: [] do resources :users, only: [] do
resource :two_factor_authentication, only: [:destroy] resource :two_factor_authentication, only: [:destroy]
resource :sign_in_token_authentication, only: [:create, :destroy]
end end
resources :custom_emojis, only: [:index, :new, :create] do resources :custom_emojis, only: [:index, :new, :create] do

@ -0,0 +1,5 @@
class AddSkipSignInTokenToUsers < ActiveRecord::Migration[6.1]
def change
add_column :users, :skip_sign_in_token, :boolean
end
end

@ -927,6 +927,7 @@ ActiveRecord::Schema.define(version: 2021_06_30_000137) do
t.datetime "sign_in_token_sent_at" t.datetime "sign_in_token_sent_at"
t.string "webauthn_id" t.string "webauthn_id"
t.inet "sign_up_ip" t.inet "sign_up_ip"
t.boolean "skip_sign_in_token"
t.index ["account_id"], name: "index_users_on_account_id" t.index ["account_id"], name: "index_users_on_account_id"
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["created_by_application_id"], name: "index_users_on_created_by_application_id" t.index ["created_by_application_id"], name: "index_users_on_created_by_application_id"

@ -54,7 +54,8 @@ module Mastodon
option :email, required: true option :email, required: true
option :confirmed, type: :boolean option :confirmed, type: :boolean
option :role, default: 'user' option :role, default: 'user', enum: %w(user moderator admin)
option :skip_sign_in_token, type: :boolean
option :reattach, type: :boolean option :reattach, type: :boolean
option :force, type: :boolean option :force, type: :boolean
desc 'create USERNAME', 'Create a new user' desc 'create USERNAME', 'Create a new user'
@ -68,6 +69,9 @@ module Mastodon
With the --role option one of "user", "admin" or "moderator" With the --role option one of "user", "admin" or "moderator"
can be supplied. Defaults to "user" can be supplied. Defaults to "user"
With the --skip-sign-in-token option, you can ensure that
the user is never asked for an e-mailed security code.
With the --reattach option, the new user will be reattached With the --reattach option, the new user will be reattached
to a given existing username of an old account. If the old to a given existing username of an old account. If the old
account is still in use by someone else, you can supply account is still in use by someone else, you can supply
@ -77,7 +81,7 @@ module Mastodon
def create(username) def create(username)
account = Account.new(username: username) account = Account.new(username: username)
password = SecureRandom.hex password = SecureRandom.hex
user = User.new(email: options[:email], password: password, agreement: true, approved: true, admin: options[:role] == 'admin', moderator: options[:role] == 'moderator', confirmed_at: options[:confirmed] ? Time.now.utc : nil, bypass_invite_request_check: true) user = User.new(email: options[:email], password: password, agreement: true, approved: true, admin: options[:role] == 'admin', moderator: options[:role] == 'moderator', confirmed_at: options[:confirmed] ? Time.now.utc : nil, bypass_invite_request_check: true, skip_sign_in_token: options[:skip_sign_in_token])
if options[:reattach] if options[:reattach]
account = Account.find_local(username) || Account.new(username: username) account = Account.find_local(username) || Account.new(username: username)
@ -113,7 +117,7 @@ module Mastodon
end end
end end
option :role option :role, enum: %w(user moderator admin)
option :email option :email
option :confirm, type: :boolean option :confirm, type: :boolean
option :enable, type: :boolean option :enable, type: :boolean
@ -121,6 +125,7 @@ module Mastodon
option :disable_2fa, type: :boolean option :disable_2fa, type: :boolean
option :approve, type: :boolean option :approve, type: :boolean
option :reset_password, type: :boolean option :reset_password, type: :boolean
option :skip_sign_in_token, type: :boolean
desc 'modify USERNAME', 'Modify a user' desc 'modify USERNAME', 'Modify a user'
long_desc <<-LONG_DESC long_desc <<-LONG_DESC
Modify a user account. Modify a user account.
@ -142,6 +147,9 @@ module Mastodon
With the --reset-password option, the user's password is replaced by With the --reset-password option, the user's password is replaced by
a randomly-generated one, printed in the output. a randomly-generated one, printed in the output.
With the --skip-sign-in-token option, you can ensure that
the user is never asked for an e-mailed security code.
LONG_DESC LONG_DESC
def modify(username) def modify(username)
user = Account.find_local(username)&.user user = Account.find_local(username)&.user
@ -163,6 +171,7 @@ module Mastodon
user.disabled = true if options[:disable] user.disabled = true if options[:disable]
user.approved = true if options[:approve] user.approved = true if options[:approve]
user.otp_required_for_login = false if options[:disable_2fa] user.otp_required_for_login = false if options[:disable_2fa]
user.skip_sign_in_token = options[:skip_sign_in_token] unless options[:skip_sign_in_token].nil?
user.confirm if options[:confirm] user.confirm if options[:confirm]
if user.save if user.save

@ -16,7 +16,7 @@ describe Admin::ResetsController do
post :create, params: { account_id: account.id } post :create, params: { account_id: account.id }
expect(response).to redirect_to(admin_accounts_path) expect(response).to redirect_to(admin_account_path(account.id))
end end
end end
end end

@ -15,12 +15,12 @@ describe Admin::TwoFactorAuthenticationsController do
user.update(otp_required_for_login: true) user.update(otp_required_for_login: true)
end end
it 'redirects to admin accounts page' do it 'redirects to admin account page' do
delete :destroy, params: { user_id: user.id } delete :destroy, params: { user_id: user.id }
user.reload user.reload
expect(user.otp_enabled?).to eq false expect(user.otp_enabled?).to eq false
expect(response).to redirect_to(admin_accounts_path) expect(response).to redirect_to(admin_account_path(user.account_id))
end end
end end
@ -38,13 +38,13 @@ describe Admin::TwoFactorAuthenticationsController do
nickname: 'Security Key') nickname: 'Security Key')
end end
it 'redirects to admin accounts page' do it 'redirects to admin account page' do
delete :destroy, params: { user_id: user.id } delete :destroy, params: { user_id: user.id }
user.reload user.reload
expect(user.otp_enabled?).to eq false expect(user.otp_enabled?).to eq false
expect(user.webauthn_enabled?).to eq false expect(user.webauthn_enabled?).to eq false
expect(response).to redirect_to(admin_accounts_path) expect(response).to redirect_to(admin_account_path(user.account_id))
end end
end end
end end

@ -344,6 +344,34 @@ RSpec.describe User, type: :model do
end end
end end
describe '#reset_password!' do
subject(:user) { Fabricate(:user, password: 'foobar12345') }
let!(:session_activation) { Fabricate(:session_activation, user: user) }
let!(:access_token) { Fabricate(:access_token, resource_owner_id: user.id) }
let!(:web_push_subscription) { Fabricate(:web_push_subscription, access_token: access_token) }
before do
user.reset_password!
end
it 'changes the password immediately' do
expect(user.external_or_valid_password?('foobar12345')).to be false
end
it 'deactivates all sessions' do
expect(user.session_activations.count).to eq 0
end
it 'revokes all access tokens' do
expect(Doorkeeper::AccessToken.active_for(user).count).to eq 0
end
it 'removes push subscriptions' do
expect(Web::PushSubscription.where(user: user).or(Web::PushSubscription.where(access_token: access_token)).count).to eq 0
end
end
describe '#confirm!' do describe '#confirm!' do
subject(:user) { Fabricate(:user, confirmed_at: confirmed_at) } subject(:user) { Fabricate(:user, confirmed_at: confirmed_at) }

Loading…
Cancel
Save