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.
- checksums.yaml +4 -4
- data/README.md +346 -198
- data/app/controllers/aikotoba/accounts_controller.rb +6 -1
- data/app/controllers/aikotoba/application_controller.rb +7 -1
- data/app/controllers/aikotoba/confirms_controller.rb +2 -2
- data/app/controllers/aikotoba/magic_links_controller.rb +103 -0
- data/app/controllers/aikotoba/recoveries_controller.rb +2 -2
- data/app/controllers/aikotoba/sessions_controller.rb +2 -2
- data/app/controllers/aikotoba/unlocks_controller.rb +2 -2
- data/app/controllers/concerns/aikotoba/authenticatable.rb +1 -22
- data/app/controllers/concerns/aikotoba/protection/rate_limiting.rb +2 -11
- data/app/controllers/concerns/aikotoba/request_backable.rb +53 -0
- data/app/controllers/concerns/aikotoba/scopable.rb +5 -5
- data/app/mailers/aikotoba/account_mailer.rb +35 -3
- data/app/models/aikotoba/account/confirmation_token.rb +6 -1
- data/app/models/aikotoba/account/magic_link.rb +38 -0
- data/app/models/aikotoba/account/magic_link_token.rb +27 -0
- data/app/models/aikotoba/account/{password → password_hash}/argon2.rb +1 -1
- data/app/models/aikotoba/account/password_hash.rb +97 -0
- data/app/models/aikotoba/account/recovery.rb +27 -1
- data/app/models/aikotoba/account/recovery_token.rb +6 -1
- data/app/models/aikotoba/account/session.rb +5 -3
- data/app/models/aikotoba/account/unlock_token.rb +6 -1
- data/app/models/aikotoba/account.rb +80 -60
- data/app/models/concerns/aikotoba/enabled_feature_checkable.rb +8 -0
- data/app/models/concerns/aikotoba/optional_association.rb +82 -0
- data/app/models/concerns/aikotoba/token_encryptable.rb +1 -11
- data/app/views/aikotoba/account_mailer/magic_link.html.erb +3 -0
- data/app/views/aikotoba/magic_links/new.html.erb +14 -0
- data/app/views/aikotoba/sessions/_links.html.erb +6 -1
- data/config/locales/en.yml +10 -0
- data/config/routes.rb +25 -16
- data/db/migrate/20211204121532_create_aikotoba_accounts.rb +77 -33
- data/lib/aikotoba/configuration.rb +99 -0
- data/lib/aikotoba/constraints/magic_link_authenticatable_constraint.rb +7 -0
- data/lib/aikotoba/engine.rb +13 -0
- data/lib/aikotoba/errors.rb +2 -0
- data/lib/aikotoba/scope.rb +64 -0
- data/lib/aikotoba/scopes.rb +102 -0
- data/lib/aikotoba/test/authentication_helper.rb +4 -4
- data/lib/aikotoba/version.rb +1 -1
- data/lib/aikotoba.rb +33 -67
- metadata +16 -5
- 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
|
-
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
117
|
-
new(
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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,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
|
|
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 %>
|
data/config/locales/en.yml
CHANGED
|
@@ -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
|
|
12
|
-
post(config
|
|
13
|
-
delete(config
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
22
|
-
post(config
|
|
23
|
-
get(File.join(config
|
|
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
|
|
28
|
-
post(config
|
|
29
|
-
get(File.join(config
|
|
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
|
|
34
|
-
post(config
|
|
35
|
-
get(File.join(config
|
|
36
|
-
patch(File.join(config
|
|
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
|