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.
- checksums.yaml +4 -4
- data/README.md +431 -52
- data/Rakefile +0 -2
- data/app/controllers/aikotoba/accounts_controller.rb +8 -3
- data/app/controllers/aikotoba/application_controller.rb +8 -1
- data/app/controllers/aikotoba/confirms_controller.rb +16 -16
- data/app/controllers/aikotoba/magic_links_controller.rb +103 -0
- data/app/controllers/aikotoba/recoveries_controller.rb +16 -16
- data/app/controllers/aikotoba/sessions_controller.rb +3 -3
- data/app/controllers/aikotoba/unlocks_controller.rb +16 -16
- data/app/controllers/concerns/aikotoba/authenticatable.rb +72 -17
- data/app/controllers/concerns/aikotoba/protection/rate_limiting.rb +23 -0
- data/app/controllers/concerns/aikotoba/request_backable.rb +53 -0
- data/app/controllers/concerns/aikotoba/scopable.rb +36 -0
- data/app/mailers/aikotoba/account_mailer.rb +35 -3
- data/app/models/aikotoba/account/{service/confirmation.rb → confirmation.rb} +1 -1
- data/app/models/aikotoba/account/confirmation_token.rb +8 -2
- data/app/models/aikotoba/account/{service/lock.rb → lock.rb} +1 -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 +57 -0
- data/app/models/aikotoba/account/recovery_token.rb +8 -2
- data/app/models/aikotoba/account/session.rb +42 -0
- data/app/models/aikotoba/account/token.rb +2 -2
- data/app/models/aikotoba/account/unlock_token.rb +8 -2
- data/app/models/aikotoba/account.rb +106 -28
- 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 +2 -12
- data/app/views/aikotoba/account_mailer/magic_link.html.erb +3 -0
- data/app/views/aikotoba/accounts/new.html.erb +4 -4
- data/app/views/aikotoba/confirms/new.html.erb +3 -3
- data/app/views/aikotoba/magic_links/new.html.erb +14 -0
- data/app/views/aikotoba/recoveries/edit.html.erb +3 -3
- data/app/views/aikotoba/recoveries/new.html.erb +3 -3
- data/app/views/aikotoba/sessions/_links.html.erb +10 -5
- data/app/views/aikotoba/sessions/new.html.erb +3 -3
- data/app/views/aikotoba/unlocks/new.html.erb +3 -3
- data/config/locales/en.yml +15 -6
- data/config/routes.rb +39 -22
- data/db/migrate/20211204121532_create_aikotoba_accounts.rb +77 -22
- 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 +43 -37
- metadata +24 -17
- data/app/controllers/concerns/aikotoba/protection/timing_atack.rb +0 -23
- data/app/models/aikotoba/account/password.rb +0 -34
- data/app/models/aikotoba/account/service/authentication.rb +0 -66
- data/app/models/aikotoba/account/service/recovery.rb +0 -31
- 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::
|
|
5
|
+
include Protection::RateLimiting
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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::
|
|
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::
|
|
63
|
+
Account::Confirmation.confirm!(account: account)
|
|
60
64
|
end
|
|
61
65
|
end
|
|
62
66
|
|
|
63
67
|
def after_confirmed_path
|
|
64
|
-
|
|
68
|
+
aikotoba_scoped_path(:new_session_path)
|
|
65
69
|
end
|
|
66
70
|
|
|
67
71
|
def success_send_confirmation_token_path
|
|
68
|
-
|
|
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::
|
|
5
|
+
include Protection::RateLimiting
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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::
|
|
69
|
+
Account::Recovery.create_token!(account: account, notify: true)
|
|
66
70
|
end
|
|
67
71
|
|
|
68
72
|
def recover_account!(account, new_password)
|
|
69
|
-
Account::
|
|
73
|
+
Account::Recovery.recover!(account: account, new_password: new_password)
|
|
70
74
|
end
|
|
71
75
|
|
|
72
76
|
def success_recovered_path
|
|
73
|
-
|
|
77
|
+
aikotoba_scoped_path(:new_session_path)
|
|
74
78
|
end
|
|
75
79
|
|
|
76
80
|
def success_send_recovery_token_path
|
|
77
|
-
|
|
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
|
-
|
|
47
|
+
aikotoba_after_authentication_path
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def after_sign_out_path
|
|
51
|
-
|
|
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::
|
|
5
|
+
include Protection::RateLimiting
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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::
|
|
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::
|
|
63
|
+
Account::Lock.unlock!(account: account)
|
|
60
64
|
end
|
|
61
65
|
end
|
|
62
66
|
|
|
63
67
|
def after_unlocked_path
|
|
64
|
-
|
|
68
|
+
aikotoba_scoped_path(:new_session_path)
|
|
65
69
|
end
|
|
66
70
|
|
|
67
71
|
def success_send_unlock_token_path
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
reset_session
|
|
21
|
+
terminate_aikotoba_session
|
|
21
22
|
end
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|