masks-server 0.11.0 → 0.13.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/app/controllers/concerns/masks/server/scim_endpoint.rb +2 -2
- data/app/controllers/masks/server/account_controller.rb +3 -0
- data/app/controllers/masks/server/account_exports_controller.rb +2 -2
- data/app/controllers/masks/server/addresses_controller.rb +2 -3
- data/app/controllers/masks/server/app_passwords_controller.rb +41 -0
- data/app/controllers/masks/server/authenticator_apps_controller.rb +2 -3
- data/app/controllers/masks/server/authorize_controller.rb +33 -2
- data/app/controllers/masks/server/device_authorizations_controller.rb +2 -2
- data/app/controllers/masks/server/device_verifications_controller.rb +2 -2
- data/app/controllers/masks/server/enrollments_controller.rb +4 -4
- data/app/controllers/masks/server/forward_auth_controller.rb +106 -14
- data/app/controllers/masks/server/introspections_controller.rb +2 -2
- data/app/controllers/masks/server/logins_controller.rb +14 -15
- data/app/controllers/masks/server/organization_access_controller.rb +39 -0
- data/app/controllers/masks/server/organization_members_controller.rb +2 -2
- data/app/controllers/masks/server/passwords_controller.rb +2 -3
- data/app/controllers/masks/server/pushed_authorizations_controller.rb +2 -2
- data/app/controllers/masks/server/received_signals_controller.rb +2 -2
- data/app/controllers/masks/server/registrations_controller.rb +2 -2
- data/app/controllers/masks/server/revocations_controller.rb +2 -2
- data/app/controllers/masks/server/saml_controller.rb +2 -2
- data/app/controllers/masks/server/sign_in_approvals_controller.rb +2 -3
- data/app/controllers/masks/server/tokens_controller.rb +2 -2
- data/app/controllers/masks/server/verifications_controller.rb +2 -3
- data/app/graphql/masks/server/manage/mutations/base_mutation.rb +7 -0
- data/app/graphql/masks/server/manage/mutations/create_group.rb +6 -4
- data/app/graphql/masks/server/manage/mutations/decline_access.rb +27 -0
- data/app/graphql/masks/server/manage/mutations/grant_access.rb +29 -0
- data/app/graphql/masks/server/manage/mutations/group_source.rb +34 -0
- data/app/graphql/masks/server/manage/mutations/issue_ldap_credential.rb +5 -2
- data/app/graphql/masks/server/manage/mutations/refresh_group.rb +21 -0
- data/app/graphql/masks/server/manage/mutations/remove_group_member.rb +2 -0
- data/app/graphql/masks/server/manage/mutations/run_health_checks.rb +19 -0
- data/app/graphql/masks/server/manage/mutations/update_client.rb +6 -0
- data/app/graphql/masks/server/manage/mutations/update_group.rb +7 -4
- data/app/graphql/masks/server/manage/mutations/update_tenant.rb +14 -1
- data/app/graphql/masks/server/manage/types/access_request_type.rb +21 -0
- data/app/graphql/masks/server/manage/types/actor_type.rb +6 -0
- data/app/graphql/masks/server/manage/types/client_type.rb +12 -0
- data/app/graphql/masks/server/manage/types/group_condition_input.rb +16 -0
- data/app/graphql/masks/server/manage/types/group_condition_type.rb +29 -0
- data/app/graphql/masks/server/manage/types/group_member_type.rb +16 -0
- data/app/graphql/masks/server/manage/types/group_type.rb +17 -1
- data/app/graphql/masks/server/manage/types/health_check_type.rb +18 -0
- data/app/graphql/masks/server/manage/types/ldap_credential_type.rb +1 -0
- data/app/graphql/masks/server/manage/types/mutation_type.rb +4 -0
- data/app/graphql/masks/server/manage/types/query_type.rb +24 -0
- data/app/graphql/masks/server/manage/types/release_type.rb +16 -0
- data/app/graphql/masks/server/manage/types/tenant_type.rb +6 -1
- data/app/helpers/masks/server/account_helper.rb +4 -0
- data/app/jobs/masks/server/cleanup_job.rb +1 -0
- data/app/jobs/masks/server/health_check_job.rb +21 -0
- data/app/jobs/masks/server/refresh_groups_job.rb +18 -0
- data/app/jobs/masks/server/release_check_job.rb +21 -0
- data/app/ldap/masks/server/ldap/connection.rb +3 -2
- data/app/ldap/masks/server/ldap/gate.rb +44 -19
- data/app/logins/masks/server/login_states/second_factor.rb +1 -1
- data/app/mailers/masks/server/actor_mailer.rb +31 -0
- data/app/models/masks/server/access_request.rb +103 -0
- data/app/models/masks/server/actor.rb +6 -0
- data/app/models/masks/server/app_password.rb +91 -0
- data/app/models/masks/server/app_passwords.rb +76 -0
- data/app/models/masks/server/client.rb +15 -0
- data/app/models/masks/server/client_metadata_document.rb +4 -8
- data/app/models/masks/server/confirmation_code.rb +2 -2
- data/app/models/masks/server/connection.rb +7 -0
- data/app/models/masks/server/declared.rb +13 -3
- data/app/models/masks/server/email_verification.rb +2 -2
- data/app/models/masks/server/event.rb +13 -3
- data/app/models/masks/server/federation/protocol.rb +1 -1
- data/app/models/masks/server/federation/saml.rb +1 -1
- data/app/models/masks/server/forward_auth_rules.rb +162 -0
- data/app/models/masks/server/group.rb +207 -4
- data/app/models/masks/server/group_member.rb +7 -0
- data/app/models/masks/server/group_rule.rb +63 -0
- data/app/models/masks/server/health.rb +310 -0
- data/app/models/masks/server/health_check.rb +27 -0
- data/app/models/masks/server/invitation.rb +2 -2
- data/app/models/masks/server/ldap_credential.rb +2 -2
- data/app/models/masks/server/login.rb +28 -2
- data/app/models/masks/server/mail_previews.rb +5 -0
- data/app/models/masks/server/mail_template.rb +2 -0
- data/app/models/masks/server/membership.rb +10 -3
- data/app/models/masks/server/notifications.rb +3 -1
- data/app/models/masks/server/password_reset.rb +2 -2
- data/app/models/masks/server/provider.rb +1 -0
- data/app/models/masks/server/queue.rb +60 -0
- data/app/models/masks/server/release.rb +74 -0
- data/app/models/masks/server/saml_identity/farewells.rb +1 -0
- data/app/models/masks/server/session.rb +8 -0
- data/app/models/masks/server/tenant.rb +87 -6
- data/app/models/masks/server/throttle.rb +165 -0
- data/app/models/masks/server/token.rb +2 -1
- data/app/views/masks/server/account/_app_passwords.html.erb +83 -0
- data/app/views/masks/server/account/_organizations.html.erb +33 -0
- data/app/views/masks/server/account/index.html.erb +5 -0
- data/app/views/masks/server/actor_mailer/access_declined.html.erb +5 -0
- data/app/views/masks/server/actor_mailer/access_declined.text.erb +5 -0
- data/app/views/masks/server/actor_mailer/access_granted.html.erb +7 -0
- data/app/views/masks/server/actor_mailer/access_granted.text.erb +7 -0
- data/app/views/masks/server/authorize/error.html.erb +25 -0
- data/config/locales/de/access_requests.yml +43 -0
- data/config/locales/de/account/app_passwords.yml +42 -0
- data/config/locales/de/events.yml +5 -0
- data/config/locales/de/flashes.yml +1 -0
- data/config/locales/de/group_sources.yml +5 -0
- data/config/locales/de/manage/access_requests.yml +12 -0
- data/config/locales/de/manage/app_passwords.yml +16 -0
- data/config/locales/de/manage/forward_auth_rules.yml +29 -0
- data/config/locales/de/manage/group_sources.yml +44 -0
- data/config/locales/de/manage/health.yml +85 -0
- data/config/locales/de/manage/links.yml +20 -0
- data/config/locales/en/access_requests.yml +43 -0
- data/config/locales/en/account/app_passwords.yml +42 -0
- data/config/locales/en/events.yml +5 -0
- data/config/locales/en/flashes.yml +1 -0
- data/config/locales/en/group_sources.yml +5 -0
- data/config/locales/en/manage/access_requests.yml +12 -0
- data/config/locales/en/manage/app_passwords.yml +16 -0
- data/config/locales/en/manage/forward_auth_rules.yml +29 -0
- data/config/locales/en/manage/group_sources.yml +44 -0
- data/config/locales/en/manage/health.yml +85 -0
- data/config/locales/en/manage/links.yml +20 -0
- data/config/locales/es/access_requests.yml +43 -0
- data/config/locales/es/account/app_passwords.yml +42 -0
- data/config/locales/es/events.yml +5 -0
- data/config/locales/es/flashes.yml +1 -0
- data/config/locales/es/group_sources.yml +5 -0
- data/config/locales/es/manage/access_requests.yml +12 -0
- data/config/locales/es/manage/app_passwords.yml +16 -0
- data/config/locales/es/manage/forward_auth_rules.yml +29 -0
- data/config/locales/es/manage/group_sources.yml +44 -0
- data/config/locales/es/manage/health.yml +85 -0
- data/config/locales/es/manage/links.yml +20 -0
- data/config/locales/fr/access_requests.yml +43 -0
- data/config/locales/fr/account/app_passwords.yml +42 -0
- data/config/locales/fr/events.yml +5 -0
- data/config/locales/fr/flashes.yml +1 -0
- data/config/locales/fr/group_sources.yml +5 -0
- data/config/locales/fr/manage/access_requests.yml +12 -0
- data/config/locales/fr/manage/app_passwords.yml +16 -0
- data/config/locales/fr/manage/forward_auth_rules.yml +29 -0
- data/config/locales/fr/manage/group_sources.yml +44 -0
- data/config/locales/fr/manage/health.yml +85 -0
- data/config/locales/fr/manage/links.yml +20 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20261020100000_create_access_requests.rb +28 -0
- data/db/migrate/20261020110000_add_app_passwords_to_clients.rb +5 -0
- data/db/migrate/20261020120000_add_forward_auth_rules_to_clients.rb +6 -0
- data/db/migrate/20261020130000_add_sources_to_groups.rb +15 -0
- data/db/migrate/20261020140000_create_health_checks.rb +25 -0
- data/db/migrate/20261020150000_add_link_lifetimes_to_tenants.rb +8 -0
- data/db/migrate/20261021100000_add_pinned_origin_to_tenants.rb +5 -0
- data/db/migrate/20261031100000_default_key_rotation_to_ninety_days.rb +5 -0
- data/lib/generators/masks/server/install/install_generator.rb +22 -2
- data/lib/masks/server/configuration.rb +16 -19
- data/lib/masks/server/engine.rb +2 -1
- data/lib/masks/server/forwarding.rb +26 -3
- data/lib/masks/server/schema_reference.rb +12 -0
- data/lib/masks/server/tenancy/middleware.rb +21 -13
- data/lib/masks/server/tenant_isolation.rb +12 -0
- data/public/masks-assets/.vite/manifest.json +13 -13
- data/public/masks-assets/assets/{account-D46qDQCo.js → account-h7I6VUPR.js} +2 -2
- data/public/masks-assets/assets/{account-D46qDQCo.js.map → account-h7I6VUPR.js.map} +1 -1
- data/public/masks-assets/assets/{app-D7W0gAyT.css → app-BXOScMjL.css} +1 -1
- data/public/masks-assets/assets/{handshake-DIFZPSAK.js → handshake-YQpRNMIs.js} +2 -2
- data/public/masks-assets/assets/{handshake-DIFZPSAK.js.map → handshake-YQpRNMIs.js.map} +1 -1
- data/public/masks-assets/assets/initial-Cn-UuAsy.js +3 -0
- data/public/masks-assets/assets/{initial-C1GpCiEh.js.map → initial-Cn-UuAsy.js.map} +1 -1
- data/public/masks-assets/assets/{login-C_6igRZ3.js → login-C21_jb6L.js} +3 -3
- data/public/masks-assets/assets/{login-C_6igRZ3.js.map → login-C21_jb6L.js.map} +1 -1
- data/public/masks-assets/assets/{manage-D4MzaDvM.js → manage-DpVxe5sv.js} +80 -51
- data/public/masks-assets/assets/manage-DpVxe5sv.js.map +1 -0
- data/public/masks-assets/assets/passkey-5UYPVD3c.js +2 -0
- data/public/masks-assets/assets/{passkey-DIj_5LCY.js.map → passkey-5UYPVD3c.js.map} +1 -1
- data/version.txt +1 -1
- metadata +89 -14
- data/public/masks-assets/assets/initial-C1GpCiEh.js +0 -3
- data/public/masks-assets/assets/manage-D4MzaDvM.js.map +0 -1
- data/public/masks-assets/assets/passkey-DIj_5LCY.js +0 -2
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Server
|
|
3
|
+
class HealthCheckJob < ApplicationJob
|
|
4
|
+
queue_as :maintenance
|
|
5
|
+
across_tenants!
|
|
6
|
+
|
|
7
|
+
def perform
|
|
8
|
+
server = Health.server_results
|
|
9
|
+
failing = Hash.new { |held, name| held[name] = [] }
|
|
10
|
+
|
|
11
|
+
begin
|
|
12
|
+
each_tenant do |tenant|
|
|
13
|
+
Health.store!(server + Health.tenant_results).select(&:failing?).each { |row| failing[row.name] << tenant.subdomain }
|
|
14
|
+
end
|
|
15
|
+
ensure
|
|
16
|
+
Health.remember_all!(failing)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Server
|
|
3
|
+
class RefreshGroupsJob < ApplicationJob
|
|
4
|
+
queue_as :maintenance
|
|
5
|
+
across_tenants!
|
|
6
|
+
|
|
7
|
+
def perform
|
|
8
|
+
each_tenant { refresh }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def refresh
|
|
14
|
+
Group.sourced.find_each(&:refresh!)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Server
|
|
3
|
+
class ReleaseCheckJob < ApplicationJob
|
|
4
|
+
queue_as :maintenance
|
|
5
|
+
across_tenants!
|
|
6
|
+
|
|
7
|
+
def perform
|
|
8
|
+
return unless ::Rails.configuration.masks.release_check
|
|
9
|
+
|
|
10
|
+
found = Release.check!
|
|
11
|
+
|
|
12
|
+
return if found.nil?
|
|
13
|
+
|
|
14
|
+
each_tenant do
|
|
15
|
+
Event.record!(Event::RELEASE_AVAILABLE, by: nil, version: found["version"], url: found["url"],
|
|
16
|
+
operator_step: found["operator_step"])
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -58,7 +58,7 @@ module Masks
|
|
|
58
58
|
|
|
59
59
|
def bind(message)
|
|
60
60
|
request = message.operation
|
|
61
|
-
@bound = nil
|
|
61
|
+
previous, @bound = @bound, nil
|
|
62
62
|
|
|
63
63
|
if request.version != 3
|
|
64
64
|
return write(Protocol.bind_response(message.id, Protocol::PROTOCOL_ERROR, "only LDAPv3 is spoken here"))
|
|
@@ -68,7 +68,8 @@ module Masks
|
|
|
68
68
|
return write(Protocol.bind_response(message.id, Protocol::AUTH_METHOD_NOT_SUPPORTED, "only simple binds are supported"))
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
-
outcome = Gate.new(ip_address: @ip_address
|
|
71
|
+
outcome = Gate.new(ip_address: @ip_address, client_id: previous&.service? ? previous.client_id : nil)
|
|
72
|
+
.bind(request.name, request.password)
|
|
72
73
|
@bound = outcome.bound
|
|
73
74
|
|
|
74
75
|
write(Protocol.bind_response(message.id, outcome.code, outcome.message))
|
|
@@ -2,10 +2,13 @@ module Masks
|
|
|
2
2
|
module Server
|
|
3
3
|
module Ldap
|
|
4
4
|
class Gate
|
|
5
|
-
WINDOW = 3.minutes
|
|
6
5
|
VIA = "ldap".freeze
|
|
7
6
|
|
|
8
|
-
Bound = Data.define(:kind, :tenant, :dn, :subject) do
|
|
7
|
+
Bound = Data.define(:kind, :tenant, :dn, :subject, :client_id) do
|
|
8
|
+
def initialize(client_id: nil, **rest)
|
|
9
|
+
super(client_id: client_id, **rest)
|
|
10
|
+
end
|
|
11
|
+
|
|
9
12
|
def service?
|
|
10
13
|
kind == :service
|
|
11
14
|
end
|
|
@@ -17,15 +20,16 @@ module Masks
|
|
|
17
20
|
|
|
18
21
|
Outcome = Data.define(:code, :message, :bound)
|
|
19
22
|
|
|
20
|
-
def initialize(ip_address:)
|
|
23
|
+
def initialize(ip_address:, client_id: nil)
|
|
21
24
|
@ip_address = ip_address
|
|
25
|
+
@client_id = client_id
|
|
22
26
|
end
|
|
23
27
|
|
|
24
28
|
def bind(name, password)
|
|
25
29
|
password = password.to_s
|
|
26
30
|
return anonymous if name.blank? && password.empty?
|
|
27
31
|
return refused(Protocol::UNWILLING_TO_PERFORM, "unauthenticated binds are refused") if password.empty?
|
|
28
|
-
return throttled if
|
|
32
|
+
return throttled(:attempt) if Throttle.exceeded?(:attempt, ip_key)
|
|
29
33
|
|
|
30
34
|
dn = parse(name)
|
|
31
35
|
tenant = dn && Directory.tenant_for(dn)
|
|
@@ -80,22 +84,24 @@ module Masks
|
|
|
80
84
|
|
|
81
85
|
Outcome.new(
|
|
82
86
|
code: Protocol::SUCCESS, message: "",
|
|
83
|
-
bound: Bound.new(kind: :service, tenant: tenant, dn: directory.service_dn(credential.label), subject: credential.id
|
|
87
|
+
bound: Bound.new(kind: :service, tenant: tenant, dn: directory.service_dn(credential.label), subject: credential.id,
|
|
88
|
+
client_id: credential.client_id)
|
|
84
89
|
)
|
|
85
90
|
end
|
|
86
91
|
|
|
87
92
|
def person(tenant, directory, uid, password)
|
|
88
93
|
account = account_key(tenant, uid)
|
|
89
94
|
|
|
90
|
-
if
|
|
91
|
-
|
|
95
|
+
if Throttle.exceeded?(:account_attempt, account)
|
|
96
|
+
Throttle.hit!(:account_attempt, actor: Actor.locate(uid), address: @ip_address, identifier: uid, via: VIA)
|
|
92
97
|
return throttled
|
|
93
98
|
end
|
|
94
99
|
|
|
95
100
|
actor = Actor.authenticate(uid, password)
|
|
101
|
+
return app_password(tenant, directory, uid, password, account) if actor.nil? && app_password?(password)
|
|
96
102
|
|
|
97
103
|
if actor.nil?
|
|
98
|
-
failed!(
|
|
104
|
+
failed!(account)
|
|
99
105
|
return refuse(uid, "password")
|
|
100
106
|
end
|
|
101
107
|
|
|
@@ -103,8 +109,28 @@ module Masks
|
|
|
103
109
|
return refuse(uid, reason, actor) if reason
|
|
104
110
|
|
|
105
111
|
actor.active!
|
|
112
|
+
bound(tenant, directory, actor)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def app_password?(password)
|
|
116
|
+
@client_id && AppPassword.shaped?(password)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def app_password(tenant, directory, uid, password, account)
|
|
120
|
+
client = Client.active.find_by(id: @client_id)
|
|
121
|
+
outcome = AppPasswords.authenticate(client: client, identifier: uid, secret: password, via: VIA,
|
|
122
|
+
ip_address: @ip_address, tenant: tenant)
|
|
123
|
+
|
|
124
|
+
return throttled if outcome.throttled?
|
|
125
|
+
return bound(tenant, directory, outcome.actor, factor: AppPasswords::FACTOR) if outcome.actor
|
|
126
|
+
|
|
127
|
+
failed!(account) if outcome.wrong?
|
|
128
|
+
invalid
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def bound(tenant, directory, actor, factor: "password")
|
|
106
132
|
dn = directory.person_dn(actor)
|
|
107
|
-
Event.record!(Event::LDAP_BOUND, actor: actor, dn: dn.to_s)
|
|
133
|
+
Event.record!(Event::LDAP_BOUND, actor: actor, dn: dn.to_s, factor: factor)
|
|
108
134
|
|
|
109
135
|
Outcome.new(code: Protocol::SUCCESS, message: "", bound: Bound.new(kind: :person, tenant: tenant, dn: dn, subject: actor.id))
|
|
110
136
|
end
|
|
@@ -132,7 +158,7 @@ module Masks
|
|
|
132
158
|
|
|
133
159
|
def decoy(password)
|
|
134
160
|
BCrypt::Password.new(Actor.decoy_digest) == password
|
|
135
|
-
|
|
161
|
+
Throttle.count!(:attempt, ip_key)
|
|
136
162
|
invalid
|
|
137
163
|
end
|
|
138
164
|
|
|
@@ -140,7 +166,9 @@ module Masks
|
|
|
140
166
|
refused(Protocol::INVALID_CREDENTIALS, "invalid credentials")
|
|
141
167
|
end
|
|
142
168
|
|
|
143
|
-
def throttled
|
|
169
|
+
def throttled(limit = nil)
|
|
170
|
+
Throttle.hit!(limit, address: @ip_address) if limit
|
|
171
|
+
|
|
144
172
|
refused(Protocol::UNWILLING_TO_PERFORM, "too many attempts; wait a few minutes")
|
|
145
173
|
end
|
|
146
174
|
|
|
@@ -149,19 +177,16 @@ module Masks
|
|
|
149
177
|
end
|
|
150
178
|
|
|
151
179
|
def ip_key
|
|
152
|
-
"ldap
|
|
180
|
+
"ldap:#{@ip_address}"
|
|
153
181
|
end
|
|
154
182
|
|
|
155
183
|
def account_key(tenant, uid)
|
|
156
|
-
"ldap
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def failures(key)
|
|
160
|
-
::Rails.cache.read(key).to_i
|
|
184
|
+
"ldap:#{tenant.id}:#{Digest::SHA256.hexdigest(uid.to_s.strip.downcase)}"
|
|
161
185
|
end
|
|
162
186
|
|
|
163
|
-
def failed!(
|
|
164
|
-
|
|
187
|
+
def failed!(account)
|
|
188
|
+
Throttle.count!(:attempt, ip_key)
|
|
189
|
+
Throttle.count!(:account_attempt, account)
|
|
165
190
|
end
|
|
166
191
|
end
|
|
167
192
|
end
|
|
@@ -160,6 +160,37 @@ module Masks
|
|
|
160
160
|
)
|
|
161
161
|
end
|
|
162
162
|
|
|
163
|
+
def access_granted(asked, journey:)
|
|
164
|
+
return message unless deliverable?
|
|
165
|
+
|
|
166
|
+
journey!(journey)
|
|
167
|
+
@actor = asked.actor
|
|
168
|
+
@app = asked.client
|
|
169
|
+
@url = (@app.approved? && @app.client_uri.presence) || home
|
|
170
|
+
|
|
171
|
+
mail(
|
|
172
|
+
from: self.class.from,
|
|
173
|
+
to: @actor.email,
|
|
174
|
+
subject: customize!(:access_granted, actor: @actor, app: @app.name,
|
|
175
|
+
default: t("actor_mailer.access_granted.subject", app: @app.name))
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def access_declined(asked, journey:)
|
|
180
|
+
return message unless deliverable?
|
|
181
|
+
|
|
182
|
+
journey!(journey)
|
|
183
|
+
@actor = asked.actor
|
|
184
|
+
@app = asked.client
|
|
185
|
+
|
|
186
|
+
mail(
|
|
187
|
+
from: self.class.from,
|
|
188
|
+
to: @actor.email,
|
|
189
|
+
subject: customize!(:access_declined, actor: @actor, app: @app.name,
|
|
190
|
+
default: t("actor_mailer.access_declined.subject", app: @app.name))
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
|
|
163
194
|
private
|
|
164
195
|
|
|
165
196
|
def tenant_lede(manager)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Server
|
|
3
|
+
class AccessRequest < ApplicationRecord
|
|
4
|
+
include TenantScoped
|
|
5
|
+
|
|
6
|
+
class Refused < StandardError; end
|
|
7
|
+
|
|
8
|
+
LIFETIME = 30.days
|
|
9
|
+
|
|
10
|
+
belongs_to :actor
|
|
11
|
+
belongs_to :client
|
|
12
|
+
belongs_to :decided_by, class_name: "Actor", optional: true
|
|
13
|
+
|
|
14
|
+
scope :open, -> { where(granted_at: nil, declined_at: nil) }
|
|
15
|
+
scope :lapsed, -> { where(created_at: ...LIFETIME.ago) }
|
|
16
|
+
scope :newest_first, -> { order(created_at: :desc) }
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
def ask!(actor:, client:)
|
|
20
|
+
return nil if client.admits?(actor)
|
|
21
|
+
|
|
22
|
+
open.find_by(actor: actor, client: client) || begin
|
|
23
|
+
created = create!(actor: actor, client: client)
|
|
24
|
+
Event.record!(Event::ACCESS_REQUESTED, actor: actor, client: client)
|
|
25
|
+
created
|
|
26
|
+
end
|
|
27
|
+
rescue ActiveRecord::RecordNotUnique
|
|
28
|
+
open.find_by!(actor: actor, client: client)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def for_organization(organization)
|
|
32
|
+
open.joins(:client).where("EXISTS (SELECT 1 FROM unnest(clients.allowed_groups) AS held WHERE held LIKE ?)",
|
|
33
|
+
"#{sanitize_sql_like(organization.key)}:%")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def open?
|
|
38
|
+
granted_at.nil? && declined_at.nil?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def grant!(label: nil, by:, journey:)
|
|
42
|
+
raise Refused, "#{actor.identifier} is already admitted to #{client.name}" if client.admits?(actor)
|
|
43
|
+
raise Refused, "this request is already decided" unless open?
|
|
44
|
+
|
|
45
|
+
chosen = label.presence || client.allowed_groups.first
|
|
46
|
+
raise Refused, "#{chosen} is not one of the groups #{client.name} allows" unless client.allowed_groups.include?(chosen)
|
|
47
|
+
|
|
48
|
+
transaction do
|
|
49
|
+
admit!(chosen, by: by)
|
|
50
|
+
update!(granted_at: Time.current, decided_by: by, label: chosen)
|
|
51
|
+
Event.record!(Event::ACCESS_GRANTED, actor: actor, by: by, client: client, label: chosen)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
ActorMailer.access_granted(self, journey: journey).deliver_later if actor.email.present?
|
|
55
|
+
|
|
56
|
+
self
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def decline!(by:, journey:)
|
|
60
|
+
raise Refused, "this request is already decided" unless open?
|
|
61
|
+
|
|
62
|
+
transaction do
|
|
63
|
+
update!(declined_at: Time.current, decided_by: by)
|
|
64
|
+
Event.record!(Event::ACCESS_DECLINED, actor: actor, by: by, client: client)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
ActorMailer.access_declined(self, journey: journey).deliver_later if actor.email.present?
|
|
68
|
+
|
|
69
|
+
self
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def organization_roles
|
|
73
|
+
client.allowed_groups.filter_map do |held|
|
|
74
|
+
key, role = held.split(":", 2)
|
|
75
|
+
[ key, role ] if role
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def admit!(label, by:)
|
|
82
|
+
key, role = label.split(":", 2)
|
|
83
|
+
|
|
84
|
+
if role.nil?
|
|
85
|
+
group = Group.find_by(key: key) || raise(Refused, "there is no group keyed #{key}")
|
|
86
|
+
group.add!(actor, by: by)
|
|
87
|
+
else
|
|
88
|
+
organization = Organization.active.find_by(key: key) || raise(Refused, "there is no organization keyed #{key}")
|
|
89
|
+
held = organization.memberships.find_by(actor: actor)
|
|
90
|
+
held = held.destroy! && nil if held&.pending?
|
|
91
|
+
|
|
92
|
+
if held.nil?
|
|
93
|
+
Members.enroll!(organization, actor, role: role, by: by)
|
|
94
|
+
elsif held.role != role
|
|
95
|
+
Members.assign!(held, role: role, by: by)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
rescue Members::Refused => e
|
|
99
|
+
raise Refused, e.message
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -30,6 +30,7 @@ module Masks
|
|
|
30
30
|
has_many :approvals, class_name: "Client", foreign_key: :approved_by_id, dependent: :nullify
|
|
31
31
|
has_one :avatar, dependent: :destroy
|
|
32
32
|
has_many :group_members, dependent: :delete_all
|
|
33
|
+
has_many :access_requests, dependent: :delete_all
|
|
33
34
|
has_many :groups, through: :group_members
|
|
34
35
|
|
|
35
36
|
before_destroy :sign_out_everywhere!, prepend: true
|
|
@@ -61,6 +62,7 @@ module Masks
|
|
|
61
62
|
after_save :release_previous_nickname, if: :saved_change_to_nickname?
|
|
62
63
|
before_save :forget_code_factors_for_new_addresses
|
|
63
64
|
after_update :forget_mailed_links, if: :saved_change_to_email?
|
|
65
|
+
after_commit :settle_sourced_groups, on: %i[create update], if: -> { saved_changes.keys.intersect?(Group::WATCHED) }
|
|
64
66
|
|
|
65
67
|
normalizes :nickname, with: ->(value) { value.to_s.strip.presence }
|
|
66
68
|
normalizes :email, with: ->(value) { value.to_s.strip.downcase.presence }
|
|
@@ -469,6 +471,10 @@ module Masks
|
|
|
469
471
|
|
|
470
472
|
private
|
|
471
473
|
|
|
474
|
+
def settle_sourced_groups
|
|
475
|
+
Group.ruled.find_each { |group| group.settle!(self) }
|
|
476
|
+
end
|
|
477
|
+
|
|
472
478
|
def forget_mailed_links
|
|
473
479
|
[ PasswordReset, Invitation, EmailVerification ].each do |kind|
|
|
474
480
|
kind.where(actor_id: id).live.find_each(&:consume!)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Server
|
|
3
|
+
class AppPassword < Token
|
|
4
|
+
ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789".freeze
|
|
5
|
+
LENGTH = 24
|
|
6
|
+
GROUP = 4
|
|
7
|
+
LIFETIME = 100.years
|
|
8
|
+
NAME_LIMIT = 64
|
|
9
|
+
MOST = 20
|
|
10
|
+
USED_GRAIN = 1.minute
|
|
11
|
+
|
|
12
|
+
class Refused < StandardError; end
|
|
13
|
+
|
|
14
|
+
scope :newest_first, -> { order(created_at: :desc) }
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def offered(actor)
|
|
18
|
+
Client.active.where(app_passwords: true).order(:name).select { |client| client.admits?(actor) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def issue!(actor:, client:, name:)
|
|
22
|
+
label = name.to_s.strip
|
|
23
|
+
|
|
24
|
+
raise Refused, "name" if label.empty? || label.length > NAME_LIMIT
|
|
25
|
+
raise Refused, "app" if client.nil? || offered(actor).none? { |offered| offered.id == client.id }
|
|
26
|
+
raise Refused, "too_many" if live.where(actor: actor).count >= MOST
|
|
27
|
+
|
|
28
|
+
secret = Array.new(LENGTH) { ALPHABET[SecureRandom.random_number(ALPHABET.length)] }.join
|
|
29
|
+
held = create!(actor: actor, client: client, digest: BCrypt::Password.create(secret, cost: cost),
|
|
30
|
+
payload: { "name" => label }, expires_at: LIFETIME.from_now)
|
|
31
|
+
held.instance_variable_set(:@secret, grouped(secret))
|
|
32
|
+
held
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def normalize(secret)
|
|
36
|
+
secret.to_s.downcase.delete("- ")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def shaped?(secret)
|
|
40
|
+
offered = normalize(secret)
|
|
41
|
+
|
|
42
|
+
offered.length == LENGTH && offered.each_char.all? { |char| ALPHABET.include?(char) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def check(actor:, client:, secret:)
|
|
46
|
+
offered = normalize(secret)
|
|
47
|
+
return burn(offered) unless actor && client && shaped?(offered)
|
|
48
|
+
|
|
49
|
+
found = live.where(actor: actor, client: client).to_a.find { |held| BCrypt::Password.new(held.digest) == offered }
|
|
50
|
+
|
|
51
|
+
found || burn(offered)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def grouped(secret)
|
|
55
|
+
secret.scan(/.{1,#{GROUP}}/o).join("-")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def cost
|
|
61
|
+
ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def burn(secret)
|
|
65
|
+
BCrypt::Password.new(Actor.decoy_digest) == secret
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def name
|
|
71
|
+
held("name")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def used_at
|
|
75
|
+
held("used_at")&.then { |value| Time.zone.parse(value) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def used!
|
|
79
|
+
return if used_at && used_at > USED_GRAIN.ago
|
|
80
|
+
|
|
81
|
+
now = Time.current
|
|
82
|
+
today = now.to_date.iso8601
|
|
83
|
+
first_today = held("used_on") != today
|
|
84
|
+
|
|
85
|
+
update_columns(payload: (payload || {}).merge("used_at" => now.iso8601, "used_on" => today))
|
|
86
|
+
|
|
87
|
+
Event.record!(Event::APP_PASSWORD_USED, actor: actor, client: client, name: name) if first_today
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Server
|
|
3
|
+
module AppPasswords
|
|
4
|
+
FACTOR = "app_password".freeze
|
|
5
|
+
THROTTLED = "throttled".freeze
|
|
6
|
+
SCOPE = "masks/server/logins".freeze
|
|
7
|
+
|
|
8
|
+
Outcome = Data.define(:actor, :refusal) do
|
|
9
|
+
def throttled?
|
|
10
|
+
refusal == THROTTLED
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def wrong?
|
|
14
|
+
refusal == FACTOR
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
def authenticate(client:, identifier:, secret:, via:, ip_address: Current.ip_address, tenant: Current.tenant)
|
|
20
|
+
return refused(nil, identifier, "factor-not-offered", via, client) unless client&.app_passwords? && !client.archived?
|
|
21
|
+
|
|
22
|
+
actor = Actor.locate(identifier)
|
|
23
|
+
keys = { account_attempt: [ account_key(tenant, actor, identifier), "identifier" ], attempt: [ address_key(tenant, ip_address), nil ] }
|
|
24
|
+
exceeded = keys.find { |limit, (by, named)| Throttle.exceeded?(limit, by, scope: SCOPE, named: named) }&.first
|
|
25
|
+
|
|
26
|
+
if exceeded
|
|
27
|
+
Throttle.hit!(exceeded, actor: actor, client: client, address: ip_address, identifier: identifier, via: via)
|
|
28
|
+
return Outcome.new(actor: nil, refusal: THROTTLED)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
held = AppPassword.check(actor: actor, client: client, secret: secret)
|
|
32
|
+
|
|
33
|
+
if held.nil?
|
|
34
|
+
failed!(keys)
|
|
35
|
+
return refused(actor, identifier, FACTOR, via, client)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
reason = obstacle(client, actor)
|
|
39
|
+
return refused(actor, identifier, reason, via, client) if reason
|
|
40
|
+
|
|
41
|
+
actor.active!
|
|
42
|
+
held.used!
|
|
43
|
+
|
|
44
|
+
Outcome.new(actor: actor, refusal: nil)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def obstacle(client, actor)
|
|
50
|
+
return "suspended" if actor.suspended?
|
|
51
|
+
return "pending_approval" if actor.pending_approval_at.present?
|
|
52
|
+
|
|
53
|
+
"not_admitted" unless client.admits?(actor)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def refused(actor, identifier, factor, via, client)
|
|
57
|
+
Event.record!(Event::LOGIN_REFUSED, actor: actor, by: nil, client: client, factor: factor, identifier: identifier, via: via)
|
|
58
|
+
|
|
59
|
+
Outcome.new(actor: nil, refusal: factor)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def account_key(tenant, actor, identifier)
|
|
63
|
+
[ tenant.id, actor&.id || identifier.to_s.strip.downcase ].join(":")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def address_key(tenant, ip_address)
|
|
67
|
+
[ tenant.id, ip_address ].join(":")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def failed!(keys)
|
|
71
|
+
keys.each { |limit, (by, named)| Throttle.count!(limit, by, scope: SCOPE, named: named) }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -54,6 +54,7 @@ module Masks
|
|
|
54
54
|
has_many :consents, dependent: :destroy
|
|
55
55
|
has_many :delegations, dependent: :destroy
|
|
56
56
|
has_many :saml_participations, dependent: :delete_all
|
|
57
|
+
has_many :access_requests, dependent: :delete_all
|
|
57
58
|
has_many :namespaces, -> { order(:name) }, dependent: :nullify
|
|
58
59
|
has_many :enrolled, class_name: "Client", foreign_key: :enrolled_by_id, inverse_of: :enrolled_by,
|
|
59
60
|
dependent: :nullify
|
|
@@ -73,6 +74,7 @@ module Masks
|
|
|
73
74
|
validate :keys_are_usable
|
|
74
75
|
validate :saml_is_described, if: :saml?
|
|
75
76
|
validate :forward_is_described, if: :forward?
|
|
77
|
+
validate :forward_auth_rules_are_usable
|
|
76
78
|
validate :backchannel_logout_uri_is_usable
|
|
77
79
|
validate :sector_is_derivable
|
|
78
80
|
validate :consent_is_skipped_only_when_approved
|
|
@@ -89,6 +91,7 @@ module Masks
|
|
|
89
91
|
|
|
90
92
|
normalizes :allowed_groups, with: ->(value) { Array(value).map { |group| group.to_s.strip.downcase }.compact_blank.uniq.sort }
|
|
91
93
|
normalizes :saml_entity_id, :saml_slo_url, with: ->(value) { value.to_s.strip.presence }
|
|
94
|
+
normalizes :forward_auth_rules, with: ->(value) { ForwardAuthRules.normalize(value) }
|
|
92
95
|
normalizes(*METADATA_URIS, with: ->(value) { value.to_s.strip.presence })
|
|
93
96
|
|
|
94
97
|
after_commit :refresh_logo!, on: %i[create update], if: :saved_change_to_logo_uri?
|
|
@@ -464,6 +467,10 @@ module Masks
|
|
|
464
467
|
allowed_groups.empty? || actor.group_labels.intersect?(allowed_groups)
|
|
465
468
|
end
|
|
466
469
|
|
|
470
|
+
def forward_rules
|
|
471
|
+
ForwardAuthRules.new(forward_auth_rules)
|
|
472
|
+
end
|
|
473
|
+
|
|
467
474
|
def permitted_scopes(requested)
|
|
468
475
|
return scope_list if Scopes.list(requested).empty?
|
|
469
476
|
|
|
@@ -604,6 +611,14 @@ module Masks
|
|
|
604
611
|
errors.add(:grant_types, "not supported: #{unknown.join(', ')}") if unknown.any?
|
|
605
612
|
end
|
|
606
613
|
|
|
614
|
+
def forward_auth_rules_are_usable
|
|
615
|
+
held = forward_auth_rules
|
|
616
|
+
|
|
617
|
+
return errors.add(:base, "forward auth rules apply only to an app behind a proxy") if held.present? && !forward?
|
|
618
|
+
|
|
619
|
+
ForwardAuthRules.problems(held).each { |problem| errors.add(:base, problem) }
|
|
620
|
+
end
|
|
621
|
+
|
|
607
622
|
def forward_is_described
|
|
608
623
|
errors.add(:grant_types, "are not used by an app behind a proxy") if grant_types.any?
|
|
609
624
|
errors.add(:token_endpoint_auth_method, "is none for an app behind a proxy") unless public?
|
|
@@ -12,7 +12,6 @@ module Masks
|
|
|
12
12
|
LONGEST = 24.hours
|
|
13
13
|
REFUSAL_LIFETIME = 5.minutes
|
|
14
14
|
NAME_LIMIT = 100
|
|
15
|
-
WINDOW = 10.minutes
|
|
16
15
|
TENANT_FACTOR = 10
|
|
17
16
|
AUTH_METHODS = %w[none private_key_jwt].freeze
|
|
18
17
|
DEFAULT_AUTH_METHOD = "none".freeze
|
|
@@ -62,14 +61,11 @@ module Masks
|
|
|
62
61
|
end
|
|
63
62
|
|
|
64
63
|
def crowded?
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
anywhere = ::Rails.cache.increment(key, 1, expires_in: WINDOW)
|
|
64
|
+
tenant = Current.tenant&.id
|
|
65
|
+
here = Throttle.crowded?(:registration, "documents:#{tenant}:#{Current.ip_address}")
|
|
66
|
+
anywhere = Throttle.crowded?(:registration, "documents:#{tenant}", times: TENANT_FACTOR)
|
|
69
67
|
|
|
70
|
-
(here
|
|
71
|
-
::Rails.logger.info("masks: too many new client metadata documents") if crowded
|
|
72
|
-
end
|
|
68
|
+
(here || anywhere).tap { |crowded| Throttle.hit!(:registration, document: true) if crowded }
|
|
73
69
|
end
|
|
74
70
|
|
|
75
71
|
def refusal_key(client_id)
|
|
@@ -31,7 +31,7 @@ module Masks
|
|
|
31
31
|
|
|
32
32
|
def inbox_crowded?(address:, ip: nil)
|
|
33
33
|
recent = inbox.where(created_at: WINDOW.ago..)
|
|
34
|
-
limit =
|
|
34
|
+
limit = Throttle.limit(:recovery)
|
|
35
35
|
|
|
36
36
|
recent.where("payload->>'address' = ?", address).count >= limit ||
|
|
37
37
|
(ip.present? && recent.where("payload->>'ip' = ?", ip).count >= limit * INBOX_PER_IP)
|
|
@@ -40,7 +40,7 @@ module Masks
|
|
|
40
40
|
def crowded?(actor:, channel:)
|
|
41
41
|
where(actor_id: actor.id, created_at: WINDOW.ago..)
|
|
42
42
|
.where("payload->>'channel' = ?", channel)
|
|
43
|
-
.count >=
|
|
43
|
+
.count >= Throttle.limit(:recovery)
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def inbox
|