rom-auth 0.0.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.md +63 -0
- data/lib/rom-auth.rb +6 -0
- data/lib/rom/auth.rb +34 -0
- data/lib/rom/auth/authenticators/authenticator.rb +11 -0
- data/lib/rom/auth/authenticators/password_authenticator.rb +14 -0
- data/lib/rom/auth/configuration.rb +45 -0
- data/lib/rom/auth/digest.rb +34 -0
- data/lib/rom/auth/migration.rb +25 -0
- data/lib/rom/auth/password_verifiers/password_verifier.rb +69 -0
- data/lib/rom/auth/password_verifiers/pbkdf2_verifier.rb +24 -0
- data/lib/rom/auth/plugins/authentication_credentials_plugin.rb +115 -0
- data/lib/rom/auth/plugins/authentication_events_plugin.rb +92 -0
- data/lib/rom/auth/plugins/lockdown_plugin.rb +114 -0
- data/lib/rom/auth/plugins/plugin.rb +22 -0
- data/lib/rom/auth/support/shorthand_symbol.rb +22 -0
- data/lib/rom/auth/system.rb +146 -0
- data/lib/rom/auth/version.rb +5 -0
- data/spec/integration/common_plugins_spec.rb +162 -0
- data/spec/integration/system_spec.rb +68 -0
- data/spec/unit/rom/auth/configuration_spec.rb +27 -0
- data/spec/unit/rom/auth/password_verifiers/pbkdf2_verifier_spec.rb +30 -0
- data/spec/unit/rom/auth/plugins/authentication_events_plugin_spec.rb +109 -0
- data/spec/unit/rom/auth_spec.rb +3 -0
- metadata +242 -0
@@ -0,0 +1,92 @@
|
|
1
|
+
module ROM::Auth
|
2
|
+
module Plugins
|
3
|
+
class AuthenticationEventsPlugin < Plugin
|
4
|
+
|
5
|
+
class Configuration
|
6
|
+
include Virtus.model
|
7
|
+
|
8
|
+
attribute :table_name, Symbol, default: :authentication_events
|
9
|
+
end
|
10
|
+
|
11
|
+
def install
|
12
|
+
system.extend(CallbackOverrides)
|
13
|
+
|
14
|
+
config = configuration
|
15
|
+
|
16
|
+
@mapper = Class.new(ROM::Mapper) do
|
17
|
+
relation(config.table_name)
|
18
|
+
model(AuthenticationEvent)
|
19
|
+
register_as :rom_auth_event
|
20
|
+
end
|
21
|
+
|
22
|
+
@relation = Class.new(ROM::Relation[:sql]) do
|
23
|
+
dataset(config.table_name)
|
24
|
+
end
|
25
|
+
|
26
|
+
@command = Class.new(ROM::Commands::Create[:sql]) do
|
27
|
+
register_as :create
|
28
|
+
relation(config.table_name)
|
29
|
+
result :one
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def migrate(setup)
|
34
|
+
AuthenticationEventsMigration.new(system, setup, configuration).run
|
35
|
+
end
|
36
|
+
|
37
|
+
class AuthenticationEvent
|
38
|
+
include Virtus.value_object(coerce: false)
|
39
|
+
|
40
|
+
values do
|
41
|
+
attribute :user_id, Integer # FIXME should be dynamic and could be account_id
|
42
|
+
attribute :success, Boolean
|
43
|
+
attribute :started_at, DateTime
|
44
|
+
attribute :ended_at, DateTime
|
45
|
+
attribute :identifier, String
|
46
|
+
attribute :type, String
|
47
|
+
attribute :authenticated, Boolean
|
48
|
+
attribute :data, String
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
class AuthenticationEventsMigration < Migration
|
53
|
+
def run
|
54
|
+
auth_config = system.configuration
|
55
|
+
config = self.config
|
56
|
+
user_fk_name = auth_config.user_fk_name
|
57
|
+
|
58
|
+
database.create_table(config.table_name) do
|
59
|
+
primary_key :id
|
60
|
+
foreign_key(user_fk_name, auth_config.users_table_name.to_sym)
|
61
|
+
DateTime :started_at
|
62
|
+
DateTime :ended_at
|
63
|
+
String :identifier
|
64
|
+
String :type
|
65
|
+
Boolean :authenticated
|
66
|
+
Boolean :success
|
67
|
+
String :data
|
68
|
+
# TODO login_ip, failure reason
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
module CallbackOverrides
|
74
|
+
def on_authentication_completed(data)
|
75
|
+
super
|
76
|
+
|
77
|
+
# ROM.env.command(plugins[AuthenticationEventsPlugin].configuration.table_name).try{
|
78
|
+
# create(data)
|
79
|
+
# }
|
80
|
+
ROM.env.command(plugins[AuthenticationEventsPlugin].configuration.table_name).create.call(data)
|
81
|
+
|
82
|
+
# TODO
|
83
|
+
# - log origin/details & warn user
|
84
|
+
# - compute failed logins per minute and alert if above threshold
|
85
|
+
# - throttling
|
86
|
+
#
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
module ROM::Auth
|
2
|
+
module Plugins
|
3
|
+
class LockdownPlugin < Plugin
|
4
|
+
|
5
|
+
class Configuration
|
6
|
+
include Virtus.model
|
7
|
+
|
8
|
+
attribute :table_name, Symbol, default: nil
|
9
|
+
attribute :lock_strategy, Object
|
10
|
+
attribute :unlock_strategy, Object
|
11
|
+
end
|
12
|
+
|
13
|
+
def initialize(*args)
|
14
|
+
super
|
15
|
+
|
16
|
+
# FIXME move to configuration?
|
17
|
+
configuration.table_name ||= system.configuration.users_table_name
|
18
|
+
end
|
19
|
+
|
20
|
+
def install
|
21
|
+
system.extend(CallbackOverrides)
|
22
|
+
|
23
|
+
config = configuration
|
24
|
+
|
25
|
+
@relation = Class.new(ROM::Relation[:sql]) do
|
26
|
+
register_as :rom_auth_lockdowns
|
27
|
+
dataset(config.table_name)
|
28
|
+
|
29
|
+
def by_user_id(user_id)
|
30
|
+
where(id: user_id)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
@mapper = Class.new(ROM::Mapper) do
|
35
|
+
relation(:rom_auth_lockdowns)
|
36
|
+
model(Lockdown)
|
37
|
+
register_as :rom_auth_lockdown
|
38
|
+
end
|
39
|
+
|
40
|
+
@command = Class.new(ROM::Commands::Update[:sql]) do
|
41
|
+
register_as :update
|
42
|
+
relation(:rom_auth_lockdowns)
|
43
|
+
result :one
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def migrate(setup)
|
48
|
+
LockdownMigration.new(system, setup, configuration).run
|
49
|
+
end
|
50
|
+
|
51
|
+
def lock_strategy
|
52
|
+
configuration.lock_strategy
|
53
|
+
end
|
54
|
+
|
55
|
+
def unlock_strategy
|
56
|
+
configuration.unlock_strategy
|
57
|
+
end
|
58
|
+
|
59
|
+
def is_locked?(user_id)
|
60
|
+
#user.locked_at.present?
|
61
|
+
ROM.env.relation(:rom_auth_lockdowns).by_user_id(user_id).as(:rom_auth_lockdown).first.try(:locked_at).present?
|
62
|
+
end
|
63
|
+
|
64
|
+
def lock(user_id, reason)
|
65
|
+
raise ArgumentError unless user_id.is_a?(Integer)
|
66
|
+
ROM.env.command(:rom_auth_lockdowns).update.where(id: user_id).call(locked_at: Time.now, lock_reason: reason)
|
67
|
+
end
|
68
|
+
|
69
|
+
def unlock(user_id)
|
70
|
+
ROM.env.command(:rom_auth_lockdowns).update.where(id: user_id).call(locked_at: nil, lock_reason: nil)
|
71
|
+
end
|
72
|
+
|
73
|
+
class LockdownMigration < Migration
|
74
|
+
def run
|
75
|
+
config = self.config
|
76
|
+
auth_config = system.configuration
|
77
|
+
|
78
|
+
database.alter_table(config.table_name) do
|
79
|
+
add_column :locked_at, DateTime, default: nil, null: true
|
80
|
+
add_column :lock_reason, String, null: true
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
class Lockdown
|
86
|
+
include Virtus.value_object(coerce: false)
|
87
|
+
|
88
|
+
values do
|
89
|
+
attribute :id, Integer
|
90
|
+
attribute :locked_at, DateTime
|
91
|
+
attribute :lock_reason, String
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
module CallbackOverrides
|
96
|
+
def authentication_authorized?(user, credentials)
|
97
|
+
plugin = plugins[LockdownPlugin]
|
98
|
+
|
99
|
+
plugin.unlock_strategy.call(plugin, user, credentials) if plugin.unlock_strategy
|
100
|
+
super && !plugin.is_locked?(user[:id])
|
101
|
+
end
|
102
|
+
|
103
|
+
def on_authentication_completed(data)
|
104
|
+
super
|
105
|
+
|
106
|
+
plugin = plugins[LockdownPlugin]
|
107
|
+
|
108
|
+
plugin.lock_strategy.call(plugin, data[:user_id], data) if plugin.lock_strategy
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module ROM::Auth
|
2
|
+
module Plugins
|
3
|
+
class Plugin
|
4
|
+
|
5
|
+
attr_reader :system, :configuration
|
6
|
+
|
7
|
+
def initialize(system, config)
|
8
|
+
raise ArgumentError unless system.kind_of?(System)
|
9
|
+
@system = system
|
10
|
+
@configuration = config
|
11
|
+
end
|
12
|
+
|
13
|
+
def migrate(*args)
|
14
|
+
end
|
15
|
+
|
16
|
+
def install(*args)
|
17
|
+
raise NotImplementedError
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module ROM::Auth
|
2
|
+
module Support
|
3
|
+
module ShorthandSymbol
|
4
|
+
|
5
|
+
def self.strip(suffix)
|
6
|
+
mod = Module.new
|
7
|
+
|
8
|
+
mod.instance_eval <<-RUBY
|
9
|
+
def included(cls)
|
10
|
+
def cls.shorthand_symbol
|
11
|
+
#name.split('::').last.gsub(/#{suffix.source}/,'').underscore.to_sym
|
12
|
+
name.demodulize.gsub(/#{suffix.source}/,'').underscore.to_sym
|
13
|
+
end
|
14
|
+
end
|
15
|
+
RUBY
|
16
|
+
|
17
|
+
mod
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,146 @@
|
|
1
|
+
module ROM
|
2
|
+
module Auth
|
3
|
+
class System
|
4
|
+
|
5
|
+
attr_reader :configuration, :plugins
|
6
|
+
|
7
|
+
def initialize(config)
|
8
|
+
raise ArgumentError unless config.is_a?(Configuration)
|
9
|
+
@configuration = config
|
10
|
+
|
11
|
+
load_plugins!
|
12
|
+
end
|
13
|
+
|
14
|
+
def migrate!(setup)
|
15
|
+
plugins.each do |_type, plugin|
|
16
|
+
plugin.migrate(setup)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
# def authenticators
|
21
|
+
# Authenticators::Authenticator.descendants.inject({}) do |acc, desc|
|
22
|
+
# acc.merge(desc.shorthand_symbol => desc)
|
23
|
+
# end
|
24
|
+
# end
|
25
|
+
|
26
|
+
def authenticate(credentials)
|
27
|
+
raise ArgumentError unless credentials
|
28
|
+
|
29
|
+
success = false
|
30
|
+
now = Time.now
|
31
|
+
user = identify_user(credentials)
|
32
|
+
|
33
|
+
on_authentication_attempt(credentials)
|
34
|
+
|
35
|
+
if user
|
36
|
+
authenticated = run_authentication_check(credentials)
|
37
|
+
|
38
|
+
if authenticated
|
39
|
+
on_authentication_success(credentials)
|
40
|
+
|
41
|
+
if authentication_authorized?(user, credentials)
|
42
|
+
on_authorized_authentication(credentials)
|
43
|
+
success = true
|
44
|
+
else
|
45
|
+
on_unauthorized_authentication(credentials)
|
46
|
+
end
|
47
|
+
else
|
48
|
+
on_authentication_failure(credentials)
|
49
|
+
end
|
50
|
+
else
|
51
|
+
on_identification_failure(credentials)
|
52
|
+
end
|
53
|
+
|
54
|
+
on_authentication_completed(
|
55
|
+
identifier: credentials.identifier,
|
56
|
+
user_id: (user[:id] if user),
|
57
|
+
started_at: now,
|
58
|
+
ended_at: Time.now,
|
59
|
+
type: credentials.type,
|
60
|
+
authenticated: authenticated,
|
61
|
+
success: success
|
62
|
+
)
|
63
|
+
|
64
|
+
user if success
|
65
|
+
end
|
66
|
+
|
67
|
+
def logger
|
68
|
+
configuration.logger
|
69
|
+
end
|
70
|
+
|
71
|
+
def inspect
|
72
|
+
"#<ROM::Auth::AuthenticationSystem plugins: #{plugins.keys}>"
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
def load_plugins!
|
78
|
+
@plugins = configuration.plugins.inject({}) do |acc, (plugin, args)|
|
79
|
+
acc.merge(plugin => plugin.new(self, *args))
|
80
|
+
end
|
81
|
+
|
82
|
+
@plugins.each do |_type, plugin|
|
83
|
+
plugin.install
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def identify_user(credentials)
|
88
|
+
raise NotImplementedError
|
89
|
+
end
|
90
|
+
|
91
|
+
def run_authentication_check(credentials)
|
92
|
+
#authenticators[type].new.authenticate(user, data)
|
93
|
+
raise NotImplementedError
|
94
|
+
end
|
95
|
+
|
96
|
+
def authentication_authorized?(user, credentials)
|
97
|
+
true # FIXME
|
98
|
+
end
|
99
|
+
|
100
|
+
def on_authentication_attempt(credentials)
|
101
|
+
logger.info("Attempt: #{credentials.identifier}")
|
102
|
+
end
|
103
|
+
|
104
|
+
def on_identification_failure(credentials)
|
105
|
+
logger.info("Identification failed: #{credentials.identifier}")
|
106
|
+
end
|
107
|
+
|
108
|
+
def on_authentication_completed(data)
|
109
|
+
raise ArgumentError unless data.is_a?(Hash)
|
110
|
+
#raise ArgumentError unless data.keys.to_set == [:identifier, :user_id, :started_at, :ended_at, :authenticated, :authenticator, :success].to_set
|
111
|
+
# TODO :reason ?
|
112
|
+
|
113
|
+
instrument_authentication_event(data)
|
114
|
+
|
115
|
+
# TODO
|
116
|
+
# - log origin/details & warn user
|
117
|
+
# - compute failed logins per minute and alert if above threshold
|
118
|
+
# - throttling
|
119
|
+
#
|
120
|
+
end
|
121
|
+
|
122
|
+
def on_authentication_failure(credentials)
|
123
|
+
logger.info("Authentication failed: #{credentials.identifier}")
|
124
|
+
end
|
125
|
+
|
126
|
+
def on_authentication_success(credentials)
|
127
|
+
logger.info("Authentication succeeded: #{credentials.identifier}")
|
128
|
+
end
|
129
|
+
|
130
|
+
def on_unauthorized_authentication(credentials)
|
131
|
+
logger.info("Unauthorized: #{credentials.identifier}")
|
132
|
+
end
|
133
|
+
|
134
|
+
def on_authorized_authentication(credentials)
|
135
|
+
logger.info("Authorized: #{credentials.identifier}")
|
136
|
+
end
|
137
|
+
|
138
|
+
def instrument_authentication_event(auth_data)
|
139
|
+
configuration.instrumentation.instrument(
|
140
|
+
'rom:auth:authentication_attempt',
|
141
|
+
auth_data
|
142
|
+
)
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
@@ -0,0 +1,162 @@
|
|
1
|
+
describe 'CommonPlugins' do
|
2
|
+
let(:setup) { ROM.setup(:sql, "sqlite::memory") }
|
3
|
+
let(:connection) { setup.default.connection }
|
4
|
+
|
5
|
+
def password
|
6
|
+
'somepassword'
|
7
|
+
end
|
8
|
+
|
9
|
+
def password_verifier
|
10
|
+
ROM::Auth::PasswordVerifiers::PBKDF2Verifier.for_password(password)
|
11
|
+
end
|
12
|
+
|
13
|
+
before do
|
14
|
+
connection.create_table(:users) do
|
15
|
+
primary_key :id
|
16
|
+
end
|
17
|
+
|
18
|
+
class User
|
19
|
+
include Virtus.value_object(coerce: false)
|
20
|
+
|
21
|
+
values do
|
22
|
+
attribute :id, Integer
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
@users = Class.new(ROM::Relation[:sql]) do
|
27
|
+
register_as :users
|
28
|
+
dataset :users
|
29
|
+
|
30
|
+
def by_id(id)
|
31
|
+
where(id: id)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
@mapper = Class.new(ROM::Mapper) do
|
36
|
+
relation(:users)
|
37
|
+
model(User) # FIXME
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
it '#authenticate with AuthenticationCredentialsPlugin' do
|
42
|
+
config = ROM::Auth::Configuration.new do |c|
|
43
|
+
c.plugin(ROM::Auth::Plugins::AuthenticationCredentialsPlugin) do
|
44
|
+
end
|
45
|
+
|
46
|
+
c.plugin(ROM::Auth::Plugins::AuthenticationEventsPlugin) do
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
system = ROM::Auth::System.new(config)
|
51
|
+
|
52
|
+
system.migrate!(setup)
|
53
|
+
|
54
|
+
rom = ROM.finalize.env
|
55
|
+
|
56
|
+
connection[:users].insert(id: 1)
|
57
|
+
connection[:authentication_credentials].insert(
|
58
|
+
user_id: 1,
|
59
|
+
type: 'email',
|
60
|
+
identifier: 'a@b.c.de',
|
61
|
+
verifier_data: password_verifier.to_s
|
62
|
+
)
|
63
|
+
|
64
|
+
credentials = double(type: 'email', identifier: 'a@b.c.de', password: password)
|
65
|
+
user = rom.relation(:users).first
|
66
|
+
|
67
|
+
assert{ system.authenticate(credentials) == user }
|
68
|
+
end
|
69
|
+
|
70
|
+
it '#authenticate with AuthenticationEventsPlugin' do
|
71
|
+
config = ROM::Auth::Configuration.new do |c|
|
72
|
+
c.plugin(ROM::Auth::Plugins::AuthenticationCredentialsPlugin) do
|
73
|
+
end
|
74
|
+
|
75
|
+
c.plugin(ROM::Auth::Plugins::AuthenticationEventsPlugin) do
|
76
|
+
end
|
77
|
+
|
78
|
+
c.plugin(ROM::Auth::Plugins::LockdownPlugin) do
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
system = ROM::Auth::System.new(config)
|
83
|
+
|
84
|
+
system.migrate!(setup)
|
85
|
+
|
86
|
+
rom = ROM.finalize.env
|
87
|
+
|
88
|
+
connection[:users].insert(id: 1)
|
89
|
+
connection[:authentication_credentials].insert(
|
90
|
+
user_id: 1,
|
91
|
+
type: 'email',
|
92
|
+
identifier: 'a@b.c.de',
|
93
|
+
verifier_data: password_verifier.to_s
|
94
|
+
)
|
95
|
+
|
96
|
+
credentials = double(type: 'email', identifier: 'a@b.c.de', password: password)
|
97
|
+
user = rom.relation(:users).first
|
98
|
+
|
99
|
+
auths = rom.relation(:authentication_events)
|
100
|
+
|
101
|
+
assert{ system.authenticate(credentials) == user }
|
102
|
+
assert{ auths.relation.count == 1 }
|
103
|
+
|
104
|
+
event = auths.as(:rom_auth_event).first
|
105
|
+
assert{ event.type == 'email' }
|
106
|
+
assert{ event.authenticated == true }
|
107
|
+
assert{ event.success == true }
|
108
|
+
assert{ event.user_id == 1 }
|
109
|
+
end
|
110
|
+
|
111
|
+
it '#authenticate with LockdownPlugin' do
|
112
|
+
config = ROM::Auth::Configuration.new do |c|
|
113
|
+
c.plugin(ROM::Auth::Plugins::AuthenticationCredentialsPlugin) do
|
114
|
+
end
|
115
|
+
|
116
|
+
c.plugin(ROM::Auth::Plugins::AuthenticationEventsPlugin) do
|
117
|
+
end
|
118
|
+
|
119
|
+
c.plugin(ROM::Auth::Plugins::LockdownPlugin) do |c|
|
120
|
+
c.lock_strategy = ->(plugin, user_id, credentials){
|
121
|
+
#plugin.system.logger.info('LOCKING')
|
122
|
+
plugin.lock(user_id, 'Login failed')
|
123
|
+
}
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
system = ROM::Auth::System.new(config)
|
128
|
+
|
129
|
+
system.migrate!(setup)
|
130
|
+
|
131
|
+
rom = ROM.finalize.env
|
132
|
+
|
133
|
+
connection[:users].insert(id: 1)
|
134
|
+
connection[:authentication_credentials].insert(
|
135
|
+
user_id: 1,
|
136
|
+
type: 'email',
|
137
|
+
identifier: 'a@b.c.de',
|
138
|
+
verifier_data: password_verifier.to_s
|
139
|
+
)
|
140
|
+
|
141
|
+
credentials = double(type: 'email', identifier: 'a@b.c.de', password: 'incorrect')
|
142
|
+
|
143
|
+
user = rom.relation(:users).first
|
144
|
+
|
145
|
+
assert{ system.authenticate(credentials) == nil }
|
146
|
+
|
147
|
+
lock = rom.relation(:rom_auth_lockdowns).as(:rom_auth_lockdown).first
|
148
|
+
|
149
|
+
assert{ lock.locked_at.close_to?(Time.now, 1) }
|
150
|
+
assert{ lock.lock_reason == 'Login failed' }
|
151
|
+
|
152
|
+
plugin = system.plugins[ROM::Auth::Plugins::LockdownPlugin]
|
153
|
+
assert{ plugin.is_locked?(lock.id) == true }
|
154
|
+
|
155
|
+
plugin.unlock(lock.id)
|
156
|
+
assert{ plugin.is_locked?(lock.id) == false }
|
157
|
+
|
158
|
+
lock = rom.relation(:rom_auth_lockdowns).as(:rom_auth_lockdown).first
|
159
|
+
assert{ lock.locked_at == nil }
|
160
|
+
assert{ lock.lock_reason == nil }
|
161
|
+
end
|
162
|
+
end
|