aikotoba 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +346 -198
- data/app/controllers/aikotoba/accounts_controller.rb +6 -1
- data/app/controllers/aikotoba/application_controller.rb +7 -1
- data/app/controllers/aikotoba/confirms_controller.rb +2 -2
- data/app/controllers/aikotoba/magic_links_controller.rb +103 -0
- data/app/controllers/aikotoba/recoveries_controller.rb +2 -2
- data/app/controllers/aikotoba/sessions_controller.rb +2 -2
- data/app/controllers/aikotoba/unlocks_controller.rb +2 -2
- data/app/controllers/concerns/aikotoba/authenticatable.rb +1 -22
- data/app/controllers/concerns/aikotoba/protection/rate_limiting.rb +2 -11
- data/app/controllers/concerns/aikotoba/request_backable.rb +53 -0
- data/app/controllers/concerns/aikotoba/scopable.rb +5 -5
- data/app/mailers/aikotoba/account_mailer.rb +35 -3
- data/app/models/aikotoba/account/confirmation_token.rb +6 -1
- data/app/models/aikotoba/account/magic_link.rb +38 -0
- data/app/models/aikotoba/account/magic_link_token.rb +27 -0
- data/app/models/aikotoba/account/{password → password_hash}/argon2.rb +1 -1
- data/app/models/aikotoba/account/password_hash.rb +97 -0
- data/app/models/aikotoba/account/recovery.rb +27 -1
- data/app/models/aikotoba/account/recovery_token.rb +6 -1
- data/app/models/aikotoba/account/session.rb +5 -3
- data/app/models/aikotoba/account/unlock_token.rb +6 -1
- data/app/models/aikotoba/account.rb +80 -60
- data/app/models/concerns/aikotoba/enabled_feature_checkable.rb +8 -0
- data/app/models/concerns/aikotoba/optional_association.rb +82 -0
- data/app/models/concerns/aikotoba/token_encryptable.rb +1 -11
- data/app/views/aikotoba/account_mailer/magic_link.html.erb +3 -0
- data/app/views/aikotoba/magic_links/new.html.erb +14 -0
- data/app/views/aikotoba/sessions/_links.html.erb +6 -1
- data/config/locales/en.yml +10 -0
- data/config/routes.rb +25 -16
- data/db/migrate/20211204121532_create_aikotoba_accounts.rb +77 -33
- data/lib/aikotoba/configuration.rb +99 -0
- data/lib/aikotoba/constraints/magic_link_authenticatable_constraint.rb +7 -0
- data/lib/aikotoba/engine.rb +13 -0
- data/lib/aikotoba/errors.rb +2 -0
- data/lib/aikotoba/scope.rb +64 -0
- data/lib/aikotoba/scopes.rb +102 -0
- data/lib/aikotoba/test/authentication_helper.rb +4 -4
- data/lib/aikotoba/version.rb +1 -1
- data/lib/aikotoba.rb +33 -67
- metadata +16 -5
- data/app/models/aikotoba/account/password.rb +0 -34
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
class CreateAikotobaAccounts < ActiveRecord::Migration[6.1]
|
|
2
|
+
# NOTE: The tables below the "optional features" marker belong to features that are
|
|
3
|
+
# disabled by default (Aikotoba.confirmable / .lockable / .recoverable /
|
|
4
|
+
# .magic_link_authenticatable all default to false), so they ship commented out.
|
|
5
|
+
# Uncomment the block for each feature you enable -- before running db:migrate
|
|
6
|
+
# on a fresh install, or in a new migration of your own if you enable the
|
|
7
|
+
# feature later.
|
|
8
|
+
#
|
|
9
|
+
# Leaving them commented out is safe: Aikotoba never queries a feature's table
|
|
10
|
+
# while that table does not exist, including on Account#destroy! and session
|
|
11
|
+
# revocation. The reverse is not true -- enabling a feature's flag without its
|
|
12
|
+
# table raises a real database error the first time that feature runs.
|
|
2
13
|
def change
|
|
3
14
|
create_table :aikotoba_accounts do |t|
|
|
4
15
|
t.belongs_to :authenticate_target, polymorphic: true, index: {unique: true}
|
|
5
16
|
t.string :email, null: false, index: {unique: true}
|
|
6
|
-
t.string :password_digest, null: false
|
|
7
17
|
t.boolean :confirmed, null: false, default: false
|
|
8
18
|
t.integer :failed_attempts, null: false, default: 0
|
|
9
19
|
t.boolean :locked, null: false, default: false
|
|
@@ -11,51 +21,85 @@ class CreateAikotobaAccounts < ActiveRecord::Migration[6.1]
|
|
|
11
21
|
t.timestamps
|
|
12
22
|
end
|
|
13
23
|
|
|
14
|
-
create_table :
|
|
15
|
-
t.belongs_to :aikotoba_account, null: false, foreign_key: true
|
|
16
|
-
t.string :token, null: false, index: {unique: true}
|
|
17
|
-
t.string :origin, null: false, default: "browser"
|
|
18
|
-
t.string :ip_address
|
|
19
|
-
t.string :user_agent
|
|
20
|
-
t.datetime :expired_at, null: false
|
|
21
|
-
|
|
22
|
-
t.timestamps
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
create_table :aikotoba_account_confirmation_tokens do |t|
|
|
24
|
+
create_table :aikotoba_account_password_hashes do |t|
|
|
26
25
|
t.belongs_to(
|
|
27
26
|
:aikotoba_account,
|
|
28
|
-
|
|
29
|
-
index: {unique: true, name: "
|
|
27
|
+
null: false, foreign_key: true,
|
|
28
|
+
index: {unique: true, name: "index_account_password_hashes_on_account_id"}
|
|
30
29
|
)
|
|
31
|
-
t.string :
|
|
32
|
-
t.datetime :expired_at, null: false
|
|
30
|
+
t.string :digest, null: false
|
|
33
31
|
|
|
34
32
|
t.timestamps
|
|
35
33
|
end
|
|
36
34
|
|
|
37
|
-
create_table :
|
|
38
|
-
t.belongs_to
|
|
39
|
-
:aikotoba_account,
|
|
40
|
-
null: false, foreign_key: true,
|
|
41
|
-
index: {unique: true, name: "index_account_unlock_tokens_on_account_id"}
|
|
42
|
-
)
|
|
35
|
+
create_table :aikotoba_account_sessions do |t|
|
|
36
|
+
t.belongs_to :aikotoba_account, null: false, foreign_key: true
|
|
43
37
|
t.string :token, null: false, index: {unique: true}
|
|
38
|
+
t.string :ip_address
|
|
39
|
+
t.string :user_agent
|
|
44
40
|
t.datetime :expired_at, null: false
|
|
45
41
|
|
|
46
42
|
t.timestamps
|
|
47
43
|
end
|
|
48
44
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
null: false, foreign_key: true,
|
|
53
|
-
index: {unique: true, name: "index_account_recovery_tokens_on_account_id"}
|
|
54
|
-
)
|
|
55
|
-
t.string :token, null: false, index: {unique: true}
|
|
56
|
-
t.datetime :expired_at, null: false
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
# Optional features -- uncomment the ones you enable. See the NOTE above.
|
|
47
|
+
# ------------------------------------------------------------------
|
|
57
48
|
|
|
58
|
-
|
|
59
|
-
|
|
49
|
+
# Aikotoba.confirmable = true
|
|
50
|
+
#
|
|
51
|
+
# create_table :aikotoba_account_confirmation_tokens do |t|
|
|
52
|
+
# t.belongs_to(
|
|
53
|
+
# :aikotoba_account,
|
|
54
|
+
# foreign_key: true, null: false,
|
|
55
|
+
# index: {unique: true, name: "index_account_confirmation_tokens_on_account_id"}
|
|
56
|
+
# )
|
|
57
|
+
# t.string :token, null: false, index: {unique: true}
|
|
58
|
+
# t.datetime :expired_at, null: false
|
|
59
|
+
#
|
|
60
|
+
# t.timestamps
|
|
61
|
+
# end
|
|
62
|
+
|
|
63
|
+
# Aikotoba.lockable = true
|
|
64
|
+
#
|
|
65
|
+
# create_table :aikotoba_account_unlock_tokens do |t|
|
|
66
|
+
# t.belongs_to(
|
|
67
|
+
# :aikotoba_account,
|
|
68
|
+
# null: false, foreign_key: true,
|
|
69
|
+
# index: {unique: true, name: "index_account_unlock_tokens_on_account_id"}
|
|
70
|
+
# )
|
|
71
|
+
# t.string :token, null: false, index: {unique: true}
|
|
72
|
+
# t.datetime :expired_at, null: false
|
|
73
|
+
#
|
|
74
|
+
# t.timestamps
|
|
75
|
+
# end
|
|
76
|
+
|
|
77
|
+
# Aikotoba.recoverable = true
|
|
78
|
+
#
|
|
79
|
+
# create_table :aikotoba_account_recovery_tokens do |t|
|
|
80
|
+
# t.belongs_to(
|
|
81
|
+
# :aikotoba_account,
|
|
82
|
+
# null: false, foreign_key: true,
|
|
83
|
+
# index: {unique: true, name: "index_account_recovery_tokens_on_account_id"}
|
|
84
|
+
# )
|
|
85
|
+
# t.string :token, null: false, index: {unique: true}
|
|
86
|
+
# t.datetime :expired_at, null: false
|
|
87
|
+
#
|
|
88
|
+
# t.timestamps
|
|
89
|
+
# end
|
|
90
|
+
|
|
91
|
+
# Aikotoba.magic_link_authenticatable = true
|
|
92
|
+
#
|
|
93
|
+
# create_table :aikotoba_account_magic_link_tokens do |t|
|
|
94
|
+
# t.belongs_to(
|
|
95
|
+
# :aikotoba_account,
|
|
96
|
+
# null: false, foreign_key: true,
|
|
97
|
+
# index: {unique: true, name: "index_account_magic_link_tokens_on_account_id"}
|
|
98
|
+
# )
|
|
99
|
+
# t.string :token, null: false, index: {unique: true}
|
|
100
|
+
# t.datetime :expired_at, null: false
|
|
101
|
+
#
|
|
102
|
+
# t.timestamps
|
|
103
|
+
# end
|
|
60
104
|
end
|
|
61
105
|
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/object/deep_dup"
|
|
4
|
+
require "active_support/core_ext/integer/time"
|
|
5
|
+
require "aikotoba/scopes"
|
|
6
|
+
|
|
7
|
+
module Aikotoba
|
|
8
|
+
# NOTE: Every setting, set through Aikotoba.configure and read through Aikotoba.xxx.
|
|
9
|
+
class Configuration
|
|
10
|
+
SETTINGS = %i[
|
|
11
|
+
parent_controller parent_mailer mailer_sender email_format password_pepper
|
|
12
|
+
password_length_range session_expiry
|
|
13
|
+
registerable
|
|
14
|
+
confirmable confirmation_token_expiry confirmation_rate_limit_options
|
|
15
|
+
lockable max_failed_attempts unlock_token_expiry unlock_rate_limit_options
|
|
16
|
+
recoverable recovery_token_expiry recovery_rate_limit_options
|
|
17
|
+
magic_link_authenticatable magic_link_token_expiry magic_link_rate_limit_options
|
|
18
|
+
encrypted_token
|
|
19
|
+
scopes
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
attr_accessor(*SETTINGS)
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@parent_controller = "ApplicationController"
|
|
26
|
+
@parent_mailer = "ActionMailer::Base"
|
|
27
|
+
@mailer_sender = "from@example.com"
|
|
28
|
+
@email_format = /\A[^\s]+@[^\s]+\z/
|
|
29
|
+
@password_pepper = "aikotoba-default-pepper"
|
|
30
|
+
@password_length_range = 8..100
|
|
31
|
+
@session_expiry = 7.days
|
|
32
|
+
|
|
33
|
+
# for Registerable
|
|
34
|
+
@registerable = true
|
|
35
|
+
|
|
36
|
+
# for Confirmable
|
|
37
|
+
@confirmable = false
|
|
38
|
+
@confirmation_token_expiry = 1.day
|
|
39
|
+
@confirmation_rate_limit_options = {}
|
|
40
|
+
|
|
41
|
+
# for Lockable
|
|
42
|
+
@lockable = false
|
|
43
|
+
@max_failed_attempts = 10
|
|
44
|
+
@unlock_token_expiry = 1.day
|
|
45
|
+
@unlock_rate_limit_options = {}
|
|
46
|
+
|
|
47
|
+
# for Recoverable
|
|
48
|
+
@recoverable = false
|
|
49
|
+
@recovery_token_expiry = 4.hours
|
|
50
|
+
@recovery_rate_limit_options = {}
|
|
51
|
+
|
|
52
|
+
# for MagicLinkAuthenticatable
|
|
53
|
+
@magic_link_authenticatable = false
|
|
54
|
+
@magic_link_token_expiry = 15.minutes
|
|
55
|
+
@magic_link_rate_limit_options = {}
|
|
56
|
+
|
|
57
|
+
# for encrypt token
|
|
58
|
+
@encrypted_token = false
|
|
59
|
+
|
|
60
|
+
@scopes = Scopes.new({
|
|
61
|
+
default: {
|
|
62
|
+
authenticate_for: nil,
|
|
63
|
+
root_path: "/",
|
|
64
|
+
session_key: "aikotoba_session_token",
|
|
65
|
+
sign_in_path: "/sign_in",
|
|
66
|
+
sign_out_path: "/sign_out",
|
|
67
|
+
after_sign_in_path: "/",
|
|
68
|
+
after_sign_out_path: "/sign_in",
|
|
69
|
+
sign_up_path: "/sign_up",
|
|
70
|
+
confirm_path: "/confirm",
|
|
71
|
+
unlock_path: "/unlock",
|
|
72
|
+
recover_path: "/recover",
|
|
73
|
+
magic_link_path: "/magic_link"
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# NOTE: A copy shares nothing with the original, so changing one (scopes, rate limit
|
|
79
|
+
# options) leaves the other as it was.
|
|
80
|
+
def initialize_copy(source)
|
|
81
|
+
super
|
|
82
|
+
SETTINGS.each { |name| public_send(:"#{name}=", source.public_send(name).deep_dup) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def default_scope
|
|
86
|
+
scopes.default_scope
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# NOTE: Merges into the default scope (does not replace, keeps the other keys).
|
|
90
|
+
# Example: config.default_scope = { sign_in_path: "/custom" }
|
|
91
|
+
def default_scope=(hash)
|
|
92
|
+
default_scope.merge!(hash)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def add_scope(name, config = {})
|
|
96
|
+
scopes.add(name, config)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
data/lib/aikotoba/engine.rb
CHANGED
|
@@ -3,5 +3,18 @@
|
|
|
3
3
|
module Aikotoba
|
|
4
4
|
class Engine < ::Rails::Engine
|
|
5
5
|
isolate_namespace Aikotoba
|
|
6
|
+
|
|
7
|
+
initializer "aikotoba.deprecator" do |app|
|
|
8
|
+
app.deprecators[:aikotoba] = Aikotoba.deprecator
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# NOTE: Runs once the host app's initializers (where Aikotoba.configure lives) are done,
|
|
12
|
+
# to also catch scopes changed outside configure. Not in config/routes.rb: Rails 8
|
|
13
|
+
# draws routes lazily in development and test, so a check there would only fire on
|
|
14
|
+
# the first request.
|
|
15
|
+
config.after_initialize do
|
|
16
|
+
Aikotoba.scopes.validate!
|
|
17
|
+
Aikotoba.scopes.warn_rewritten_paths
|
|
18
|
+
end
|
|
6
19
|
end
|
|
7
20
|
end
|
data/lib/aikotoba/errors.rb
CHANGED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/hash_with_indifferent_access"
|
|
4
|
+
|
|
5
|
+
module Aikotoba
|
|
6
|
+
# NOTE: One scope's settings. Still a HashWithIndifferentAccess, so the documented
|
|
7
|
+
# Aikotoba.default_scope[:key] / Aikotoba.scopes[:name][:key] reads and writes keep
|
|
8
|
+
# working; the methods below answer the questions callers used to ask of the raw hash.
|
|
9
|
+
class Scope < ActiveSupport::HashWithIndifferentAccess
|
|
10
|
+
def authenticate_for
|
|
11
|
+
self[:authenticate_for]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# NOTE: A scope restricted by authenticate_for gets no sign-up: its accounts need an
|
|
15
|
+
# authenticate_target of that type, which the generic sign-up form can't create.
|
|
16
|
+
# Routes and the sign-in page's sign-up link both read this, so they can't disagree.
|
|
17
|
+
# blank? (not nil?) matches Account.for_target_type, which treats "" as unrestricted.
|
|
18
|
+
def registerable?
|
|
19
|
+
authenticate_for.blank?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# NOTE: Whether this is the scope set up for the target type (authenticate_for: "Admin"
|
|
23
|
+
# for "Admin"). An unrestricted scope also signs Admins in, but isn't "for" them.
|
|
24
|
+
def for?(target_type_name)
|
|
25
|
+
authenticate_for.present? && authenticate_for.to_s == target_type_name.to_s
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# NOTE: Whether a request path belongs to this scope. Scopable resolves the current
|
|
29
|
+
# scope with this, so route_path keeps each route inside it.
|
|
30
|
+
# A trailing slash on root_path is ignored, so "/admin/" covers "/admin/sign_in" too.
|
|
31
|
+
def covers?(path)
|
|
32
|
+
path == root || path.start_with?("#{root}/")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# NOTE: An absolute path with at least one segment ("/admin", "/admin/", "/a/b"), the
|
|
36
|
+
# only shape covers? handles: "/" (or unset) would cover every request, and a
|
|
37
|
+
# relative or malformed one ("admin", "/admin//") none, not even its own routes.
|
|
38
|
+
def valid_root_path?
|
|
39
|
+
self[:root_path].to_s.match?(%r{\A(/[^/\s]+)+/?\z})
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# NOTE: The path config/routes.rb draws for a path setting: the value as is when it is
|
|
43
|
+
# already under root_path, otherwise prefixed with root_path. So a path add_scope
|
|
44
|
+
# copied from the default scope (/magic_link in an /admin scope) still lands in this
|
|
45
|
+
# scope (/admin/magic_link), and "/admin/..." isn't prefixed twice. Only for routed
|
|
46
|
+
# paths; redirect targets like after_sign_in_path may point anywhere.
|
|
47
|
+
def route_path(key)
|
|
48
|
+
path = self[key]
|
|
49
|
+
covers?(path) ? path : File.join(self[:root_path], path)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# NOTE: The *_path settings config/routes.rb draws through route_path; the others are
|
|
53
|
+
# the scope's own root and redirect targets, which may point anywhere.
|
|
54
|
+
def route_path_keys
|
|
55
|
+
keys.select { |key| key.end_with?("_path") } - %w[root_path after_sign_in_path after_sign_out_path]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def root
|
|
61
|
+
self[:root_path].to_s.chomp("/")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/hash_with_indifferent_access"
|
|
4
|
+
require "aikotoba/scope"
|
|
5
|
+
|
|
6
|
+
module Aikotoba
|
|
7
|
+
# NOTE: All scopes, keyed by name. Still a HashWithIndifferentAccess, so Aikotoba.scopes
|
|
8
|
+
# keeps its documented Hash behavior; the cross-scope rules live here.
|
|
9
|
+
class Scopes < ActiveSupport::HashWithIndifferentAccess
|
|
10
|
+
# NOTE: Not named default: that would override Hash#default(key = nil), which Hash calls
|
|
11
|
+
# with the key on every miss (scopes[:typo] raised ArgumentError), and which
|
|
12
|
+
# HashWithIndifferentAccess copies into every dup (select, reject, deep_dup), turning
|
|
13
|
+
# the default scope into the copy's fallback value for missing keys.
|
|
14
|
+
def default_scope
|
|
15
|
+
self[:default]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def add(name, config = {})
|
|
19
|
+
scope = default_scope.merge(config)
|
|
20
|
+
validate!(merge(name => scope))
|
|
21
|
+
self[name] = scope
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# NOTE: The scope whose links an account's emails use: the scope whose authenticate_for
|
|
25
|
+
# matches the account's target type (unique, see validate!), else the default.
|
|
26
|
+
# Emails are rendered in a job, with no request to tell which scope was used.
|
|
27
|
+
def name_for(target_type_name)
|
|
28
|
+
name, _ = find { |_, scope| scope.for?(target_type_name) }
|
|
29
|
+
name || "default"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# NOTE: Run by the Engine after initialization. route_path prefixes any path outside
|
|
33
|
+
# root_path; for one inherited from the default scope or written relative that is
|
|
34
|
+
# the intent, but an absolute path set on the scope itself was probably meant as is.
|
|
35
|
+
# A value equal to the default scope's can't be told from an inherited one, so it is
|
|
36
|
+
# treated as inherited.
|
|
37
|
+
def warn_rewritten_paths(logger = Rails.logger)
|
|
38
|
+
except(:default).each do |name, scope|
|
|
39
|
+
scope.route_path_keys.each do |key|
|
|
40
|
+
path = scope[key]
|
|
41
|
+
rewritten = !scope.covers?(path)
|
|
42
|
+
absolute = path.start_with?("/")
|
|
43
|
+
same_as_default = path == default_scope[key]
|
|
44
|
+
next unless rewritten && absolute && !same_as_default
|
|
45
|
+
|
|
46
|
+
route = scope.route_path(key)
|
|
47
|
+
logger&.warn("[Aikotoba] #{name}.#{key} #{path.inspect} is drawn as #{route.inspect}; " \
|
|
48
|
+
"write #{route.inspect} (or a relative path) to silence this.")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# NOTE: Checked by add, and again after initialization (Engine), to catch default_scope=
|
|
54
|
+
# or direct edits made afterwards.
|
|
55
|
+
def validate!(scopes = self)
|
|
56
|
+
validate_root_paths!(scopes)
|
|
57
|
+
validate_unique_authenticate_for!(scopes)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# NOTE: An added scope serves the requests its root_path covers (Scope#covers?). One at "/"
|
|
63
|
+
# would take over every request, the host app's included, and a malformed one would
|
|
64
|
+
# leave its own pages to the default scope. add_scope copies root_path "/" from the
|
|
65
|
+
# default scope unless it is set.
|
|
66
|
+
def validate_root_paths!(scopes)
|
|
67
|
+
invalid = scopes.except(:default).reject { |_, scope| scope.valid_root_path? }
|
|
68
|
+
return if invalid.empty?
|
|
69
|
+
|
|
70
|
+
details = invalid.map { |name, scope| "#{name} (#{scope[:root_path].inspect})" }.join(", ")
|
|
71
|
+
raise Errors::InvalidScopeConfiguration,
|
|
72
|
+
"root_path of an added scope must be an absolute path other than \"/\", like \"/admin\", but is not on: " \
|
|
73
|
+
"#{details}. add_scope copies root_path \"/\" from the default scope unless you set it."
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# NOTE: Each authenticate_for may belong to one scope only, so an account's type maps to
|
|
77
|
+
# exactly one scope (see name_for).
|
|
78
|
+
def validate_unique_authenticate_for!(scopes)
|
|
79
|
+
shared = scopes
|
|
80
|
+
.select { |_, scope| scope.authenticate_for.present? }
|
|
81
|
+
.group_by { |_, scope| scope.authenticate_for.to_s }
|
|
82
|
+
.select { |_, entries| entries.size > 1 }
|
|
83
|
+
return if shared.empty?
|
|
84
|
+
|
|
85
|
+
details = shared.map { |type, entries| "#{type} (#{entries.map(&:first).join(", ")})" }.join("; ")
|
|
86
|
+
raise Errors::InvalidScopeConfiguration,
|
|
87
|
+
"authenticate_for must be unique per scope, but is shared: #{details}. " \
|
|
88
|
+
"add_scope copies authenticate_for from the default scope unless you set it, so set it " \
|
|
89
|
+
"explicitly on the added scope (nil for an unrestricted scope)."
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# NOTE: HashWithIndifferentAccess turns every Hash it stores into a plain
|
|
93
|
+
# HashWithIndifferentAccess; store a Scope instead, so a scope assigned directly
|
|
94
|
+
# (scopes[:name] = {...}) still answers registerable? / for?. :to_hash is the
|
|
95
|
+
# conversion back to a plain Hash.
|
|
96
|
+
def convert_value(value, conversion: nil)
|
|
97
|
+
return Scope.new(value) if value.is_a?(Hash) && !value.is_a?(Scope) && conversion != :to_hash
|
|
98
|
+
|
|
99
|
+
super
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -9,8 +9,8 @@ module Aikotoba
|
|
|
9
9
|
follow_redirect!
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
def aikotoba_sign_in(account)
|
|
13
|
-
post aikotoba.new_session_path, params: {account: {email: account.email, password:
|
|
12
|
+
def aikotoba_sign_in(account, password:)
|
|
13
|
+
post aikotoba.new_session_path, params: {account: {email: account.email, password: password}}
|
|
14
14
|
follow_redirect!
|
|
15
15
|
end
|
|
16
16
|
end
|
|
@@ -24,10 +24,10 @@ module Aikotoba
|
|
|
24
24
|
end
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
def aikotoba_sign_in(account)
|
|
27
|
+
def aikotoba_sign_in(account, password:)
|
|
28
28
|
if page.driver.is_a?(Capybara::RackTest::Driver)
|
|
29
29
|
disable_forgery_protection do
|
|
30
|
-
page.driver.send(:post, aikotoba.new_session_path, account: {email: account.email, password:
|
|
30
|
+
page.driver.send(:post, aikotoba.new_session_path, account: {email: account.email, password: password})
|
|
31
31
|
end
|
|
32
32
|
else
|
|
33
33
|
raise NotImplementedError, "Sorry. Only RackTest::Driver is supported as a test helper for Aikotoba's authentication."
|
data/lib/aikotoba/version.rb
CHANGED
data/lib/aikotoba.rb
CHANGED
|
@@ -3,83 +3,49 @@
|
|
|
3
3
|
require "aikotoba/version"
|
|
4
4
|
require "aikotoba/engine"
|
|
5
5
|
require "aikotoba/errors"
|
|
6
|
+
require "aikotoba/configuration"
|
|
6
7
|
|
|
7
8
|
module Aikotoba
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
mattr_accessor(:keep_legacy_login_session) { false }
|
|
18
|
-
|
|
19
|
-
# for Registerable
|
|
20
|
-
mattr_accessor(:registerable) { true }
|
|
21
|
-
|
|
22
|
-
# for Confirmable
|
|
23
|
-
mattr_accessor(:confirmable) { false }
|
|
24
|
-
mattr_accessor(:confirmation_token_expiry) { 1.day }
|
|
25
|
-
mattr_accessor(:confirmation_rate_limit_options) { {} }
|
|
26
|
-
|
|
27
|
-
# for Lockable
|
|
28
|
-
mattr_accessor(:lockable) { false }
|
|
29
|
-
mattr_accessor(:max_failed_attempts) { 10 }
|
|
30
|
-
mattr_accessor(:unlock_token_expiry) { 1.day }
|
|
31
|
-
mattr_accessor(:unlock_rate_limit_options) { {} }
|
|
32
|
-
|
|
33
|
-
# for Recoverable
|
|
34
|
-
mattr_accessor(:recoverable) { false }
|
|
35
|
-
mattr_accessor(:recovery_token_expiry) { 4.hours }
|
|
36
|
-
mattr_accessor(:recovery_rate_limit_options) { {} }
|
|
37
|
-
|
|
38
|
-
# for encrypt token
|
|
39
|
-
mattr_accessor(:encrypted_token) { false }
|
|
40
|
-
|
|
41
|
-
mattr_accessor(:scopes) {
|
|
42
|
-
HashWithIndifferentAccess.new({
|
|
43
|
-
default: {
|
|
44
|
-
authenticate_for: nil,
|
|
45
|
-
root_path: "/",
|
|
46
|
-
session_key: "aikotoba_session_token",
|
|
47
|
-
sign_in_path: "/sign_in",
|
|
48
|
-
sign_out_path: "/sign_out",
|
|
49
|
-
after_sign_in_path: "/",
|
|
50
|
-
after_sign_out_path: "/sign_in",
|
|
51
|
-
sign_up_path: "/sign_up",
|
|
52
|
-
confirm_path: "/confirm",
|
|
53
|
-
unlock_path: "/unlock",
|
|
54
|
-
recover_path: "/recover"
|
|
55
|
-
}
|
|
56
|
-
})
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
def self.default_scope
|
|
60
|
-
scopes[:default]
|
|
9
|
+
# NOTE: Set every setting in one place:
|
|
10
|
+
# Aikotoba.configure do |config|
|
|
11
|
+
# config.confirmable = true
|
|
12
|
+
# config.add_scope(:admin, { root_path: "/admin", authenticate_for: "Admin" })
|
|
13
|
+
# end
|
|
14
|
+
# The scopes are checked once the block has set everything, feature flags included.
|
|
15
|
+
def self.configure
|
|
16
|
+
yield config
|
|
17
|
+
scopes.validate!
|
|
61
18
|
end
|
|
62
19
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def self.default_scope=(hash)
|
|
66
|
-
default_scope.merge!(hash)
|
|
20
|
+
def self.config
|
|
21
|
+
@config ||= Configuration.new
|
|
67
22
|
end
|
|
68
23
|
|
|
69
|
-
def self.
|
|
70
|
-
|
|
24
|
+
def self.deprecator
|
|
25
|
+
@deprecator ||= ActiveSupport::Deprecation.new("1.0", "Aikotoba")
|
|
71
26
|
end
|
|
72
27
|
|
|
73
|
-
#
|
|
74
|
-
default_scope
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
28
|
+
# NOTE: Read a setting with Aikotoba.xxx. The Aikotoba.xxx= writers, add_scope and
|
|
29
|
+
# default_scope= still work for initializers written before configure existed, but
|
|
30
|
+
# are deprecated and will be removed in 1.0.
|
|
31
|
+
class << self
|
|
32
|
+
delegate(*Configuration::SETTINGS, :default_scope, to: :config)
|
|
33
|
+
|
|
34
|
+
Configuration::SETTINGS.each do |name|
|
|
35
|
+
define_method(:"#{name}=") do |value|
|
|
36
|
+
deprecator.warn("Aikotoba.#{name}= is deprecated. Set config.#{name} in Aikotoba.configure instead.")
|
|
37
|
+
config.public_send(:"#{name}=", value)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def default_scope=(hash)
|
|
42
|
+
deprecator.warn("Aikotoba.default_scope= is deprecated. Set config.default_scope in Aikotoba.configure instead.")
|
|
43
|
+
config.default_scope = hash
|
|
78
44
|
end
|
|
79
45
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
46
|
+
def add_scope(name, scope_config = {})
|
|
47
|
+
deprecator.warn("Aikotoba.add_scope is deprecated. Call config.add_scope in Aikotoba.configure instead.")
|
|
48
|
+
config.add_scope(name, scope_config)
|
|
83
49
|
end
|
|
84
50
|
end
|
|
85
51
|
end
|