aikotoba 0.1.1 → 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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +431 -52
  3. data/Rakefile +0 -2
  4. data/app/controllers/aikotoba/accounts_controller.rb +8 -3
  5. data/app/controllers/aikotoba/application_controller.rb +8 -1
  6. data/app/controllers/aikotoba/confirms_controller.rb +16 -16
  7. data/app/controllers/aikotoba/magic_links_controller.rb +103 -0
  8. data/app/controllers/aikotoba/recoveries_controller.rb +16 -16
  9. data/app/controllers/aikotoba/sessions_controller.rb +3 -3
  10. data/app/controllers/aikotoba/unlocks_controller.rb +16 -16
  11. data/app/controllers/concerns/aikotoba/authenticatable.rb +72 -17
  12. data/app/controllers/concerns/aikotoba/protection/rate_limiting.rb +23 -0
  13. data/app/controllers/concerns/aikotoba/request_backable.rb +53 -0
  14. data/app/controllers/concerns/aikotoba/scopable.rb +36 -0
  15. data/app/mailers/aikotoba/account_mailer.rb +35 -3
  16. data/app/models/aikotoba/account/{service/confirmation.rb → confirmation.rb} +1 -1
  17. data/app/models/aikotoba/account/confirmation_token.rb +8 -2
  18. data/app/models/aikotoba/account/{service/lock.rb → lock.rb} +1 -1
  19. data/app/models/aikotoba/account/magic_link.rb +38 -0
  20. data/app/models/aikotoba/account/magic_link_token.rb +27 -0
  21. data/app/models/aikotoba/account/{password → password_hash}/argon2.rb +1 -1
  22. data/app/models/aikotoba/account/password_hash.rb +97 -0
  23. data/app/models/aikotoba/account/recovery.rb +57 -0
  24. data/app/models/aikotoba/account/recovery_token.rb +8 -2
  25. data/app/models/aikotoba/account/session.rb +42 -0
  26. data/app/models/aikotoba/account/token.rb +2 -2
  27. data/app/models/aikotoba/account/unlock_token.rb +8 -2
  28. data/app/models/aikotoba/account.rb +106 -28
  29. data/app/models/concerns/aikotoba/enabled_feature_checkable.rb +8 -0
  30. data/app/models/concerns/aikotoba/optional_association.rb +82 -0
  31. data/app/models/concerns/aikotoba/token_encryptable.rb +2 -12
  32. data/app/views/aikotoba/account_mailer/magic_link.html.erb +3 -0
  33. data/app/views/aikotoba/accounts/new.html.erb +4 -4
  34. data/app/views/aikotoba/confirms/new.html.erb +3 -3
  35. data/app/views/aikotoba/magic_links/new.html.erb +14 -0
  36. data/app/views/aikotoba/recoveries/edit.html.erb +3 -3
  37. data/app/views/aikotoba/recoveries/new.html.erb +3 -3
  38. data/app/views/aikotoba/sessions/_links.html.erb +10 -5
  39. data/app/views/aikotoba/sessions/new.html.erb +3 -3
  40. data/app/views/aikotoba/unlocks/new.html.erb +3 -3
  41. data/config/locales/en.yml +15 -6
  42. data/config/routes.rb +39 -22
  43. data/db/migrate/20211204121532_create_aikotoba_accounts.rb +77 -22
  44. data/lib/aikotoba/configuration.rb +99 -0
  45. data/lib/aikotoba/constraints/magic_link_authenticatable_constraint.rb +7 -0
  46. data/lib/aikotoba/engine.rb +13 -0
  47. data/lib/aikotoba/errors.rb +2 -0
  48. data/lib/aikotoba/scope.rb +64 -0
  49. data/lib/aikotoba/scopes.rb +102 -0
  50. data/lib/aikotoba/test/authentication_helper.rb +4 -4
  51. data/lib/aikotoba/version.rb +1 -1
  52. data/lib/aikotoba.rb +43 -37
  53. metadata +24 -17
  54. data/app/controllers/concerns/aikotoba/protection/timing_atack.rb +0 -23
  55. data/app/models/aikotoba/account/password.rb +0 -34
  56. data/app/models/aikotoba/account/service/authentication.rb +0 -66
  57. data/app/models/aikotoba/account/service/recovery.rb +0 -31
  58. data/app/models/aikotoba/account/service/registration.rb +0 -30
@@ -3,11 +3,18 @@
3
3
  module Aikotoba
4
4
  class ApplicationController < Aikotoba.parent_controller.constantize
5
5
  include EnabledFeatureCheckable
6
+ include Scopable
6
7
 
7
- helper_method :confirmable?, :lockable?, :recoverable?, :registerable?
8
+ helper_method :confirmable?, :lockable?, :recoverable?, :registerable?, :magic_link_authenticatable?, :aikotoba_scoped_path, :aikotoba_scope, :aikotoba_scope_registerable?
8
9
 
9
10
  def aikotoba_controller?
10
11
  true
11
12
  end
13
+
14
+ private
15
+
16
+ def aikotoba_scope_registerable?
17
+ registerable? && aikotoba_scope_config.registerable?
18
+ end
12
19
  end
13
20
  end
@@ -2,9 +2,14 @@
2
2
 
3
3
  module Aikotoba
4
4
  class ConfirmsController < ApplicationController
5
- include Protection::TimingAtack
5
+ include Protection::RateLimiting
6
6
 
7
- before_action :prevent_timing_atack, only: [:update]
7
+ def self.confirmation_rate_limit_options
8
+ Aikotoba.confirmation_rate_limit_options
9
+ end
10
+ private_class_method :confirmation_rate_limit_options
11
+
12
+ rate_limit(**confirmation_rate_limit_options)
8
13
 
9
14
  def new
10
15
  @account = build_account({email: "", password: ""})
@@ -15,12 +20,11 @@ module Aikotoba
15
20
  before_send_confirmation_token_process
16
21
  send_token_account!(account)
17
22
  after_send_confirmation_token_process
18
- redirect_to success_send_confirmation_token_path, flash: {notice: success_send_confirmation_token_message}
19
23
  rescue ActiveRecord::RecordNotFound => e
20
24
  failed_send_confirmation_token_process(e)
21
- @account = build_account({email: "", password: ""})
22
- flash[:alert] = failed_send_confirmation_token_message
23
- render :new, status: :unprocessable_entity
25
+ ensure
26
+ # NOTE: Always show success message to avoid account enumeration.
27
+ redirect_to success_send_confirmation_token_path, flash: {notice: success_send_confirmation_token_message}
24
28
  end
25
29
 
26
30
  def update
@@ -42,30 +46,30 @@ module Aikotoba
42
46
  end
43
47
 
44
48
  def find_by_send_token_account!(params)
45
- Account.unconfirmed.find_by!(email: params[:email])
49
+ Account.for_target_type(aikotoba_authenticate_target).unconfirmed.find_by!(email: params[:email])
46
50
  end
47
51
 
48
52
  def send_token_account!(account)
49
- Account::Service::Confirmation.create_token!(account: account, notify: true)
53
+ Account::Confirmation.create_token!(account: account, notify: true)
50
54
  end
51
55
 
52
56
  def find_by_has_token_account!(params)
53
- 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
54
58
  end
55
59
 
56
60
  def confirm_account!(account)
57
61
  # NOTE: Confirmation is done using URL tokens, so it is done in the writing role.
58
62
  ActiveRecord::Base.connected_to(role: :writing) do
59
- Account::Service::Confirmation.confirm!(account: account)
63
+ Account::Confirmation.confirm!(account: account)
60
64
  end
61
65
  end
62
66
 
63
67
  def after_confirmed_path
64
- aikotoba.new_session_path
68
+ aikotoba_scoped_path(:new_session_path)
65
69
  end
66
70
 
67
71
  def success_send_confirmation_token_path
68
- aikotoba.new_session_path
72
+ aikotoba_scoped_path(:new_session_path)
69
73
  end
70
74
 
71
75
  def confirmed_message
@@ -76,10 +80,6 @@ module Aikotoba
76
80
  I18n.t(".aikotoba.messages.confirmation.sent")
77
81
  end
78
82
 
79
- def failed_send_confirmation_token_message
80
- I18n.t(".aikotoba.messages.confirmation.failed")
81
- end
82
-
83
83
  # NOTE: Methods to override if you want to do something before send confirm token.
84
84
  def before_send_confirmation_token_process
85
85
  end
@@ -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
@@ -2,9 +2,14 @@
2
2
 
3
3
  module Aikotoba
4
4
  class RecoveriesController < ApplicationController
5
- include Protection::TimingAtack
5
+ include Protection::RateLimiting
6
6
 
7
- before_action :prevent_timing_atack, only: [:edit, :update]
7
+ def self.recovery_rate_limit_options
8
+ Aikotoba.recovery_rate_limit_options
9
+ end
10
+ private_class_method :recovery_rate_limit_options
11
+
12
+ rate_limit(**recovery_rate_limit_options)
8
13
 
9
14
  def new
10
15
  @account = build_account({email: "", password: ""})
@@ -15,12 +20,11 @@ module Aikotoba
15
20
  before_send_recovery_token_process
16
21
  send_recovery_token!(account)
17
22
  after_send_recovery_token_process
18
- redirect_to success_send_recovery_token_path, flash: {notice: success_send_recovery_token_message}
19
23
  rescue ActiveRecord::RecordNotFound => e
20
24
  failed_send_recovery_token_process(e)
21
- @account = build_account({email: "", password: ""})
22
- flash[:alert] = failed_send_recovery_token_message
23
- render :new, status: :unprocessable_entity
25
+ ensure
26
+ # NOTE: Always show success message to avoid account enumeration.
27
+ redirect_to success_send_recovery_token_path, flash: {notice: success_send_recovery_token_message}
24
28
  end
25
29
 
26
30
  def edit
@@ -54,27 +58,27 @@ module Aikotoba
54
58
  end
55
59
 
56
60
  def find_by_send_token_account!(params)
57
- Account.find_by!(email: params[:email])
61
+ Account.for_target_type(aikotoba_authenticate_target).find_by!(email: params[:email])
58
62
  end
59
63
 
60
64
  def find_by_has_token_account!(params)
61
- 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
62
66
  end
63
67
 
64
68
  def send_recovery_token!(account)
65
- Account::Service::Recovery.create_token!(account: account, notify: true)
69
+ Account::Recovery.create_token!(account: account, notify: true)
66
70
  end
67
71
 
68
72
  def recover_account!(account, new_password)
69
- Account::Service::Recovery.recover!(account: account, new_password: new_password)
73
+ Account::Recovery.recover!(account: account, new_password: new_password)
70
74
  end
71
75
 
72
76
  def success_recovered_path
73
- aikotoba.new_session_path
77
+ aikotoba_scoped_path(:new_session_path)
74
78
  end
75
79
 
76
80
  def success_send_recovery_token_path
77
- aikotoba.new_session_path
81
+ aikotoba_scoped_path(:new_session_path)
78
82
  end
79
83
 
80
84
  def failed_message
@@ -89,10 +93,6 @@ module Aikotoba
89
93
  I18n.t(".aikotoba.messages.recovery.sent")
90
94
  end
91
95
 
92
- def failed_send_recovery_token_message
93
- I18n.t(".aikotoba.messages.recovery.sent_failed")
94
- end
95
-
96
96
  # NOTE: Methods to override if you want to do something before send recover token.
97
97
  def before_send_recovery_token_process
98
98
  end
@@ -40,15 +40,15 @@ module Aikotoba
40
40
  end
41
41
 
42
42
  def authenticate_account(params)
43
- Account.authenticate_by(attributes: params)
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.after_sign_in_path
47
+ aikotoba_after_authentication_path
48
48
  end
49
49
 
50
50
  def after_sign_out_path
51
- Aikotoba.after_sign_out_path
51
+ aikotoba_scope_config[:after_sign_out_path]
52
52
  end
53
53
 
54
54
  def successed_message
@@ -2,9 +2,14 @@
2
2
 
3
3
  module Aikotoba
4
4
  class UnlocksController < ApplicationController
5
- include Protection::TimingAtack
5
+ include Protection::RateLimiting
6
6
 
7
- before_action :prevent_timing_atack, only: [:update]
7
+ def self.unlock_rate_limit_options
8
+ Aikotoba.unlock_rate_limit_options
9
+ end
10
+ private_class_method :unlock_rate_limit_options
11
+
12
+ rate_limit(**unlock_rate_limit_options)
8
13
 
9
14
  def new
10
15
  @account = build_account({email: "", password: ""})
@@ -15,12 +20,11 @@ module Aikotoba
15
20
  before_send_unlock_token_process
16
21
  send_token_account!(account)
17
22
  after_send_unlock_token_process
18
- redirect_to success_send_unlock_token_path, flash: {notice: success_send_unlock_token_message}
19
23
  rescue ActiveRecord::RecordNotFound => e
20
24
  failed_send_unlock_token_process(e)
21
- @account = build_account({email: "", password: ""})
22
- flash[:alert] = failed_send_unlock_token_message
23
- render :new, status: :unprocessable_entity
25
+ ensure
26
+ # NOTE: Always show success message to avoid account enumeration.
27
+ redirect_to success_send_unlock_token_path, flash: {notice: success_send_unlock_token_message}
24
28
  end
25
29
 
26
30
  def update
@@ -42,30 +46,30 @@ module Aikotoba
42
46
  end
43
47
 
44
48
  def find_by_send_token_account!(params)
45
- Account.locked.find_by!(email: params[:email])
49
+ Account.for_target_type(aikotoba_authenticate_target).locked.find_by!(email: params[:email])
46
50
  end
47
51
 
48
52
  def send_token_account!(account)
49
- Account::Service::Lock.create_unlock_token!(account: account, notify: true)
53
+ Account::Lock.create_unlock_token!(account: account, notify: true)
50
54
  end
51
55
 
52
56
  def find_by_has_token_account!(params)
53
- 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
54
58
  end
55
59
 
56
60
  def unlock_account!(account)
57
61
  # NOTE: Unlocking is done using URL tokens, so it is done in the writing role.
58
62
  ActiveRecord::Base.connected_to(role: :writing) do
59
- Account::Service::Lock.unlock!(account: account)
63
+ Account::Lock.unlock!(account: account)
60
64
  end
61
65
  end
62
66
 
63
67
  def after_unlocked_path
64
- aikotoba.new_session_path
68
+ aikotoba_scoped_path(:new_session_path)
65
69
  end
66
70
 
67
71
  def success_send_unlock_token_path
68
- aikotoba.new_session_path
72
+ aikotoba_scoped_path(:new_session_path)
69
73
  end
70
74
 
71
75
  def unlocked_message
@@ -76,10 +80,6 @@ module Aikotoba
76
80
  I18n.t(".aikotoba.messages.unlocking.sent")
77
81
  end
78
82
 
79
- def failed_send_unlock_token_message
80
- I18n.t(".aikotoba.messages.unlocking.failed")
81
- end
82
-
83
83
  # NOTE: Methods to override if you want to do something before send unlock token.
84
84
  def before_send_unlock_token_process
85
85
  end
@@ -2,39 +2,94 @@ module Aikotoba
2
2
  module Authenticatable
3
3
  extend ActiveSupport::Concern
4
4
  include Protection::SessionFixationAttack
5
+ include Scopable
6
+ include RequestBackable
7
+
8
+ included do
9
+ helper_method :aikotoba_current_account
10
+ end
5
11
 
6
12
  def aikotoba_current_account
7
- unless defined?(@aikotoba_current_account)
8
- @aikotoba_current_account ||= aikotoba_authenticate_by_session
9
- end
10
- @aikotoba_current_account
13
+ @aikotoba_current_account ||= aikotoba_current_session&.account
11
14
  end
12
15
 
13
16
  def aikotoba_sign_in(account)
14
- prevent_session_fixation_attack
15
- session[aikotoba_session_key] = account.id
17
+ start_new_aikotoba_session_for(account)
16
18
  end
17
19
 
18
20
  def aikotoba_sign_out
19
- @aikotoba_current_account = nil
20
- reset_session
21
+ terminate_aikotoba_session
21
22
  end
22
23
 
23
- # NOTE: Even if there is already a session, verify that it can be authenticated, and if not, reset the session,
24
- # in case the session is created and then locked by another browser etc.
25
- def aikotoba_authenticate_by_session
26
- account = Account.authenticatable.find_by(id: session[aikotoba_session_key])
27
- account.tap { |account| reset_aikotoba_session unless account }
24
+ private
25
+
26
+ def aikotoba_current_session
27
+ unless defined?(@aikotoba_current_session)
28
+ @aikotoba_current_session ||= resume_aikotoba_session
29
+ end
30
+ @aikotoba_current_session
28
31
  end
29
32
 
30
- private
33
+ def resume_aikotoba_session
34
+ account_session = find_aikotoba_session_by_cookie
35
+ unless account_session
36
+ # NOTE: NOTE: Even if there is already a session, verify that it can be authenticated, and if not, reset the session,
37
+ # in case the session is created and then locked by another browser etc.
38
+ #
39
+ # Although the session record in DB should be deleted ideally,
40
+ # deleting it issues a DELETE statement that causes an error due to Rails' multi-DB automatic switching feature,
41
+ # so the session record deletion is not performed here.
42
+ # Deleting only the cookie will require explicit re-login, and the situation where only the DB session remains can also occur
43
+ # when the cookie is directly deleted on the browser side, so we decided to allow it.
44
+ terminate_aikotoba_session(only_cookie: true)
45
+ end
46
+ account_session
47
+ end
31
48
 
32
- def reset_aikotoba_session
33
- session[aikotoba_session_key] = nil
49
+ def find_aikotoba_session_by_cookie
50
+ token = cookies.signed[aikotoba_session_key]
51
+ aikotoba_session_find_by(token) if token.present?
52
+ end
53
+
54
+ def start_new_aikotoba_session_for(account)
55
+ prevent_session_fixation_attack
56
+ @aikotoba_current_session = Aikotoba::Account::Session.start!(account: account, **aikotoba_session_meta_from_request)
57
+ set_aikotoba_session_cookie(@aikotoba_current_session)
58
+ @aikotoba_current_session
59
+ end
60
+
61
+ def terminate_aikotoba_session(only_cookie: false)
62
+ # NOTE: If you call `aikotoba_current_session` method, `aikotoba_current_session` will be regenerated, so refer to the instance variable directly.
63
+ @aikotoba_current_session&.revoke! unless only_cookie
64
+ cookies.delete(aikotoba_session_key)
65
+ @aikotoba_current_session = nil
66
+ @aikotoba_current_account = nil
67
+ end
68
+
69
+ def set_aikotoba_session_cookie(account_session)
70
+ cookies.signed[aikotoba_session_key] = {
71
+ value: account_session.token,
72
+ httponly: true,
73
+ same_site: :lax,
74
+ # NOTE: Set the secure attribute except in the development and test environment
75
+ secure: !Rails.env.local?,
76
+ expires: Aikotoba.session_expiry.from_now
77
+ }
34
78
  end
35
79
 
36
80
  def aikotoba_session_key
37
- Aikotoba.session_key
81
+ aikotoba_scope_config[:session_key]
82
+ end
83
+
84
+ def aikotoba_session_meta_from_request
85
+ {
86
+ ip_address: request.remote_ip,
87
+ user_agent: request.user_agent
88
+ }
89
+ end
90
+
91
+ def aikotoba_session_find_by(token)
92
+ Account::Session.find_by_token(token, target_type_name: aikotoba_authenticate_target)
38
93
  end
39
94
  end
40
95
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rate limiting protection for email-sending endpoints to prevent email bombing attacks.
4
+ # Uses Rails' built-in rate_limit feature.
5
+ #
6
+ # Usage:
7
+ # class MyController < ApplicationController
8
+ # include Protection::RateLimiting
9
+ # rate_limit(to: 10, within: 1.minute, by: -> { request.remote_ip })
10
+ # end
11
+ module Aikotoba
12
+ module Protection::RateLimiting
13
+ extend ActiveSupport::Concern
14
+
15
+ module ClassMethods
16
+ def rate_limit(**options)
17
+ return if options.empty? # Allow empty config (disabled by default)
18
+
19
+ super
20
+ end
21
+ end
22
+ end
23
+ 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
@@ -0,0 +1,36 @@
1
+ module Aikotoba
2
+ module Scopable
3
+ extend ActiveSupport::Concern
4
+
5
+ def aikotoba_scope_config
6
+ Aikotoba.scopes[aikotoba_scope]
7
+ end
8
+
9
+ def aikotoba_scoped_path(path, *params)
10
+ return aikotoba.public_send(path, *params) if aikotoba_scope.default?
11
+
12
+ aikotoba.public_send("#{aikotoba_scope}_#{path}", *params)
13
+ end
14
+
15
+ def aikotoba_scope
16
+ @aikotoba_scope ||= aikotoba_calc_scope_from(request.path).to_s.inquiry
17
+ end
18
+
19
+ private
20
+
21
+ def aikotoba_authenticate_target
22
+ aikotoba_scope_config.authenticate_for
23
+ end
24
+
25
+ def aikotoba_calc_scope_from(request_path)
26
+ scope = :default
27
+ Aikotoba.scopes.except(:default).each do |name, config|
28
+ if config.covers?(request_path)
29
+ scope = name
30
+ break # NOTE: Adopt the first matching scope
31
+ end
32
+ end
33
+ scope
34
+ end
35
+ end
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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Aikotoba
4
- class Account::Service::Confirmation
4
+ class Account::Confirmation
5
5
  def self.create_token!(account:, notify: false)
6
6
  new(account: account).create_token!(notify: notify)
7
7
  end