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
@@ -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.recovery_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).recover.deliver_now
23
+ ActiveRecord.after_all_transactions_commit do
24
+ AccountMailer.with(account: account).recover.deliver_later
25
+ end
21
26
  end
22
27
  end
23
28
  end
@@ -15,9 +15,11 @@ module Aikotoba
15
15
  scope :active, ->(now: Time.current) { where("expired_at >= ?", now) }
16
16
 
17
17
  after_initialize do |record|
18
- token = Account::Token.new(expiry: Aikotoba.session_expiry)
19
- record.token ||= token.value
20
- record.expired_at ||= token.expired_at
18
+ if record.token.nil? || record.expired_at.nil?
19
+ token = Account::Token.new(expiry: Aikotoba.session_expiry)
20
+ record.token ||= token.value
21
+ record.expired_at ||= token.expired_at
22
+ end
21
23
  end
22
24
 
23
25
  def revoke!
@@ -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.unlock_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).unlock.deliver_now
23
+ ActiveRecord.after_all_transactions_commit do
24
+ AccountMailer.with(account: account).unlock.deliver_later
25
+ end
21
26
  end
22
27
  end
23
28
  end
@@ -3,6 +3,7 @@
3
3
  module Aikotoba
4
4
  class Account < ApplicationRecord
5
5
  include EnabledFeatureCheckable
6
+ include OptionalAssociation
6
7
 
7
8
  # NOTE: (RFC5321) Path: The maximum total length of a reverse-path or forward-path is 256 octets.
8
9
  # https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.3.1.3
@@ -14,8 +15,6 @@ module Aikotoba
14
15
  attribute :max_failed_attempts, :integer, default: -> { Aikotoba.max_failed_attempts }
15
16
 
16
17
  validates :email, presence: true, uniqueness: {case_sensitive: false}, format: EMAIL_REGEXP, length: {maximum: EMAIL_MAXIMUM_LENGTH}
17
- validates :password, presence: true, length: {in: Password::LENGTH_RANGE}, on: [:create, :recover]
18
- validates :password_digest, presence: true
19
18
  validates :confirmed, inclusion: [true, false]
20
19
  validates :failed_attempts, presence: true, numericality: {only_integer: true, greater_than_or_equal_to: 0}
21
20
  validates :max_failed_attempts, numericality: {only_integer: true, greater_than: 0}
@@ -35,14 +34,6 @@ module Aikotoba
35
34
  end
36
35
  end
37
36
 
38
- attr_reader :password
39
-
40
- def password=(value)
41
- new_password = Password.new(value: value)
42
- @password = new_password.value
43
- assign_attributes(password_digest: new_password.digest)
44
- end
45
-
46
37
  concerning :Authenticatable do
47
38
  included do
48
39
  has_many :sessions,
@@ -50,11 +41,15 @@ module Aikotoba
50
41
  dependent: :destroy,
51
42
  foreign_key: "aikotoba_account_id"
52
43
 
44
+ # NOTE: The accounts a scope handles: all of them when it has no authenticate_for,
45
+ # otherwise only that target type. A Class passed to authenticate_for is
46
+ # converted to a string for searching.
47
+ scope :for_target_type, ->(target_type_name) {
48
+ target_type_name.present? ? where(authenticate_target_type: target_type_name.to_s) : all
49
+ }
50
+
53
51
  scope :authenticatable, ->(target_type_name: nil) {
54
- result = all
55
- # NOTE: To ensure that authentication works even if a Class is passed to authenticate_for,
56
- # convert it to a string for searching.
57
- result = result.where(authenticate_target_type: target_type_name.to_s) if target_type_name.present?
52
+ result = for_target_type(target_type_name)
58
53
  result = result.confirmed if confirmable?
59
54
  result = result.unlocked if lockable?
60
55
  result
@@ -62,39 +57,9 @@ module Aikotoba
62
57
  end
63
58
 
64
59
  class_methods do
65
- def authenticate_by(attributes:, target_type_name: nil)
66
- email, password = attributes.values_at(:email, :password)
67
- account = find_by_identifier(email, target_type_name: target_type_name)
68
- return prevent_timing_atack(email, password) unless account
69
-
70
- account.authenticate(password).tap do |result|
71
- ActiveRecord::Base.transaction do
72
- if result
73
- account.authentication_success!
74
- else
75
- account.authentication_failed!
76
- Lock.lock!(account: account, notify: true) if lockable? && account.should_lock?
77
- end
78
- end
79
- end
80
- end
81
-
82
- private
83
-
84
60
  def find_by_identifier(email, target_type_name: nil)
85
61
  authenticatable(target_type_name: target_type_name).find_by(email: email)
86
62
  end
87
-
88
- # NOTE: Verify passwords even when accounts are not found to prevent timing attacks.
89
- def prevent_timing_atack(email, password)
90
- account = build_by(attributes: {email: email, password: password})
91
- account.password_match?(password)
92
- nil
93
- end
94
- end
95
-
96
- def authenticate(input_password)
97
- password_match?(input_password) ? self : nil
98
63
  end
99
64
 
100
65
  def authentication_failed!
@@ -104,20 +69,66 @@ module Aikotoba
104
69
  def authentication_success!
105
70
  update!(failed_attempts: 0)
106
71
  end
72
+ end
107
73
 
108
- def password_match?(input_password)
109
- Password.new(value: input_password).match?(digest: password_digest)
74
+ concerning :PasswordAuthenticatable do
75
+ included do
76
+ has_one :password_hash,
77
+ class_name: "Aikotoba::Account::PasswordHash",
78
+ dependent: :destroy,
79
+ foreign_key: "aikotoba_account_id",
80
+ autosave: true,
81
+ inverse_of: :account
110
82
  end
111
83
  end
112
84
 
113
85
  concerning :Registrable do
114
86
  class_methods do
87
+ # NOTE: This is one of the two places (see #update_by below) Account is allowed
88
+ # to know :password is a valid registration attribute (unlike the removed
89
+ # password/password=/password_digest delegators, which made Account speak
90
+ # password as its own API). It exists so callers can keep submitting a flat
91
+ # account[password] param instead of Rails' nested_attributes shape.
92
+ # Extracting a Registration class wouldn't remove this knowledge, only
93
+ # relocate it, so this is left as the accepted minimal seam.
115
94
  def build_by(attributes:)
116
- email, password = attributes.values_at(:email, :password)
117
- new(email: email).tap { |account| account.password = password }
95
+ attrs, has_password, password = extract_password_attribute(attributes)
96
+ new(attrs).tap { |account| account.build_password_hash.generate(password) if has_password }
97
+ end
98
+
99
+ def create_by!(attributes:)
100
+ build_by(attributes: attributes).tap(&:save!)
101
+ end
102
+
103
+ private
104
+
105
+ # NOTE: Shared by .build_by and #update_by so the :password-key-splitting
106
+ # logic exists exactly once. has_password is returned separately from
107
+ # password because a present-but-nil password must still reach
108
+ # PasswordHash#generate (so its presence validation can reject it),
109
+ # while an absent :password key must build no password_hash at all.
110
+ def extract_password_attribute(attributes)
111
+ attrs = attributes.to_h.symbolize_keys
112
+ has_password = attrs.key?(:password)
113
+ password = attrs.delete(:password)
114
+ [attrs, has_password, password]
118
115
  end
119
116
  end
120
117
 
118
+ # NOTE: The update-side counterpart to .build_by above -- account.update!(password:)
119
+ # can't work here any more than Account.new(password:) can, since Account has
120
+ # no password= for mass-assignment to land on.
121
+ def update_by(attributes:)
122
+ attrs, has_password, password = self.class.send(:extract_password_attribute, attributes)
123
+ assign_attributes(attrs)
124
+ (password_hash || build_password_hash).generate(password) if has_password
125
+ self
126
+ end
127
+
128
+ def update_by!(attributes:)
129
+ update_by(attributes: attributes).tap(&:save!)
130
+ end
131
+
121
132
  def register!
122
133
  ActiveRecord::Base.transaction do
123
134
  save!
@@ -128,9 +139,12 @@ module Aikotoba
128
139
 
129
140
  concerning :Confirmable do
130
141
  included do
131
- has_one :confirmation_token,
132
- dependent: :destroy,
133
- foreign_key: "aikotoba_account_id"
142
+ # NOTE: optional_has_one rather than has_one -- this table may legitimately not
143
+ # exist, so its dependent: :destroy callback has to be gated on that.
144
+ # See OptionalAssociation.
145
+ optional_has_one :confirmation_token,
146
+ foreign_key: "aikotoba_account_id",
147
+ dependent: :destroy
134
148
  scope :confirmed, -> { where(confirmed: true) }
135
149
  scope :unconfirmed, -> { where(confirmed: false) }
136
150
  end
@@ -142,9 +156,10 @@ module Aikotoba
142
156
 
143
157
  concerning :Lockable do
144
158
  included do
145
- has_one :unlock_token,
146
- dependent: :destroy,
147
- foreign_key: "aikotoba_account_id"
159
+ # NOTE: See the comment on Confirmable's association above -- same reasoning.
160
+ optional_has_one :unlock_token,
161
+ foreign_key: "aikotoba_account_id",
162
+ dependent: :destroy
148
163
  scope :locked, -> { where(locked: true) }
149
164
  scope :unlocked, -> { where(locked: false) }
150
165
  end
@@ -164,14 +179,19 @@ module Aikotoba
164
179
 
165
180
  concerning :Recoverable do
166
181
  included do
167
- has_one :recovery_token,
168
- dependent: :destroy,
169
- foreign_key: "aikotoba_account_id"
182
+ # NOTE: See the comment on Confirmable's association above -- same reasoning.
183
+ optional_has_one :recovery_token,
184
+ foreign_key: "aikotoba_account_id",
185
+ dependent: :destroy
170
186
  end
187
+ end
171
188
 
172
- def recover!(new_password:)
173
- self.password = new_password
174
- save!(context: :recover)
189
+ concerning :MagicLinkAuthenticatable do
190
+ included do
191
+ # NOTE: See the comment on Confirmable's association above -- same reasoning.
192
+ optional_has_one :magic_link_token,
193
+ foreign_key: "aikotoba_account_id",
194
+ dependent: :destroy
175
195
  end
176
196
  end
177
197
  end
@@ -20,6 +20,10 @@ module Aikotoba
20
20
  def recoverable?
21
21
  Aikotoba.recoverable
22
22
  end
23
+
24
+ def magic_link_authenticatable?
25
+ Aikotoba.magic_link_authenticatable
26
+ end
23
27
  end
24
28
 
25
29
  def registerable?
@@ -37,5 +41,9 @@ module Aikotoba
37
41
  def recoverable?
38
42
  self.class.recoverable?
39
43
  end
44
+
45
+ def magic_link_authenticatable?
46
+ self.class.magic_link_authenticatable?
47
+ end
40
48
  end
41
49
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikotoba
4
+ # NOTE: Support for has_one associations whose table is optional -- a host app that
5
+ # never enables the owning feature may legitimately never run its migration (see
6
+ # the README's Getting Start section). `dependent: :destroy` can't be used for
7
+ # those, because it registers an unconditional callback that loads the association
8
+ # on every parent destroy; that is what raised PG::UndefinedTable for host apps
9
+ # with a feature off and its table never created.
10
+ module OptionalAssociation
11
+ extend ActiveSupport::Concern
12
+
13
+ module ClassMethods
14
+ SUPPORTED_DEPENDENT_OPTIONS = [nil, :destroy].freeze
15
+
16
+ # NOTE: Declares a has_one whose table may not exist. Behaves like plain `has_one`
17
+ # in every respect except one: with `dependent: :destroy`, the callback that
18
+ # cleans the child up is gated on the table actually existing.
19
+ #
20
+ # That gate is the whole point. Rails' own `dependent: :destroy` callback is
21
+ # unconditional -- it loads the association on every parent destroy whether or
22
+ # not the table is there -- which is what raised PG::UndefinedTable. Gating on
23
+ # the table's existence (rather than on the owning feature's flag) keeps
24
+ # destroy working in *both* directions: no table means the reader is never
25
+ # called and the table is never queried, while a table left behind after a
26
+ # flag was switched off still gets cleaned up instead of stranding the row and
27
+ # raising ActiveRecord::InvalidForeignKey.
28
+ #
29
+ # Only `dependent: :destroy` (or no :dependent at all) is supported. The other
30
+ # values would have to be forwarded to has_one to work, which would register
31
+ # the unconditional callback this exists to avoid, so they raise rather than
32
+ # silently doing nothing.
33
+ def optional_has_one(name, **options)
34
+ dependent = options.delete(:dependent)
35
+ unless SUPPORTED_DEPENDENT_OPTIONS.include?(dependent)
36
+ raise ArgumentError,
37
+ "optional_has_one supports dependent: :destroy only, got #{dependent.inspect}"
38
+ end
39
+
40
+ has_one(name, **options)
41
+ return if dependent.nil?
42
+
43
+ # NOTE: `dependent: :destroy` does two separate jobs and we want exactly one of
44
+ # them, so it is deliberately withheld from has_one above and set here
45
+ # instead. Order matters:
46
+ #
47
+ # - Withheld: has_one reads :dependent at *declaration* time to register the
48
+ # unconditional before_destroy. Never passing it means that callback is
49
+ # never created, and setting the option afterwards cannot retroactively
50
+ # create it. The guarded equivalent is registered below.
51
+ # - Restored: HasOneAssociation#remove_target! reads :dependent out of the
52
+ # reflection at *runtime*, to clear the previous record when a new one
53
+ # replaces it. Without it, `build_<name>` + `save!` would try to nullify
54
+ # the old row's NOT NULL foreign key and raise
55
+ # ActiveRecord::RecordNotSaved, so every caller would need to hand-roll a
56
+ # destroy first.
57
+ reflect_on_association(name).options[:dependent] = dependent
58
+
59
+ before_destroy do
60
+ # NOTE: Resolved per call rather than at declaration time to avoid autoloading
61
+ # the associated model while this one is still being defined. It reads
62
+ # table_name off the class, which is derived from the class name and so
63
+ # needs no database access of its own.
64
+ table_name = self.class.reflect_on_association(name).klass.table_name
65
+ public_send(name)&.destroy if self.class.table_exists_in_schema_cache?(table_name)
66
+ end
67
+ end
68
+
69
+ # NOTE: Served from the schema cache -- negative results are cached too -- so this
70
+ # costs at most one query per process rather than one per destroy. Like
71
+ # anything schema-cached, a process that was already running when the
72
+ # migration was applied needs a restart to notice the new table.
73
+ # connection_pool#schema_cache is the current home for it; older supported
74
+ # Rails versions keep it on the connection, hence the fallback.
75
+ def table_exists_in_schema_cache?(table_name)
76
+ pool = connection_pool
77
+ cache = pool.respond_to?(:schema_cache) ? pool.schema_cache : connection.schema_cache
78
+ cache.data_source_exists?(table_name)
79
+ end
80
+ end
81
+ end
82
+ end
@@ -5,23 +5,13 @@ module Aikotoba
5
5
  extend ActiveSupport::Concern
6
6
 
7
7
  included do
8
- if enabled_aikotoba_enctypted_token?
9
- if available_active_record_encryption?
10
- encrypts :token, deterministic: true
11
- else
12
- raise Errors::NotAvailableException, "You need to be able to encrypt the token using Active Record Encryption."
13
- end
14
- end
8
+ encrypts :token, deterministic: true if enabled_aikotoba_enctypted_token?
15
9
  end
16
10
 
17
11
  module ClassMethods
18
12
  def enabled_aikotoba_enctypted_token?
19
13
  Aikotoba.encrypted_token
20
14
  end
21
-
22
- def available_active_record_encryption?
23
- ActiveRecord::VERSION::MAJOR >= 7
24
- end
25
15
  end
26
16
  end
27
17
  end
@@ -0,0 +1,3 @@
1
+ Sign in url: <%= @magic_link_url %>
2
+ <br>
3
+ The url expires at <%= I18n.l(@token.expired_at, format: :long) %>
@@ -0,0 +1,14 @@
1
+ <div class="aikotoba-magic-links-new">
2
+ <%= render "aikotoba/common/message" %>
3
+ <h1><%= I18n.t(".aikotoba.magic_links.new") %></h1>
4
+ <%= form_with model: @account, method: :post, url: aikotoba_scoped_path(:create_magic_link_path) do |f| %>
5
+ <p>
6
+ <%= f.label :email %>
7
+ <%= f.email_field :email, required: true, autocomplete: :email %>
8
+ </p>
9
+ <%= f.submit I18n.t(".aikotoba.magic_links.new") %>
10
+ <% end %>
11
+ <p>
12
+ <%= link_to I18n.t(".aikotoba.sessions.new"), aikotoba_scoped_path(:new_session_path) %>
13
+ </p>
14
+ </div>
@@ -1,4 +1,4 @@
1
- <% if registerable? %>
1
+ <% if aikotoba_scope_registerable? %>
2
2
  <p>
3
3
  <%= link_to I18n.t(".aikotoba.accounts.new"), aikotoba_scoped_path(:new_account_path) %>
4
4
  </p>
@@ -18,3 +18,8 @@
18
18
  <%= link_to I18n.t(".aikotoba.recoveries.new"), aikotoba_scoped_path(:new_recovery_token_path) %>
19
19
  </p>
20
20
  <% end %>
21
+ <% if magic_link_authenticatable? %>
22
+ <p>
23
+ <%= link_to I18n.t(".aikotoba.magic_links.new"), aikotoba_scoped_path(:new_magic_link_path) %>
24
+ </p>
25
+ <% end %>
@@ -4,6 +4,9 @@ en:
4
4
  aikotoba/account:
5
5
  email: Email
6
6
  password: Password
7
+ password_hash: Password
8
+ aikotoba/account/password_hash:
9
+ plaintext: Password
7
10
  models:
8
11
  aikotoba/account: Account
9
12
  aikotoba:
@@ -18,6 +21,8 @@ en:
18
21
  recoveries:
19
22
  new: Send password reset token
20
23
  edit: Password reset
24
+ magic_links:
25
+ new: Send sign in link
21
26
  mailers:
22
27
  confirm:
23
28
  subject: "Confirmation instructions"
@@ -25,6 +30,8 @@ en:
25
30
  subject: "Unlock instructions"
26
31
  recover:
27
32
  subject: "Password reset instructions"
33
+ magic_link:
34
+ subject: "Your sign in link"
28
35
 
29
36
  messages:
30
37
  authentication:
@@ -46,3 +53,6 @@ en:
46
53
  sent: Password reset url has been sent to your email address (if user with that email address exists).
47
54
  success: Password reset you account successfully.
48
55
  failed: Oops. Password reset failed.
56
+ magic_link:
57
+ sent: Sign in url has been sent to your email address (if user with that email address exists).
58
+ failed: Oops. Sign in link is invalid or has expired.
data/config/routes.rb CHANGED
@@ -4,36 +4,45 @@ require "aikotoba/constraints/registerable_constraint"
4
4
  require "aikotoba/constraints/confirmable_constraint"
5
5
  require "aikotoba/constraints/lockable_constraint"
6
6
  require "aikotoba/constraints/recoverable_constraint"
7
+ require "aikotoba/constraints/magic_link_authenticatable_constraint"
7
8
 
8
9
  Aikotoba::Engine.routes.draw do
9
10
  Aikotoba.scopes.each do |scope_name, config|
10
11
  scope as: ((scope_name == "default") ? nil : scope_name) do
11
- get(config[:sign_in_path], to: "sessions#new", as: :new_session)
12
- post(config[:sign_in_path], to: "sessions#create", as: :create_session)
13
- delete(config[:sign_out_path], to: "sessions#destroy", as: :destroy_session)
12
+ get(config.route_path(:sign_in_path), to: "sessions#new", as: :new_session)
13
+ post(config.route_path(:sign_in_path), to: "sessions#create", as: :create_session)
14
+ delete(config.route_path(:sign_out_path), to: "sessions#destroy", as: :destroy_session)
14
15
 
15
- constraints(Aikotoba::RegisterableConstraint) do
16
- get(config[:sign_up_path], to: "accounts#new", as: :new_account)
17
- post(config[:sign_up_path], to: "accounts#create", as: :create_account)
16
+ if config.registerable?
17
+ constraints(Aikotoba::RegisterableConstraint) do
18
+ get(config.route_path(:sign_up_path), to: "accounts#new", as: :new_account)
19
+ post(config.route_path(:sign_up_path), to: "accounts#create", as: :create_account)
20
+ end
18
21
  end
19
22
 
20
23
  constraints(Aikotoba::ConfirmableConstraint) do
21
- get(config[:confirm_path], to: "confirms#new", as: :new_confirmation_token)
22
- post(config[:confirm_path], to: "confirms#create", as: :create_confirmation_token)
23
- get(File.join(config[:confirm_path], ":token"), to: "confirms#update", as: :confirm_account)
24
+ get(config.route_path(:confirm_path), to: "confirms#new", as: :new_confirmation_token)
25
+ post(config.route_path(:confirm_path), to: "confirms#create", as: :create_confirmation_token)
26
+ get(File.join(config.route_path(:confirm_path), ":token"), to: "confirms#update", as: :confirm_account)
24
27
  end
25
28
 
26
29
  constraints(Aikotoba::LockableConstraint) do
27
- get(config[:unlock_path], to: "unlocks#new", as: :new_unlock_token)
28
- post(config[:unlock_path], to: "unlocks#create", as: :create_unlock_token)
29
- get(File.join(config[:unlock_path], ":token"), to: "unlocks#update", as: :unlock_account)
30
+ get(config.route_path(:unlock_path), to: "unlocks#new", as: :new_unlock_token)
31
+ post(config.route_path(:unlock_path), to: "unlocks#create", as: :create_unlock_token)
32
+ get(File.join(config.route_path(:unlock_path), ":token"), to: "unlocks#update", as: :unlock_account)
30
33
  end
31
34
 
32
35
  constraints(Aikotoba::RecoverableConstraint) do
33
- get(config[:recover_path], to: "recoveries#new", as: :new_recovery_token)
34
- post(config[:recover_path], to: "recoveries#create", as: :create_recovery_token)
35
- get(File.join(config[:recover_path], ":token"), to: "recoveries#edit", as: :edit_account_password)
36
- patch(File.join(config[:recover_path], ":token"), to: "recoveries#update", as: :update_account_password)
36
+ get(config.route_path(:recover_path), to: "recoveries#new", as: :new_recovery_token)
37
+ post(config.route_path(:recover_path), to: "recoveries#create", as: :create_recovery_token)
38
+ get(File.join(config.route_path(:recover_path), ":token"), to: "recoveries#edit", as: :edit_account_password)
39
+ patch(File.join(config.route_path(:recover_path), ":token"), to: "recoveries#update", as: :update_account_password)
40
+ end
41
+
42
+ constraints(Aikotoba::MagicLinkAuthenticatableConstraint) do
43
+ get(config.route_path(:magic_link_path), to: "magic_links#new", as: :new_magic_link)
44
+ post(config.route_path(:magic_link_path), to: "magic_links#create", as: :create_magic_link)
45
+ get(File.join(config.route_path(:magic_link_path), ":token"), to: "magic_links#update", as: :authenticate_via_magic_link)
37
46
  end
38
47
  end
39
48
  end