aikotoba 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +334 -0
  4. data/Rakefile +18 -0
  5. data/app/controllers/aikotoba/accounts_controller.rb +61 -0
  6. data/app/controllers/aikotoba/application_controller.rb +13 -0
  7. data/app/controllers/aikotoba/confirms_controller.rb +103 -0
  8. data/app/controllers/aikotoba/recoveries_controller.rb +120 -0
  9. data/app/controllers/aikotoba/sessions_controller.rb +78 -0
  10. data/app/controllers/aikotoba/unlocks_controller.rb +103 -0
  11. data/app/controllers/concerns/aikotoba/authenticatable.rb +40 -0
  12. data/app/controllers/concerns/aikotoba/protection/session_fixation_attack.rb +21 -0
  13. data/app/controllers/concerns/aikotoba/protection/timing_atack.rb +23 -0
  14. data/app/mailers/aikotoba/account_mailer.rb +24 -0
  15. data/app/mailers/aikotoba/application_mailer.rb +5 -0
  16. data/app/models/aikotoba/account/confirmation_token.rb +22 -0
  17. data/app/models/aikotoba/account/recovery_token.rb +22 -0
  18. data/app/models/aikotoba/account/service/authentication.rb +65 -0
  19. data/app/models/aikotoba/account/service/confirmation.rb +31 -0
  20. data/app/models/aikotoba/account/service/lock.rb +42 -0
  21. data/app/models/aikotoba/account/service/recovery.rb +31 -0
  22. data/app/models/aikotoba/account/service/registration.rb +30 -0
  23. data/app/models/aikotoba/account/unlock_token.rb +22 -0
  24. data/app/models/aikotoba/account/value/password.rb +48 -0
  25. data/app/models/aikotoba/account/value/token.rb +18 -0
  26. data/app/models/aikotoba/account.rb +120 -0
  27. data/app/models/concerns/aikotoba/enabled_feature_checkable.rb +41 -0
  28. data/app/models/concerns/aikotoba/token_encryptable.rb +27 -0
  29. data/app/views/aikotoba/account_mailer/confirm.html.erb +3 -0
  30. data/app/views/aikotoba/account_mailer/recover.html.erb +3 -0
  31. data/app/views/aikotoba/account_mailer/unlock.html.erb +3 -0
  32. data/app/views/aikotoba/accounts/new.html.erb +19 -0
  33. data/app/views/aikotoba/common/_errors.html.erb +9 -0
  34. data/app/views/aikotoba/common/_message.html.erb +8 -0
  35. data/app/views/aikotoba/confirms/new.html.erb +14 -0
  36. data/app/views/aikotoba/recoveries/edit.html.erb +15 -0
  37. data/app/views/aikotoba/recoveries/new.html.erb +14 -0
  38. data/app/views/aikotoba/sessions/_links.html.erb +20 -0
  39. data/app/views/aikotoba/sessions/new.html.erb +16 -0
  40. data/app/views/aikotoba/unlocks/new.html.erb +21 -0
  41. data/app/views/layouts/aikotoba/application.html.erb +11 -0
  42. data/app/views/layouts/aikotoba/mailer.html.erb +13 -0
  43. data/app/views/layouts/aikotoba/mailer.text.erb +1 -0
  44. data/config/locales/en.yml +49 -0
  45. data/config/routes.rb +32 -0
  46. data/db/migrate/20211204121532_create_aikotoba_accounts.rb +50 -0
  47. data/lib/aikotoba/constraints/confirmable_constraint.rb +7 -0
  48. data/lib/aikotoba/constraints/lockable_constraint.rb +7 -0
  49. data/lib/aikotoba/constraints/recoverable_constraint.rb +7 -0
  50. data/lib/aikotoba/constraints/registerable_constraint.rb +7 -0
  51. data/lib/aikotoba/engine.rb +7 -0
  52. data/lib/aikotoba/errors.rb +7 -0
  53. data/lib/aikotoba/test/authentication_helper.rb +48 -0
  54. data/lib/aikotoba/version.rb +5 -0
  55. data/lib/aikotoba.rb +45 -0
  56. data/lib/tasks/aikotoba_tasks.rake +4 -0
  57. 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,5 @@
1
+ module Aikotoba
2
+ class ApplicationMailer < Aikotoba.parent_mailer.constantize
3
+ default from: Aikotoba.mailer_sender
4
+ end
5
+ 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