aikotoba 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|