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
|
@@ -27,7 +27,12 @@ module Aikotoba
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def build_account(params)
|
|
30
|
-
|
|
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, :
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
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
|