aikotoba 0.1.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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +334 -0
- data/Rakefile +18 -0
- data/app/controllers/aikotoba/accounts_controller.rb +61 -0
- data/app/controllers/aikotoba/application_controller.rb +13 -0
- data/app/controllers/aikotoba/confirms_controller.rb +103 -0
- data/app/controllers/aikotoba/recoveries_controller.rb +120 -0
- data/app/controllers/aikotoba/sessions_controller.rb +78 -0
- data/app/controllers/aikotoba/unlocks_controller.rb +103 -0
- data/app/controllers/concerns/aikotoba/authenticatable.rb +40 -0
- data/app/controllers/concerns/aikotoba/protection/session_fixation_attack.rb +21 -0
- data/app/controllers/concerns/aikotoba/protection/timing_atack.rb +23 -0
- data/app/mailers/aikotoba/account_mailer.rb +24 -0
- data/app/mailers/aikotoba/application_mailer.rb +5 -0
- data/app/models/aikotoba/account/confirmation_token.rb +22 -0
- data/app/models/aikotoba/account/recovery_token.rb +22 -0
- data/app/models/aikotoba/account/service/authentication.rb +65 -0
- data/app/models/aikotoba/account/service/confirmation.rb +31 -0
- data/app/models/aikotoba/account/service/lock.rb +42 -0
- data/app/models/aikotoba/account/service/recovery.rb +31 -0
- data/app/models/aikotoba/account/service/registration.rb +30 -0
- data/app/models/aikotoba/account/unlock_token.rb +22 -0
- data/app/models/aikotoba/account/value/password.rb +48 -0
- data/app/models/aikotoba/account/value/token.rb +18 -0
- data/app/models/aikotoba/account.rb +120 -0
- data/app/models/concerns/aikotoba/enabled_feature_checkable.rb +41 -0
- data/app/models/concerns/aikotoba/token_encryptable.rb +27 -0
- data/app/views/aikotoba/account_mailer/confirm.html.erb +3 -0
- data/app/views/aikotoba/account_mailer/recover.html.erb +3 -0
- data/app/views/aikotoba/account_mailer/unlock.html.erb +3 -0
- data/app/views/aikotoba/accounts/new.html.erb +19 -0
- data/app/views/aikotoba/common/_errors.html.erb +9 -0
- data/app/views/aikotoba/common/_message.html.erb +8 -0
- data/app/views/aikotoba/confirms/new.html.erb +14 -0
- data/app/views/aikotoba/recoveries/edit.html.erb +15 -0
- data/app/views/aikotoba/recoveries/new.html.erb +14 -0
- data/app/views/aikotoba/sessions/_links.html.erb +20 -0
- data/app/views/aikotoba/sessions/new.html.erb +16 -0
- data/app/views/aikotoba/unlocks/new.html.erb +21 -0
- data/app/views/layouts/aikotoba/application.html.erb +11 -0
- data/app/views/layouts/aikotoba/mailer.html.erb +13 -0
- data/app/views/layouts/aikotoba/mailer.text.erb +1 -0
- data/config/locales/en.yml +49 -0
- data/config/routes.rb +32 -0
- data/db/migrate/20211204121532_create_aikotoba_accounts.rb +50 -0
- data/lib/aikotoba/constraints/confirmable_constraint.rb +7 -0
- data/lib/aikotoba/constraints/lockable_constraint.rb +7 -0
- data/lib/aikotoba/constraints/recoverable_constraint.rb +7 -0
- data/lib/aikotoba/constraints/registerable_constraint.rb +7 -0
- data/lib/aikotoba/engine.rb +7 -0
- data/lib/aikotoba/errors.rb +7 -0
- data/lib/aikotoba/test/authentication_helper.rb +48 -0
- data/lib/aikotoba/version.rb +5 -0
- data/lib/aikotoba.rb +45 -0
- data/lib/tasks/aikotoba_tasks.rake +4 -0
- metadata +128 -0
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikotoba
|
4
|
+
class SessionsController < ApplicationController
|
5
|
+
include Authenticatable
|
6
|
+
|
7
|
+
def new
|
8
|
+
return redirect_to after_sign_in_path if aikotoba_current_account
|
9
|
+
@account = build_account({email: "", password: ""})
|
10
|
+
end
|
11
|
+
|
12
|
+
def create
|
13
|
+
@account = authenticate_account(session_params.to_h.symbolize_keys)
|
14
|
+
if @account
|
15
|
+
before_sign_in_process
|
16
|
+
aikotoba_sign_in(@account)
|
17
|
+
after_sign_in_process
|
18
|
+
redirect_to after_sign_in_path, notice: successed_message
|
19
|
+
else
|
20
|
+
failed_sign_in_process
|
21
|
+
@account = build_account({email: "", password: ""})
|
22
|
+
flash[:alert] = failed_message
|
23
|
+
render :new, status: :unprocessable_entity
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def destroy
|
28
|
+
aikotoba_sign_out if aikotoba_current_account
|
29
|
+
redirect_to after_sign_out_path, notice: signed_out_message
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def session_params
|
35
|
+
params.require(:account).permit(:email, :password)
|
36
|
+
end
|
37
|
+
|
38
|
+
def build_account(params)
|
39
|
+
Account.build_by(attributes: params)
|
40
|
+
end
|
41
|
+
|
42
|
+
def authenticate_account(params)
|
43
|
+
Account.authenticate_by(attributes: params)
|
44
|
+
end
|
45
|
+
|
46
|
+
def after_sign_in_path
|
47
|
+
Aikotoba.after_sign_in_path
|
48
|
+
end
|
49
|
+
|
50
|
+
def after_sign_out_path
|
51
|
+
Aikotoba.after_sign_out_path
|
52
|
+
end
|
53
|
+
|
54
|
+
def successed_message
|
55
|
+
I18n.t(".aikotoba.messages.authentication.success")
|
56
|
+
end
|
57
|
+
|
58
|
+
def failed_message
|
59
|
+
I18n.t(".aikotoba.messages.authentication.failed")
|
60
|
+
end
|
61
|
+
|
62
|
+
def signed_out_message
|
63
|
+
I18n.t(".aikotoba.messages.authentication.sign_out")
|
64
|
+
end
|
65
|
+
|
66
|
+
# NOTE: Methods to override if you want to do something before sign in.
|
67
|
+
def before_sign_in_process
|
68
|
+
end
|
69
|
+
|
70
|
+
# NOTE: Methods to override if you want to do something after sign in.
|
71
|
+
def after_sign_in_process
|
72
|
+
end
|
73
|
+
|
74
|
+
# NOTE: Methods to override if you want to do something failed sign in.
|
75
|
+
def failed_sign_in_process(e = nil)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikotoba
|
4
|
+
class UnlocksController < ApplicationController
|
5
|
+
include Protection::TimingAtack
|
6
|
+
|
7
|
+
before_action :prevent_timing_atack, only: [:update]
|
8
|
+
|
9
|
+
def new
|
10
|
+
@account = build_account({email: "", password: ""})
|
11
|
+
end
|
12
|
+
|
13
|
+
def create
|
14
|
+
account = find_by_send_token_account!(unlock_accounts_params)
|
15
|
+
before_send_unlock_token_process
|
16
|
+
send_token_account!(account)
|
17
|
+
after_send_unlock_token_process
|
18
|
+
redirect_to success_send_unlock_token_path, flash: {notice: success_send_unlock_token_message}
|
19
|
+
rescue ActiveRecord::RecordNotFound => e
|
20
|
+
failed_send_unlock_token_process(e)
|
21
|
+
@account = build_account({email: "", password: ""})
|
22
|
+
flash[:alert] = failed_send_unlock_token_message
|
23
|
+
render :new, status: :unprocessable_entity
|
24
|
+
end
|
25
|
+
|
26
|
+
def update
|
27
|
+
account = find_by_has_token_account!(params)
|
28
|
+
before_unlock_process
|
29
|
+
unlock_account!(account)
|
30
|
+
after_unlock_process
|
31
|
+
redirect_to after_unlocked_path, flash: {notice: unlocked_message}
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def unlock_accounts_params
|
37
|
+
params.require(:account).permit(:email)
|
38
|
+
end
|
39
|
+
|
40
|
+
def build_account(params)
|
41
|
+
Account.build_by(attributes: params)
|
42
|
+
end
|
43
|
+
|
44
|
+
def find_by_send_token_account!(params)
|
45
|
+
Account.locked.find_by!(email: params[:email])
|
46
|
+
end
|
47
|
+
|
48
|
+
def send_token_account!(account)
|
49
|
+
Account::Service::Lock.create_unlock_token!(account: account, notify: true)
|
50
|
+
end
|
51
|
+
|
52
|
+
def find_by_has_token_account!(params)
|
53
|
+
Account::UnlockToken.active.find_by!(token: params[:token]).account
|
54
|
+
end
|
55
|
+
|
56
|
+
def unlock_account!(account)
|
57
|
+
# NOTE: Unlocking is done using URL tokens, so it is done in the writing role.
|
58
|
+
ActiveRecord::Base.connected_to(role: :writing) do
|
59
|
+
Account::Service::Lock.unlock!(account: account)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def after_unlocked_path
|
64
|
+
aikotoba.new_session_path
|
65
|
+
end
|
66
|
+
|
67
|
+
def success_send_unlock_token_path
|
68
|
+
aikotoba.new_session_path
|
69
|
+
end
|
70
|
+
|
71
|
+
def unlocked_message
|
72
|
+
I18n.t(".aikotoba.messages.unlocking.success")
|
73
|
+
end
|
74
|
+
|
75
|
+
def success_send_unlock_token_message
|
76
|
+
I18n.t(".aikotoba.messages.unlocking.sent")
|
77
|
+
end
|
78
|
+
|
79
|
+
def failed_send_unlock_token_message
|
80
|
+
I18n.t(".aikotoba.messages.unlocking.failed")
|
81
|
+
end
|
82
|
+
|
83
|
+
# NOTE: Methods to override if you want to do something before send unlock token.
|
84
|
+
def before_send_unlock_token_process
|
85
|
+
end
|
86
|
+
|
87
|
+
# NOTE: Methods to override if you want to do something after send unlock token.
|
88
|
+
def after_send_unlock_token_process
|
89
|
+
end
|
90
|
+
|
91
|
+
# NOTE: Methods to override if you want to do something failed send unlock token.
|
92
|
+
def failed_send_unlock_token_process(e)
|
93
|
+
end
|
94
|
+
|
95
|
+
# NOTE: Methods to override if you want to do something before unlock.
|
96
|
+
def before_unlock_process
|
97
|
+
end
|
98
|
+
|
99
|
+
# NOTE: Methods to override if you want to do something after unlock.
|
100
|
+
def after_unlock_process
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module Aikotoba
|
2
|
+
module Authenticatable
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
include Protection::SessionFixationAttack
|
5
|
+
|
6
|
+
def aikotoba_current_account
|
7
|
+
unless defined?(@aikotoba_current_account)
|
8
|
+
@aikotoba_current_account ||= aikotoba_authenticate_by_session
|
9
|
+
end
|
10
|
+
@aikotoba_current_account
|
11
|
+
end
|
12
|
+
|
13
|
+
def aikotoba_sign_in(account)
|
14
|
+
prevent_session_fixation_attack
|
15
|
+
session[aikotoba_session_key] = account.id
|
16
|
+
end
|
17
|
+
|
18
|
+
def aikotoba_sign_out
|
19
|
+
@aikotoba_current_account = nil
|
20
|
+
reset_session
|
21
|
+
end
|
22
|
+
|
23
|
+
# NOTE: Even if there is already a session, verify that it can be authenticated, and if not, reset the session,
|
24
|
+
# in case the session is created and then locked by another browser etc.
|
25
|
+
def aikotoba_authenticate_by_session
|
26
|
+
account = Account.authenticatable.find_by(id: session[aikotoba_session_key])
|
27
|
+
account.tap { |account| reset_aikotoba_session unless account }
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def reset_aikotoba_session
|
33
|
+
session[aikotoba_session_key] = nil
|
34
|
+
end
|
35
|
+
|
36
|
+
def aikotoba_session_key
|
37
|
+
Aikotoba.session_key
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# NOTE: Provides the ability to refresh session before sign_in for session fixation attacks.
|
4
|
+
# https://owasp.org/www-community/attacks/Session_fixation
|
5
|
+
module Aikotoba
|
6
|
+
module Protection::SessionFixationAttack
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
def prevent_session_fixation_attack
|
10
|
+
reflesh_session
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
def reflesh_session
|
16
|
+
old_session = session.dup.to_hash
|
17
|
+
reset_session
|
18
|
+
old_session.each_pair { |k, v| session[k.to_sym] = v }
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# NOTE: Add random delay for Timing attack.
|
4
|
+
# https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/10-Business_Logic_Testing/04-Test_for_Process_Timing
|
5
|
+
module Aikotoba
|
6
|
+
module Protection::TimingAtack
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
def prevent_timing_atack
|
10
|
+
random_delay if aikotoba_prevent_timing_atack
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
def aikotoba_prevent_timing_atack
|
16
|
+
Aikotoba.prevent_timing_atack
|
17
|
+
end
|
18
|
+
|
19
|
+
def random_delay
|
20
|
+
sleep (1..5).to_a.sample / 100.0
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module Aikotoba
|
2
|
+
class AccountMailer < ApplicationMailer
|
3
|
+
def confirm
|
4
|
+
@account = params[:account]
|
5
|
+
@token = @account.confirmation_token
|
6
|
+
@confirm_url = aikotoba.confirm_account_url(token: @token.token)
|
7
|
+
mail(to: @account.email, subject: I18n.t(".aikotoba.mailers.confirm.subject"))
|
8
|
+
end
|
9
|
+
|
10
|
+
def unlock
|
11
|
+
@account = params[:account]
|
12
|
+
@token = @account.unlock_token
|
13
|
+
@unlock_url = aikotoba.unlock_account_url(token: @token.token)
|
14
|
+
mail(to: @account.email, subject: I18n.t(".aikotoba.mailers.unlock.subject"))
|
15
|
+
end
|
16
|
+
|
17
|
+
def recover
|
18
|
+
@account = params[:account]
|
19
|
+
@token = @account.recovery_token
|
20
|
+
@recover_url = aikotoba.edit_account_password_url(token: @token.token)
|
21
|
+
mail(to: @account.email, subject: I18n.t(".aikotoba.mailers.recover.subject"))
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikotoba
|
4
|
+
class Account::ConfirmationToken < ApplicationRecord
|
5
|
+
include TokenEncryptable
|
6
|
+
belongs_to :account, class_name: "Aikotoba::Account", foreign_key: "aikotoba_account_id"
|
7
|
+
validates :token, presence: true
|
8
|
+
validates :expired_at, presence: true
|
9
|
+
|
10
|
+
scope :active, ->(now: Time.current) { where("expired_at >= ?", now) }
|
11
|
+
|
12
|
+
after_initialize do |record|
|
13
|
+
token = Account::Value::Token.new(extipry: Aikotoba.confirmation_token_expiry)
|
14
|
+
record.token ||= token.value
|
15
|
+
record.expired_at ||= token.expired_at
|
16
|
+
end
|
17
|
+
|
18
|
+
def notify
|
19
|
+
AccountMailer.with(account: account).confirm.deliver_now
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikotoba
|
4
|
+
class Account::RecoveryToken < ApplicationRecord
|
5
|
+
include TokenEncryptable
|
6
|
+
belongs_to :account, class_name: "Aikotoba::Account", foreign_key: "aikotoba_account_id"
|
7
|
+
validates :token, presence: true
|
8
|
+
validates :expired_at, presence: true
|
9
|
+
|
10
|
+
scope :active, ->(now: Time.current) { where("expired_at >= ?", now) }
|
11
|
+
|
12
|
+
after_initialize do |record|
|
13
|
+
token = Account::Value::Token.new(extipry: Aikotoba.recovery_token_expiry)
|
14
|
+
record.token ||= token.value
|
15
|
+
record.expired_at ||= token.expired_at
|
16
|
+
end
|
17
|
+
|
18
|
+
def notify
|
19
|
+
AccountMailer.with(account: account).recover.deliver_now
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikotoba
|
4
|
+
class Account::Service::Authentication
|
5
|
+
def self.call!(email:, password:)
|
6
|
+
new(email: email, password: password).call!
|
7
|
+
end
|
8
|
+
|
9
|
+
def initialize(email:, password:)
|
10
|
+
@account_class = Account
|
11
|
+
@lock_service = Account::Service::Lock
|
12
|
+
@lockable = @account_class.lockable?
|
13
|
+
@email = email
|
14
|
+
@password = password
|
15
|
+
end
|
16
|
+
|
17
|
+
def call!
|
18
|
+
account = find_by_identifier
|
19
|
+
return prevent_timing_atack && nil unless account
|
20
|
+
|
21
|
+
authenticate(account).tap do |result|
|
22
|
+
ActiveRecord::Base.transaction do
|
23
|
+
result ? success_callback(account) : failed_callback(account)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
# NOTE: Verify passwords even when accounts are not found to prevent timing attacks.
|
31
|
+
def prevent_timing_atack
|
32
|
+
return true unless aikotoba_prevent_timing_atack
|
33
|
+
account = @account_class.build_by(attributes: {email: @email, password: @password})
|
34
|
+
account.password_match?(@password)
|
35
|
+
true
|
36
|
+
end
|
37
|
+
|
38
|
+
def aikotoba_prevent_timing_atack
|
39
|
+
Aikotoba.prevent_timing_atack
|
40
|
+
end
|
41
|
+
|
42
|
+
def success_callback(account)
|
43
|
+
account.authentication_success!
|
44
|
+
end
|
45
|
+
|
46
|
+
def failed_callback(account)
|
47
|
+
account.authentication_failed!
|
48
|
+
lock_when_should_lock!(account) if @lockable
|
49
|
+
end
|
50
|
+
|
51
|
+
def find_by_identifier
|
52
|
+
@account_class.authenticatable.find_by(email: @email)
|
53
|
+
end
|
54
|
+
|
55
|
+
def authenticate(account)
|
56
|
+
account.password_match?(@password) ? account : nil
|
57
|
+
end
|
58
|
+
|
59
|
+
concerning :Lockable do
|
60
|
+
def lock_when_should_lock!(account)
|
61
|
+
@lock_service.lock!(account: account, notify: true) if account.should_lock?
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikotoba
|
4
|
+
class Account::Service::Confirmation
|
5
|
+
def self.create_token!(account:, notify: false)
|
6
|
+
new(account: account).create_token!(notify: notify)
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.confirm!(account:)
|
10
|
+
new(account: account).confirm!
|
11
|
+
end
|
12
|
+
|
13
|
+
def initialize(account:)
|
14
|
+
@account = account
|
15
|
+
end
|
16
|
+
|
17
|
+
def create_token!(notify:)
|
18
|
+
ActiveRecord::Base.transaction do
|
19
|
+
@account.build_confirmation_token.save!
|
20
|
+
@account.confirmation_token.notify if notify
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def confirm!
|
25
|
+
ActiveRecord::Base.transaction do
|
26
|
+
@account.confirm!
|
27
|
+
@account.confirmation_token&.destroy!
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikotoba
|
4
|
+
class Account::Service::Lock
|
5
|
+
def self.lock!(account:, notify: false)
|
6
|
+
new(account: account).lock!(notify: notify)
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.unlock!(account:)
|
10
|
+
new(account: account).unlock!
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.create_unlock_token!(account:, notify: false)
|
14
|
+
new(account: account).create_unlock_token!(notify: notify)
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize(account:)
|
18
|
+
@account = account
|
19
|
+
end
|
20
|
+
|
21
|
+
def lock!(notify:)
|
22
|
+
ActiveRecord::Base.transaction do
|
23
|
+
@account.lock!
|
24
|
+
create_unlock_token!(notify: notify)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def unlock!
|
29
|
+
ActiveRecord::Base.transaction do
|
30
|
+
@account.unlock!
|
31
|
+
@account.unlock_token&.destroy!
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def create_unlock_token!(notify:)
|
36
|
+
ActiveRecord::Base.transaction do
|
37
|
+
@account.build_unlock_token.save!
|
38
|
+
@account.unlock_token.notify if notify
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikotoba
|
4
|
+
class Account::Service::Recovery
|
5
|
+
def self.create_token!(account:, notify: false)
|
6
|
+
new(account: account).create_token!(notify: notify)
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.recover!(account:, new_password:)
|
10
|
+
new(account: account).recover!(new_password: new_password)
|
11
|
+
end
|
12
|
+
|
13
|
+
def initialize(account:)
|
14
|
+
@account = account
|
15
|
+
end
|
16
|
+
|
17
|
+
def create_token!(notify:)
|
18
|
+
ActiveRecord::Base.transaction do
|
19
|
+
@account.build_recovery_token.save!
|
20
|
+
@account.recovery_token.notify if notify
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def recover!(new_password:)
|
25
|
+
ActiveRecord::Base.transaction do
|
26
|
+
@account.recover!(new_password: new_password)
|
27
|
+
@account.recovery_token&.destroy!
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikotoba
|
4
|
+
class Account::Service::Registration
|
5
|
+
def self.call!(account:)
|
6
|
+
new.call!(account: account)
|
7
|
+
end
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
@account_class = Account
|
11
|
+
@confirm_service = Account::Service::Confirmation
|
12
|
+
@confirmable = @account_class.confirmable?
|
13
|
+
end
|
14
|
+
|
15
|
+
def call!(account:)
|
16
|
+
ActiveRecord::Base.transaction do
|
17
|
+
account.save!
|
18
|
+
send_confirmation_token!(account) if @confirmable
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
concerning :Confirmable do
|
25
|
+
def send_confirmation_token!(account)
|
26
|
+
@confirm_service.create_token!(account: account, notify: true)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikotoba
|
4
|
+
class Account::UnlockToken < ApplicationRecord
|
5
|
+
include TokenEncryptable
|
6
|
+
belongs_to :account, class_name: "Aikotoba::Account", foreign_key: "aikotoba_account_id"
|
7
|
+
validates :token, presence: true
|
8
|
+
validates :expired_at, presence: true
|
9
|
+
|
10
|
+
scope :active, ->(now: Time.current) { where("expired_at >= ?", now) }
|
11
|
+
|
12
|
+
after_initialize do |record|
|
13
|
+
token = Account::Value::Token.new(extipry: Aikotoba.unlock_token_expiry)
|
14
|
+
record.token ||= token.value
|
15
|
+
record.expired_at ||= token.expired_at
|
16
|
+
end
|
17
|
+
|
18
|
+
def notify
|
19
|
+
AccountMailer.with(account: account).unlock.deliver_now
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "argon2"
|
4
|
+
|
5
|
+
module Aikotoba
|
6
|
+
class Account::Value::Password
|
7
|
+
LENGTH_RENGE = Aikotoba.password_length_range
|
8
|
+
|
9
|
+
def initialize(
|
10
|
+
value:,
|
11
|
+
pepper: Aikotoba.password_pepper
|
12
|
+
)
|
13
|
+
@value = value
|
14
|
+
@pepper = pepper
|
15
|
+
end
|
16
|
+
|
17
|
+
attr_reader :value
|
18
|
+
|
19
|
+
def match?(digest:)
|
20
|
+
verify_password?(password_with_pepper(value), digest)
|
21
|
+
end
|
22
|
+
|
23
|
+
def digest
|
24
|
+
return "" if value.blank?
|
25
|
+
generate_hash(password_with_pepper(value))
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def verify_password?(password, digest)
|
31
|
+
Argon2::Password.verify_password(password, digest)
|
32
|
+
rescue Argon2::ArgonHashFail # NOTE: If an invalid digest is passed, consider it a mismatch.
|
33
|
+
false
|
34
|
+
end
|
35
|
+
|
36
|
+
def password_with_pepper(password)
|
37
|
+
"#{password}-#{@pepper}"
|
38
|
+
end
|
39
|
+
|
40
|
+
def generate_hash(password)
|
41
|
+
# NOTE: Adjusted to be OWASAP's recommended value by default.
|
42
|
+
# > Use Argon2id with a minimum configuration of 15 MiB of memory, an iteration count of 2, and 1 degree of parallelism.
|
43
|
+
# > https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#introduction
|
44
|
+
argon = Argon2::Password.new(t_cost: 2, m_cost: 14, p_cost: 1)
|
45
|
+
argon.create(password)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikotoba
|
4
|
+
class Account::Value::Token
|
5
|
+
def initialize(extipry:)
|
6
|
+
@value = build_token
|
7
|
+
@expired_at = extipry.since
|
8
|
+
end
|
9
|
+
|
10
|
+
attr_reader :value, :expired_at
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
def build_token
|
15
|
+
SecureRandom.urlsafe_base64(32)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|