aikotoba 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +346 -198
  3. data/app/controllers/aikotoba/accounts_controller.rb +6 -1
  4. data/app/controllers/aikotoba/application_controller.rb +7 -1
  5. data/app/controllers/aikotoba/confirms_controller.rb +2 -2
  6. data/app/controllers/aikotoba/magic_links_controller.rb +103 -0
  7. data/app/controllers/aikotoba/recoveries_controller.rb +2 -2
  8. data/app/controllers/aikotoba/sessions_controller.rb +2 -2
  9. data/app/controllers/aikotoba/unlocks_controller.rb +2 -2
  10. data/app/controllers/concerns/aikotoba/authenticatable.rb +1 -22
  11. data/app/controllers/concerns/aikotoba/protection/rate_limiting.rb +2 -11
  12. data/app/controllers/concerns/aikotoba/request_backable.rb +53 -0
  13. data/app/controllers/concerns/aikotoba/scopable.rb +5 -5
  14. data/app/mailers/aikotoba/account_mailer.rb +35 -3
  15. data/app/models/aikotoba/account/confirmation_token.rb +6 -1
  16. data/app/models/aikotoba/account/magic_link.rb +38 -0
  17. data/app/models/aikotoba/account/magic_link_token.rb +27 -0
  18. data/app/models/aikotoba/account/{password → password_hash}/argon2.rb +1 -1
  19. data/app/models/aikotoba/account/password_hash.rb +97 -0
  20. data/app/models/aikotoba/account/recovery.rb +27 -1
  21. data/app/models/aikotoba/account/recovery_token.rb +6 -1
  22. data/app/models/aikotoba/account/session.rb +5 -3
  23. data/app/models/aikotoba/account/unlock_token.rb +6 -1
  24. data/app/models/aikotoba/account.rb +80 -60
  25. data/app/models/concerns/aikotoba/enabled_feature_checkable.rb +8 -0
  26. data/app/models/concerns/aikotoba/optional_association.rb +82 -0
  27. data/app/models/concerns/aikotoba/token_encryptable.rb +1 -11
  28. data/app/views/aikotoba/account_mailer/magic_link.html.erb +3 -0
  29. data/app/views/aikotoba/magic_links/new.html.erb +14 -0
  30. data/app/views/aikotoba/sessions/_links.html.erb +6 -1
  31. data/config/locales/en.yml +10 -0
  32. data/config/routes.rb +25 -16
  33. data/db/migrate/20211204121532_create_aikotoba_accounts.rb +77 -33
  34. data/lib/aikotoba/configuration.rb +99 -0
  35. data/lib/aikotoba/constraints/magic_link_authenticatable_constraint.rb +7 -0
  36. data/lib/aikotoba/engine.rb +13 -0
  37. data/lib/aikotoba/errors.rb +2 -0
  38. data/lib/aikotoba/scope.rb +64 -0
  39. data/lib/aikotoba/scopes.rb +102 -0
  40. data/lib/aikotoba/test/authentication_helper.rb +4 -4
  41. data/lib/aikotoba/version.rb +1 -1
  42. data/lib/aikotoba.rb +33 -67
  43. metadata +16 -5
  44. data/app/models/aikotoba/account/password.rb +0 -34
@@ -27,7 +27,12 @@ module Aikotoba
27
27
  end
28
28
 
29
29
  def build_account(params)
30
- Account.build_by(attributes: params)
30
+ # NOTE: This is the password sign-up form, so it must always attempt to build a
31
+ # password credential — otherwise an omitted :password key (as opposed to a
32
+ # blank one) makes Account.build_by skip it entirely, silently registering an
33
+ # account no auth method can ever sign into. Defaulting the key to "" ensures
34
+ # Account::PasswordHash's own presence validation is what actually rejects it.
35
+ Account.build_by(attributes: {password: ""}.merge(params))
31
36
  end
32
37
 
33
38
  def save_with_callbacks!(account)
@@ -5,10 +5,16 @@ module Aikotoba
5
5
  include EnabledFeatureCheckable
6
6
  include Scopable
7
7
 
8
- helper_method :confirmable?, :lockable?, :recoverable?, :registerable?, :aikotoba_scoped_path, :aikotoba_scoped_path, :aikotoba_scope
8
+ helper_method :confirmable?, :lockable?, :recoverable?, :registerable?, :magic_link_authenticatable?, :aikotoba_scoped_path, :aikotoba_scope, :aikotoba_scope_registerable?
9
9
 
10
10
  def aikotoba_controller?
11
11
  true
12
12
  end
13
+
14
+ private
15
+
16
+ def aikotoba_scope_registerable?
17
+ registerable? && aikotoba_scope_config.registerable?
18
+ end
13
19
  end
14
20
  end
@@ -46,7 +46,7 @@ module Aikotoba
46
46
  end
47
47
 
48
48
  def find_by_send_token_account!(params)
49
- Account.unconfirmed.find_by!(email: params[:email])
49
+ Account.for_target_type(aikotoba_authenticate_target).unconfirmed.find_by!(email: params[:email])
50
50
  end
51
51
 
52
52
  def send_token_account!(account)
@@ -54,7 +54,7 @@ module Aikotoba
54
54
  end
55
55
 
56
56
  def find_by_has_token_account!(params)
57
- Account::ConfirmationToken.active.find_by!(token: params[:token]).account
57
+ Account::ConfirmationToken.active.for_target_type(aikotoba_authenticate_target).find_by!(token: params[:token]).account
58
58
  end
59
59
 
60
60
  def confirm_account!(account)
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikotoba
4
+ class MagicLinksController < ApplicationController
5
+ include Authenticatable
6
+ include Protection::RateLimiting
7
+
8
+ def self.magic_link_rate_limit_options
9
+ Aikotoba.magic_link_rate_limit_options
10
+ end
11
+ private_class_method :magic_link_rate_limit_options
12
+
13
+ rate_limit(**magic_link_rate_limit_options)
14
+
15
+ def new
16
+ @account = build_account({email: ""})
17
+ end
18
+
19
+ def create
20
+ account = find_by_send_token_account!(magic_link_accounts_params)
21
+ before_send_magic_link_token_process
22
+ send_magic_link_token!(account)
23
+ after_send_magic_link_token_process
24
+ rescue ActiveRecord::RecordNotFound => e
25
+ failed_send_magic_link_token_process(e)
26
+ ensure
27
+ # NOTE: Always show success message to avoid account enumeration.
28
+ redirect_to success_send_magic_link_token_path, flash: {notice: success_send_magic_link_token_message}
29
+ end
30
+
31
+ def update
32
+ # NOTE: Sign-in is done using a URL token (a GET request), so both consuming the
33
+ # token and establishing the session are done in the writing role -- unlike
34
+ # Confirm/Unlock, which only flip a boolean flag, this also creates an
35
+ # Account::Session record via aikotoba_sign_in.
36
+ ActiveRecord::Base.connected_to(role: :writing) do
37
+ account = authenticate_account!(params[:token])
38
+ before_sign_in_process
39
+ aikotoba_sign_in(account)
40
+ after_sign_in_process
41
+ end
42
+ redirect_to after_sign_in_path, flash: {notice: successed_message}
43
+ end
44
+
45
+ private
46
+
47
+ def magic_link_accounts_params
48
+ params.require(:account).permit(:email)
49
+ end
50
+
51
+ def build_account(params)
52
+ Account.new(params)
53
+ end
54
+
55
+ def find_by_send_token_account!(params)
56
+ Account.for_target_type(aikotoba_authenticate_target).find_by!(email: params[:email])
57
+ end
58
+
59
+ def send_magic_link_token!(account)
60
+ Account::MagicLink.create_token!(account: account, notify: true)
61
+ end
62
+
63
+ def authenticate_account!(token)
64
+ Account::MagicLink.authenticate_by(token: token, target_type_name: aikotoba_authenticate_target) || raise(ActiveRecord::RecordNotFound)
65
+ end
66
+
67
+ def after_sign_in_path
68
+ aikotoba_after_authentication_path
69
+ end
70
+
71
+ def success_send_magic_link_token_path
72
+ aikotoba_scoped_path(:new_session_path)
73
+ end
74
+
75
+ def successed_message
76
+ I18n.t(".aikotoba.messages.authentication.success")
77
+ end
78
+
79
+ def success_send_magic_link_token_message
80
+ I18n.t(".aikotoba.messages.magic_link.sent")
81
+ end
82
+
83
+ # NOTE: Methods to override if you want to do something before send magic link token.
84
+ def before_send_magic_link_token_process
85
+ end
86
+
87
+ # NOTE: Methods to override if you want to do something after send magic link token.
88
+ def after_send_magic_link_token_process
89
+ end
90
+
91
+ # NOTE: Methods to override if you want to do something failed send magic link token.
92
+ def failed_send_magic_link_token_process(e)
93
+ end
94
+
95
+ # NOTE: Methods to override if you want to do something before sign in.
96
+ def before_sign_in_process
97
+ end
98
+
99
+ # NOTE: Methods to override if you want to do something after sign in.
100
+ def after_sign_in_process
101
+ end
102
+ end
103
+ end
@@ -58,11 +58,11 @@ module Aikotoba
58
58
  end
59
59
 
60
60
  def find_by_send_token_account!(params)
61
- Account.find_by!(email: params[:email])
61
+ Account.for_target_type(aikotoba_authenticate_target).find_by!(email: params[:email])
62
62
  end
63
63
 
64
64
  def find_by_has_token_account!(params)
65
- Account::RecoveryToken.active.find_by!(token: params[:token]).account
65
+ Account::RecoveryToken.active.for_target_type(aikotoba_authenticate_target).find_by!(token: params[:token]).account
66
66
  end
67
67
 
68
68
  def send_recovery_token!(account)
@@ -40,11 +40,11 @@ module Aikotoba
40
40
  end
41
41
 
42
42
  def authenticate_account(params)
43
- Account.authenticate_by(attributes: params, target_type_name: aikotoba_authenticate_target)
43
+ Account::PasswordHash.authenticate_by(attributes: params, target_type_name: aikotoba_authenticate_target)
44
44
  end
45
45
 
46
46
  def after_sign_in_path
47
- aikotoba_scope_config[:after_sign_in_path]
47
+ aikotoba_after_authentication_path
48
48
  end
49
49
 
50
50
  def after_sign_out_path
@@ -46,7 +46,7 @@ module Aikotoba
46
46
  end
47
47
 
48
48
  def find_by_send_token_account!(params)
49
- Account.locked.find_by!(email: params[:email])
49
+ Account.for_target_type(aikotoba_authenticate_target).locked.find_by!(email: params[:email])
50
50
  end
51
51
 
52
52
  def send_token_account!(account)
@@ -54,7 +54,7 @@ module Aikotoba
54
54
  end
55
55
 
56
56
  def find_by_has_token_account!(params)
57
- Account::UnlockToken.active.find_by!(token: params[:token]).account
57
+ Account::UnlockToken.active.for_target_type(aikotoba_authenticate_target).find_by!(token: params[:token]).account
58
58
  end
59
59
 
60
60
  def unlock_account!(account)
@@ -3,6 +3,7 @@ module Aikotoba
3
3
  extend ActiveSupport::Concern
4
4
  include Protection::SessionFixationAttack
5
5
  include Scopable
6
+ include RequestBackable
6
7
 
7
8
  included do
8
9
  helper_method :aikotoba_current_account
@@ -41,12 +42,6 @@ module Aikotoba
41
42
  # Deleting only the cookie will require explicit re-login, and the situation where only the DB session remains can also occur
42
43
  # when the cookie is directly deleted on the browser side, so we decided to allow it.
43
44
  terminate_aikotoba_session(only_cookie: true)
44
- # NOTE: This is to maintain the session logged in by the old login process, so if you find the account from the session, log in.
45
- # TODO: Remove this process in future.
46
- if Aikotoba.keep_legacy_login_session && session[aikotoba_session_key].present?
47
- account = find_aikotoba_account_by_legacy_session
48
- account_session = start_new_aikotoba_session_for(account) if account.present?
49
- end
50
45
  end
51
46
  account_session
52
47
  end
@@ -56,12 +51,6 @@ module Aikotoba
56
51
  aikotoba_session_find_by(token) if token.present?
57
52
  end
58
53
 
59
- # NOTE: This is to maintain the session logged in by the old login process, so if you find the account from the session, log in.
60
- # TODO: Remove this process in future.
61
- def find_aikotoba_account_by_legacy_session
62
- aikotoba_authenticatable_accounts.find_by(id: session[aikotoba_session_key])
63
- end
64
-
65
54
  def start_new_aikotoba_session_for(account)
66
55
  prevent_session_fixation_attack
67
56
  @aikotoba_current_session = Aikotoba::Account::Session.start!(account: account, **aikotoba_session_meta_from_request)
@@ -99,18 +88,8 @@ module Aikotoba
99
88
  }
100
89
  end
101
90
 
102
- # NOTE: Authenticate by target type if specified in the scope configuration.
103
- # TODO: Remove this process in future.
104
- def aikotoba_authenticatable_accounts
105
- Account.authenticatable(target_type_name: aikotoba_authenticate_target)
106
- end
107
-
108
91
  def aikotoba_session_find_by(token)
109
92
  Account::Session.find_by_token(token, target_type_name: aikotoba_authenticate_target)
110
93
  end
111
-
112
- def aikotoba_authenticate_target
113
- aikotoba_scope_config[:authenticate_for]
114
- end
115
94
  end
116
95
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Rate limiting protection for email-sending endpoints to prevent email bombing attacks.
4
- # Requires Rails 8+ to use the built-in rate_limit feature.
4
+ # Uses Rails' built-in rate_limit feature.
5
5
  #
6
6
  # Usage:
7
7
  # class MyController < ApplicationController
@@ -16,16 +16,7 @@ module Aikotoba
16
16
  def rate_limit(**options)
17
17
  return if options.empty? # Allow empty config (disabled by default)
18
18
 
19
- if available_rails_rate_limiting?
20
- super
21
- else
22
- raise Errors::NotAvailableException,
23
- "Rate limiting requires Rails 8+. Current version: #{Rails::VERSION::STRING}"
24
- end
25
- end
26
-
27
- def available_rails_rate_limiting?
28
- Rails::VERSION::MAJOR >= 8
19
+ super
29
20
  end
30
21
  end
31
22
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikotoba
4
+ module RequestBackable
5
+ extend ActiveSupport::Concern
6
+ include Scopable
7
+
8
+ # NOTE: Modeled on the Rails authentication generator's
9
+ # `request_authentication` / `after_authentication_url` pair. Call this
10
+ # from the host app's own `authenticate_account!`-style before_action
11
+ # instead of a bare `redirect_to` to the sign in page. The engine can't
12
+ # capture the destination on its own: the host owns that redirect, and the
13
+ # `Referer` the browser sends to `GET /sign_in` is the page that *linked*
14
+ # to the protected page, not the protected page itself (a redirect never
15
+ # updates the Referer).
16
+ def aikotoba_request_authentication(**redirect_options)
17
+ aikotoba_store_return_to_path
18
+ redirect_to aikotoba_scoped_path(:new_session_path), **redirect_options
19
+ end
20
+
21
+ private
22
+
23
+ # NOTE: Not session[key] -- deleting makes this single-use, so a stale
24
+ # return_to can't be replayed on a later sign in.
25
+ def aikotoba_after_authentication_path
26
+ session.delete(aikotoba_return_to_session_key) || aikotoba_scope_config[:after_sign_in_path]
27
+ end
28
+
29
+ # NOTE: Unlike the Rails generator, which stores `request.url`, store only
30
+ # the host-less `fullpath`, so the stored value can never point at
31
+ # another host.
32
+ def aikotoba_store_return_to_path
33
+ path = request.fullpath
34
+ session[aikotoba_return_to_session_key] = path if aikotoba_storable_return_to_path?(path)
35
+ end
36
+
37
+ # NOTE: Only a page the browser can be sent back to with a plain GET is
38
+ # stored -- redirecting back to a POST/XHR/JSON endpoint after sign in
39
+ # would replay it as a GET and fail. `fullpath` never carries a host, but
40
+ # a request for "//evil.example.com/x" (or "/\evil.example.com/x", which
41
+ # browsers treat the same) still yields a protocol-relative URL once
42
+ # redirected to -- Rails only started guarding against this in
43
+ # redirect_to itself in 7.1, and this gem supports Rails >= 6.1.4.
44
+ def aikotoba_storable_return_to_path?(path)
45
+ return false unless request.get? && !request.xhr? && request.format.html?
46
+ path.start_with?("/") && !path.start_with?("//", "/\\")
47
+ end
48
+
49
+ def aikotoba_return_to_session_key
50
+ "#{aikotoba_scope_config[:session_key]}_return_to_after_authenticating"
51
+ end
52
+ end
53
+ end
@@ -18,19 +18,19 @@ module Aikotoba
18
18
 
19
19
  private
20
20
 
21
+ def aikotoba_authenticate_target
22
+ aikotoba_scope_config.authenticate_for
23
+ end
24
+
21
25
  def aikotoba_calc_scope_from(request_path)
22
26
  scope = :default
23
27
  Aikotoba.scopes.except(:default).each do |name, config|
24
- if aikotoba_from_scope_path?(config[:root_path])
28
+ if config.covers?(request_path)
25
29
  scope = name
26
30
  break # NOTE: Adopt the first matching scope
27
31
  end
28
32
  end
29
33
  scope
30
34
  end
31
-
32
- def aikotoba_from_scope_path?(scope_root_path)
33
- request.path == scope_root_path || request.path.start_with?("#{scope_root_path}/")
34
- end
35
35
  end
36
36
  end
@@ -1,24 +1,56 @@
1
1
  module Aikotoba
2
+ # NOTE: These run from deliver_later, so the token is read when the job runs, not when
3
+ # it was enqueued. Reading the account's current token is deliberate: issuing a new
4
+ # token destroys the previous one, so the latest is the only link that still works.
5
+ # It can also be gone by then (used, or cleared by unlock!), in which case nothing is
6
+ # sent -- returning without calling `mail` makes the delivery a no-op.
2
7
  class AccountMailer < ApplicationMailer
3
8
  def confirm
4
9
  @account = params[:account]
5
10
  @token = @account.confirmation_token
6
- @confirm_url = aikotoba.confirm_account_url(token: @token.token)
11
+ return unless @token
12
+
13
+ @confirm_url = aikotoba_scoped_url(:confirm_account_url, token: @token.token)
7
14
  mail(to: @account.email, subject: I18n.t(".aikotoba.mailers.confirm.subject"))
8
15
  end
9
16
 
10
17
  def unlock
11
18
  @account = params[:account]
12
19
  @token = @account.unlock_token
13
- @unlock_url = aikotoba.unlock_account_url(token: @token.token)
20
+ return unless @token
21
+
22
+ @unlock_url = aikotoba_scoped_url(:unlock_account_url, token: @token.token)
14
23
  mail(to: @account.email, subject: I18n.t(".aikotoba.mailers.unlock.subject"))
15
24
  end
16
25
 
17
26
  def recover
18
27
  @account = params[:account]
19
28
  @token = @account.recovery_token
20
- @recover_url = aikotoba.edit_account_password_url(token: @token.token)
29
+ return unless @token
30
+
31
+ @recover_url = aikotoba_scoped_url(:edit_account_password_url, token: @token.token)
21
32
  mail(to: @account.email, subject: I18n.t(".aikotoba.mailers.recover.subject"))
22
33
  end
34
+
35
+ def magic_link
36
+ @account = params[:account]
37
+ @token = @account.magic_link_token
38
+ return unless @token
39
+
40
+ @magic_link_url = aikotoba_scoped_url(:authenticate_via_magic_link_url, token: @token.token)
41
+ mail(to: @account.email, subject: I18n.t(".aikotoba.mailers.magic_link.subject"))
42
+ end
43
+
44
+ private
45
+
46
+ def aikotoba_scoped_url(route, **params)
47
+ scope = Aikotoba.scopes.name_for(@account.authenticate_target_type)
48
+ name = (scope == "default") ? route : :"#{scope}_#{route}"
49
+ # NOTE: The name is built from config, never from request input, but only ever
50
+ # public_send an engine route helper, not other public methods on the proxy.
51
+ raise ArgumentError, "not an Aikotoba route helper: #{name}" unless Aikotoba::Engine.routes.named_routes.route_defined?(name)
52
+
53
+ aikotoba.public_send(name, **params)
54
+ end
23
55
  end
24
56
  end
@@ -9,6 +9,7 @@ module Aikotoba
9
9
  validates :expired_at, presence: true
10
10
 
11
11
  scope :active, ->(now: Time.current) { where("expired_at >= ?", now) }
12
+ scope :for_target_type, ->(target_type_name) { joins(:account).merge(Account.for_target_type(target_type_name)) }
12
13
 
13
14
  after_initialize do |record|
14
15
  token = Account::Token.new(expiry: Aikotoba.confirmation_token_expiry)
@@ -16,8 +17,12 @@ module Aikotoba
16
17
  record.expired_at ||= token.expired_at
17
18
  end
18
19
 
20
+ # NOTE: Waits for every open transaction (including callers' outer ones) to commit,
21
+ # and is dropped on rollback, so a sent link never points at a missing token.
19
22
  def notify
20
- AccountMailer.with(account: account).confirm.deliver_now
23
+ ActiveRecord.after_all_transactions_commit do
24
+ AccountMailer.with(account: account).confirm.deliver_later
25
+ end
21
26
  end
22
27
  end
23
28
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikotoba
4
+ class Account::MagicLink
5
+ def self.create_token!(account:, notify: false)
6
+ new(account: account).create_token!(notify: notify)
7
+ end
8
+
9
+ def self.authenticate_by(token:, target_type_name: nil)
10
+ account = Account.authenticatable(target_type_name: target_type_name)
11
+ .joins(:magic_link_token)
12
+ .merge(Account::MagicLinkToken.active.where(token: token))
13
+ .first
14
+ return unless account
15
+
16
+ new(account: account).authenticate!
17
+ end
18
+
19
+ def initialize(account:)
20
+ @account = account
21
+ end
22
+
23
+ def create_token!(notify:)
24
+ ActiveRecord::Base.transaction do
25
+ @account.build_magic_link_token.save!
26
+ @account.magic_link_token.notify if notify
27
+ end
28
+ end
29
+
30
+ def authenticate!
31
+ ActiveRecord::Base.transaction do
32
+ @account.authentication_success!
33
+ @account.magic_link_token.destroy!
34
+ end
35
+ @account
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikotoba
4
+ class Account::MagicLinkToken < ApplicationRecord
5
+ include TokenEncryptable
6
+
7
+ belongs_to :account, class_name: "Aikotoba::Account", foreign_key: "aikotoba_account_id"
8
+ validates :token, presence: true
9
+ validates :expired_at, presence: true
10
+
11
+ scope :active, ->(now: Time.current) { where("expired_at >= ?", now) }
12
+
13
+ after_initialize do |record|
14
+ token = Account::Token.new(expiry: Aikotoba.magic_link_token_expiry)
15
+ record.token ||= token.value
16
+ record.expired_at ||= token.expired_at
17
+ end
18
+
19
+ # NOTE: Waits for every open transaction (including callers' outer ones) to commit,
20
+ # and is dropped on rollback, so a sent link never points at a missing token.
21
+ def notify
22
+ ActiveRecord.after_all_transactions_commit do
23
+ AccountMailer.with(account: account).magic_link.deliver_later
24
+ end
25
+ end
26
+ end
27
+ end
@@ -3,7 +3,7 @@
3
3
  require "argon2"
4
4
 
5
5
  module Aikotoba
6
- class Account::Password::Argon2
6
+ class Account::PasswordHash::Argon2
7
7
  def initialize(password:)
8
8
  @password = password
9
9
  end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikotoba
4
+ class Account::PasswordHash < ApplicationRecord
5
+ LENGTH_RANGE = Aikotoba.password_length_range
6
+
7
+ belongs_to :account, class_name: "Aikotoba::Account", foreign_key: "aikotoba_account_id", inverse_of: :password_hash
8
+
9
+ # NOTE: No separate `validates :digest, presence: true`. `digest` only ever ends up
10
+ # blank as a direct side effect of `generate` being given a blank input, which
11
+ # always fails this same `plaintext` validation at the same time -- a digest
12
+ # presence check would only ever fire alongside this one, never independently.
13
+ # The DB's `null: false` constraint still guards against `digest` ending up nil
14
+ # through some other path.
15
+ #
16
+ # NOTE: Deliberately no `on:` restriction (used to be `on: [:create, :recover]`).
17
+ # Nested has_one/belongs_to autosave validation uses the parent's context when
18
+ # one is explicitly given (e.g. `save!(context: :recover)`), but otherwise
19
+ # validates an already-persisted child under its own natural `:update` context
20
+ # -- so a scoped `on:` here let `account.password_hash.generate("short");
21
+ # account.save` silently skip this validation entirely for any *existing*
22
+ # account (only `#recover!`'s explicit `context: :recover` caught it). Running
23
+ # unconditionally closes that gap; it's still cheap, because Rails only
24
+ # revalidates this record at all when it has pending changes to save (i.e. some
25
+ # code actually called `generate`) or a custom context was passed -- an
26
+ # untouched, already-persisted password_hash is never re-validated just because
27
+ # the parent account is saved for an unrelated reason.
28
+ validates :plaintext, presence: true, length: {in: LENGTH_RANGE}
29
+
30
+ def generate(input, pepper: Aikotoba.password_pepper, algorithm_class: Argon2)
31
+ @plaintext = input
32
+ self.digest = input.blank? ? "" : algorithm_class.new(password: with_pepper(input, pepper)).generate_hash
33
+ end
34
+
35
+ def match?(input, pepper: Aikotoba.password_pepper, algorithm_class: Argon2)
36
+ algorithm_class.new(password: with_pepper(input, pepper)).verify_password?(digest)
37
+ end
38
+
39
+ def recover!(new_value)
40
+ generate(new_value)
41
+ save!(context: :recover)
42
+ end
43
+
44
+ class << self
45
+ def authenticate_by(attributes:, target_type_name: nil)
46
+ email, password = attributes.values_at(:email, :password)
47
+ # NOTE: A blank password can never match anything, so there's nothing to guard
48
+ # against timing-wise here -- the caller already knows their own input was
49
+ # blank, and returning instantly regardless of account state leaks nothing.
50
+ # This also means `password` is guaranteed non-blank below, so it's safe to
51
+ # use as the dummy digest's seed (see prevent_timing_attack).
52
+ return nil if password.blank?
53
+
54
+ account = Account.find_by_identifier(email, target_type_name: target_type_name)
55
+
56
+ matched = if account&.password_hash
57
+ account.password_hash.match?(password)
58
+ else
59
+ # NOTE: Runs whether account is nil (not found) or exists but has no
60
+ # password_hash (e.g. a magic-link-only account), so neither case is
61
+ # distinguishable from a wrong-password guess by response time.
62
+ prevent_timing_attack(password)
63
+ false
64
+ end
65
+ return nil unless account
66
+
67
+ ActiveRecord::Base.transaction do
68
+ if matched
69
+ account.authentication_success!
70
+ else
71
+ account.authentication_failed!
72
+ Account::Lock.lock!(account: account, notify: true) if account.lockable? && account.should_lock?
73
+ end
74
+ end
75
+ matched ? account : nil
76
+ end
77
+
78
+ private
79
+
80
+ # NOTE: Seeds the dummy digest from the caller's own (guaranteed non-blank, per the
81
+ # guard in authenticate_by) password, so `generate` always pays full Argon2
82
+ # cost -- it only takes its blank-input shortcut for blank input, which can't
83
+ # reach here.
84
+ def prevent_timing_attack(password)
85
+ Account::PasswordHash.new.tap { |password_hash| password_hash.generate(password) }.match?(password)
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ attr_reader :plaintext
92
+
93
+ def with_pepper(input, pepper)
94
+ "#{input}-#{pepper}"
95
+ end
96
+ end
97
+ end
@@ -1,6 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Aikotoba
4
+ # NOTE: The web-facing shape here (token, controller, routes) is deliberately generic/
5
+ # Account-level, mirroring how SessionsController stays generic while delegating
6
+ # the actual credential check to Account::PasswordHash.authenticate_by -- a
7
+ # recovery token proves ownership of the account/email, which is an account-level
8
+ # concept, not a credential-specific one. But the *operation* this class performs
9
+ # is still hardcoded to password reset (`new_password:`, `@account.password_hash`)
10
+ # -- there's no polymorphism yet for recovering some other future credential type.
11
+ # If/when a second auth method needs its own recovery-like flow, revisit whether
12
+ # this should generalize or whether that method should own a separate mechanism.
4
13
  class Account::Recovery
5
14
  def self.create_token!(account:, notify: false)
6
15
  new(account: account).create_token!(notify: notify)
@@ -22,10 +31,27 @@ module Aikotoba
22
31
  end
23
32
 
24
33
  def recover!(new_password:)
34
+ # NOTE: An account can legitimately have no password (e.g. a magic-link-only
35
+ # account), so there's nothing to recover. Surface this the same way an
36
+ # invalid new_password would be (RecordInvalid -> :password_hash error)
37
+ # instead of raising NoMethodError on a nil association.
38
+ unless @account.password_hash
39
+ @account.errors.add(:password_hash, :blank)
40
+ raise ActiveRecord::RecordInvalid, @account
41
+ end
42
+
25
43
  ActiveRecord::Base.transaction do
26
- @account.recover!(new_password: new_password)
44
+ @account.password_hash.recover!(new_password)
27
45
  @account.recovery_token&.destroy!
28
46
  end
47
+ rescue ActiveRecord::RecordInvalid => e
48
+ raise if e.record == @account
49
+ # NOTE: e.record here is @account.password_hash, not @account -- its own save!
50
+ # failed, so @account.errors is still empty. The controller re-renders using
51
+ # @account.errors, so copy the errors over (attribute: :password_hash also
52
+ # gives them a clean display label via config/locales/en.yml).
53
+ e.record.errors.each { |error| @account.errors.import(error, attribute: :password_hash) }
54
+ raise ActiveRecord::RecordInvalid, @account
29
55
  end
30
56
  end
31
57
  end