masks 0.3.1 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/masks/application.css +1 -1
  3. data/app/assets/builds/masks/application.js +2153 -726
  4. data/app/assets/builds/masks/application.js.map +4 -4
  5. data/app/assets/javascripts/controllers/application.js +1 -1
  6. data/app/assets/javascripts/controllers/index.js +9 -0
  7. data/app/assets/javascripts/controllers/table_controller.js +15 -0
  8. data/app/assets/stylesheets/application.css +12 -4
  9. data/app/controllers/concerns/masks/controller.rb +1 -1
  10. data/app/controllers/masks/manage/actors_controller.rb +72 -1
  11. data/app/controllers/masks/manage/base_controller.rb +10 -2
  12. data/app/controllers/masks/manage/clients_controller.rb +84 -0
  13. data/app/controllers/masks/manage/dashboard_controller.rb +15 -0
  14. data/app/controllers/masks/manage/devices_controller.rb +19 -0
  15. data/app/controllers/masks/openid/authorizations_controller.rb +45 -0
  16. data/app/controllers/masks/openid/discoveries_controller.rb +55 -0
  17. data/app/controllers/masks/openid/tokens_controller.rb +45 -0
  18. data/app/controllers/masks/openid/userinfo_controller.rb +28 -0
  19. data/app/controllers/masks/sessions_controller.rb +1 -1
  20. data/app/models/concerns/masks/access.rb +2 -2
  21. data/app/models/masks/access/actor_password.rb +2 -1
  22. data/app/models/masks/access/actor_signup.rb +1 -2
  23. data/app/models/masks/credentials/access_token.rb +60 -0
  24. data/app/models/masks/credentials/key.rb +1 -1
  25. data/app/models/masks/credentials/return_to.rb +27 -0
  26. data/app/models/masks/mask.rb +12 -1
  27. data/app/models/masks/openid/authorization.rb +116 -0
  28. data/app/models/masks/openid/token.rb +56 -0
  29. data/app/models/masks/rails/actor.rb +23 -1
  30. data/app/models/masks/rails/openid/access_token.rb +55 -0
  31. data/app/models/masks/rails/openid/authorization.rb +45 -0
  32. data/app/models/masks/rails/openid/client.rb +186 -0
  33. data/app/models/masks/rails/openid/id_token.rb +43 -0
  34. data/app/models/masks/sessions/access.rb +2 -1
  35. data/app/resources/masks/session_resource.rb +1 -1
  36. data/app/views/layouts/masks/manage.html.erb +22 -5
  37. data/app/views/masks/actor_mailer/recover_credentials.html.erb +2 -3
  38. data/app/views/masks/actor_mailer/verify_email.html.erb +2 -3
  39. data/app/views/masks/actors/current.html.erb +7 -14
  40. data/app/views/masks/application/_header.html.erb +3 -4
  41. data/app/views/masks/backup_codes/new.html.erb +34 -20
  42. data/app/views/masks/emails/new.html.erb +14 -8
  43. data/app/views/masks/keys/new.html.erb +7 -7
  44. data/app/views/masks/manage/actors/index.html.erb +101 -37
  45. data/app/views/masks/manage/{actor → actors}/show.html.erb +63 -17
  46. data/app/views/masks/manage/clients/index.html.erb +102 -0
  47. data/app/views/masks/manage/clients/show.html.erb +156 -0
  48. data/app/views/masks/manage/dashboard/index.html.erb +10 -0
  49. data/app/views/masks/manage/devices/index.html.erb +47 -0
  50. data/app/views/masks/one_time_code/new.html.erb +41 -24
  51. data/app/views/masks/openid/authorizations/error.html.erb +23 -0
  52. data/app/views/masks/openid/authorizations/new.html.erb +46 -0
  53. data/app/views/masks/passwords/edit.html.erb +20 -7
  54. data/app/views/masks/recoveries/new.html.erb +2 -4
  55. data/app/views/masks/recoveries/password.html.erb +2 -3
  56. data/app/views/masks/sessions/new.html.erb +22 -23
  57. data/config/initializers/inflections.rb +5 -0
  58. data/config/locales/en.yml +23 -2
  59. data/config/routes.rb +40 -3
  60. data/db/migrate/20240329182422_support_openid.rb +64 -0
  61. data/lib/generators/masks/install/templates/masks.json +4 -1
  62. data/lib/masks/configuration.rb +22 -9
  63. data/lib/masks/version.rb +1 -1
  64. data/lib/masks.rb +1 -0
  65. data/lib/tasks/masks_tasks.rake +3 -2
  66. data/masks.json +47 -6
  67. metadata +59 -11
  68. data/app/assets/builds/application.css +0 -4764
  69. data/app/assets/builds/application.js +0 -8236
  70. data/app/assets/builds/application.js.map +0 -7
  71. data/app/controllers/masks/manage/actor_controller.rb +0 -35
@@ -3,7 +3,7 @@ import { Application } from "@hotwired/stimulus";
3
3
  const application = Application.start();
4
4
 
5
5
  // Configure Stimulus development experience
6
- application.debug = false;
6
+ application.debug = true;
7
7
  window.Stimulus = application;
8
8
 
9
9
  export { application };
@@ -1,12 +1,21 @@
1
1
  import { application } from "./application";
2
2
 
3
+ import PasswordVisibilityController from "@stimulus-components/password-visibility";
4
+ import RevealController from "@stimulus-components/reveal";
5
+ import DialogController from "@stimulus-components/dialog";
3
6
  import SessionController from "./session_controller";
4
7
  import RecoverController from "./recover_controller";
5
8
  import RecoverPasswordController from "./recover_password_controller";
6
9
  import EmailsController from "./emails_controller";
7
10
  import KeysController from "./keys_controller";
11
+ import TableController from "./table_controller";
12
+
8
13
  application.register("session", SessionController);
9
14
  application.register("recover", RecoverController);
10
15
  application.register("recover-password", RecoverPasswordController);
11
16
  application.register("emails", EmailsController);
12
17
  application.register("keys", KeysController);
18
+ application.register("table", TableController);
19
+ application.register("password-visibility", PasswordVisibilityController);
20
+ application.register("reveal", RevealController);
21
+ application.register("dialog", DialogController);
@@ -0,0 +1,15 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ static get targets() {
5
+ return ["url"];
6
+ }
7
+
8
+ get href() {
9
+ return this.urlTarget.href;
10
+ }
11
+
12
+ click(e) {
13
+ Turbo.visit(this.href);
14
+ }
15
+ }
@@ -17,10 +17,18 @@
17
17
  @tailwind components;
18
18
  @tailwind utilities;
19
19
 
20
- .pagination {
20
+ .pagy {
21
21
  @apply flex gap-2 items-center justify-center p-4;
22
- }
23
22
 
24
- .pagination .page {
25
- @apply btn btn-sm;
23
+ a:not(.gap) {
24
+ @apply btn btn-sm;
25
+
26
+ &:not([href]) {
27
+ @apply btn-disabled;
28
+ }
29
+
30
+ &.current {
31
+ @apply btn-neutral;
32
+ }
33
+ }
26
34
  }
@@ -85,7 +85,7 @@ module Masks
85
85
  # @return [Masks::Actor]
86
86
  # @visibility public
87
87
  def current_actor
88
- masked_session.scoped
88
+ masked_session.actor
89
89
  end
90
90
 
91
91
  # Returns the mask for the request.
@@ -4,8 +4,79 @@ module Masks
4
4
  # @visibility private
5
5
  module Manage
6
6
  class ActorsController < BaseController
7
+ section :actors
8
+
9
+ before_action :find_actor
10
+
7
11
  def index
8
- @pagy, @actors = pagy(Masks::Rails::Actor.all)
12
+ @pagy, @actors = pagy(actor_model.all)
13
+ end
14
+
15
+ def create
16
+ actor =
17
+ signup_access.signup(
18
+ nickname: params[:nickname],
19
+ password: params[:password]
20
+ )
21
+
22
+ if actor.valid?
23
+ redirect_to manage_actor_path(actor)
24
+ else
25
+ flash[:errors] = actor.errors.full_messages
26
+ redirect_to manage_actors_path
27
+ end
28
+ end
29
+
30
+ def update
31
+ actor_model.transaction do
32
+ if params[:add_scope]
33
+ @actor.assign_scopes!(params[:add_scope])
34
+ flash[:info] = "added scope \"#{params[:add_scope]}\""
35
+ elsif params[:remove_scope]
36
+ @actor.remove_scopes!(params[:remove_scope])
37
+ flash[:info] = "removed scope \"#{params[:remove_scope]}\""
38
+ elsif params[:remove_factor2]
39
+ @actor.remove_factor2!
40
+ flash[:info] = "removed second factor authentication"
41
+ elsif params[:logout]
42
+ @actor.logout!
43
+ flash[:info] = "logged out of all devices"
44
+ elsif password_param
45
+ password_access.change_password(password_param, actor: @actor)
46
+
47
+ if @actor.valid?
48
+ flash[:info] = "password changed"
49
+ else
50
+ flash[:error] = "invalid password"
51
+ end
52
+ end
53
+
54
+ redirect_to manage_actor_path(@actor)
55
+ end
56
+
57
+ @actor
58
+ end
59
+
60
+ private
61
+
62
+ def find_actor
63
+ @actor = actor_model.find_by(nickname: params[:actor])
64
+ end
65
+
66
+ def actor_model
67
+ Masks.configuration.model(:actor)
68
+ end
69
+
70
+ def signup_access
71
+ masked_session.access("actor.signup")
72
+ end
73
+
74
+ def password_access
75
+ masked_session.access("actor.password")
76
+ end
77
+
78
+ def password_param
79
+ params[:change_password]
9
80
  end
10
81
  end
11
82
  end
@@ -4,9 +4,17 @@ module Masks
4
4
  # @visibility private
5
5
  module Manage
6
6
  class BaseController < ApplicationController
7
- # require_mask type: :manage
8
-
9
7
  layout "masks/manage"
8
+
9
+ class << self
10
+ def section(name)
11
+ before_action { @section = name }
12
+ end
13
+ end
14
+
15
+ helper_method :current_actor, :section
16
+
17
+ attr_accessor :section
10
18
  end
11
19
  end
12
20
  end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ # @visibility private
5
+ module Manage
6
+ class ClientsController < BaseController
7
+ section :clients
8
+
9
+ before_action :find_client, only: %i[show update destroy]
10
+
11
+ rescue_from Pagy::OverflowError do
12
+ redirect_to manage_clients_path
13
+ end
14
+
15
+ def index
16
+ @pagy, @clients =
17
+ pagy(Masks::Rails::OpenID::Client.all.order(created_at: :desc))
18
+ end
19
+
20
+ def create
21
+ client =
22
+ Masks.configuration.model(:openid_client).new(name: params[:name])
23
+
24
+ if client.save
25
+ redirect_to manage_client_path(client)
26
+ else
27
+ flash[:errors] = client.errors.full_messages
28
+
29
+ redirect_to manage_clients_path
30
+ end
31
+ end
32
+
33
+ def update
34
+ Masks
35
+ .configuration
36
+ .model(:openid_client)
37
+ .transaction do
38
+ if params[:add_scope]
39
+ @client.assign_scopes!(params[:add_scope])
40
+ flash[:info] = "added scope"
41
+ elsif params[:remove_scope]
42
+ @client.remove_scopes!(params[:remove_scope])
43
+ flash[:info] = "removed scope"
44
+ else
45
+ update_client
46
+ end
47
+
48
+ redirect_to manage_client_path(@client)
49
+ end
50
+ end
51
+
52
+ def destroy
53
+ @client.destroy
54
+
55
+ flash[:destroyed] = @client.name
56
+
57
+ redirect_to manage_clients_path
58
+ end
59
+
60
+ private
61
+
62
+ def update_client
63
+ @client.name = params[:name]
64
+ @client.secret = params[:secret]
65
+ @client.consent = params[:consent]
66
+ @client.redirect_uris = params[:redirect_uris].split("\n")
67
+ @client.subject_type = params[:subject_type]
68
+ @client.code_expires_in = params[:code_expires_in]
69
+ @client.token_expires_in = params[:token_expires_in]
70
+ @client.refresh_expires_in = params[:refresh_expires_in]
71
+ @client.save
72
+
73
+ return if @client.valid?
74
+
75
+ flash[:errors] = @client.errors.full_messages
76
+ end
77
+
78
+ def find_client
79
+ @client =
80
+ Masks.configuration.model(:openid_client).find_by(key: params[:id])
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ # @visibility private
5
+ module Manage
6
+ class DashboardController < BaseController
7
+ section :dashboard
8
+
9
+ def index
10
+ @clients = Masks.configuration.model(:openid_client).count
11
+ @actors = Masks.configuration.model(:actor).count
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ # @visibility private
5
+ module Manage
6
+ class DevicesController < BaseController
7
+ section :devices
8
+
9
+ rescue_from Pagy::OverflowError do
10
+ redirect_to manage_devices_path
11
+ end
12
+
13
+ def index
14
+ @pagy, @devices =
15
+ pagy(Masks.configuration.model(:device).all.order(created_at: :desc))
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+ module Masks
3
+ module OpenID
4
+ class AuthorizationsController < ApplicationController
5
+ rescue_from Rack::OAuth2::Server::Authorize::BadRequest do |e|
6
+ @error = e
7
+
8
+ render :error, status: e.status
9
+ end
10
+
11
+ def new
12
+ authorize
13
+ end
14
+
15
+ def create
16
+ authorize approved: params[:approve]
17
+ end
18
+
19
+ private
20
+
21
+ def authorize(**opts)
22
+ # TODO: support incoming id_token request object + max_age parameter
23
+ @authorization = Authorization.perform(request.env, **opts)
24
+
25
+ _status, header, = @authorization.response
26
+
27
+ if header["WWW-Authenticate"].present?
28
+ headers["WWW-Authenticate"] = header["WWW-Authenticate"]
29
+ end
30
+
31
+ if header["Location"]
32
+ return redirect_to header["Location"], allow_other_host: true
33
+ end
34
+
35
+ unless @authorization.actor
36
+ session[:return_to] = request.url
37
+
38
+ return redirect_to session_path
39
+ end
40
+
41
+ render :new
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+ module Masks
3
+ module OpenID
4
+ class DiscoveriesController < ApplicationController
5
+ before_action :find_client
6
+
7
+ def jwks
8
+ jwks =
9
+ JSON::JWK::Set.new(
10
+ JSON::JWK.new(client.public_key, use: :sig, kid: client.kid)
11
+ )
12
+
13
+ render json: jwks
14
+ end
15
+
16
+ def new
17
+ render json:
18
+ OpenIDConnect::Discovery::Provider::Config::Response.new(
19
+ issuer: client.issuer,
20
+ authorization_endpoint: openid_authorization_url,
21
+ token_endpoint: openid_token_url,
22
+ userinfo_endpoint: openid_userinfo_url,
23
+ jwks_uri: openid_jwks_url,
24
+ # registration_endpoint: site_url, # TODO
25
+ scopes_supported: client.scopes,
26
+ response_types_supported: client.response_types,
27
+ grant_types_supported: client.grant_types,
28
+ claims_parameter_supported: false,
29
+ request_parameter_supported: false,
30
+ request_uri_parameter_supported: false,
31
+ subject_types_supported: [
32
+ client.pairwise_subject? ? "pairwise" : "public"
33
+ ],
34
+ id_token_signing_alg_values_supported: [:RS256],
35
+ token_endpoint_auth_methods_supported: %w[
36
+ client_secret_basic
37
+ client_secret_post
38
+ ],
39
+ claims_supported: %w[sub iss name email address phone_number]
40
+ )
41
+ end
42
+
43
+ private
44
+
45
+ def find_client
46
+ head :not_found unless client
47
+ end
48
+
49
+ def client
50
+ @client ||=
51
+ Masks.configuration.model(:openid_client).find_by(key: params[:id])
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+ module Masks
3
+ module OpenID
4
+ class TokensController < ApplicationController
5
+ rescue_from Rack::OAuth2::Server::Authorize::BadRequest do |e|
6
+ @error = e
7
+
8
+ render :error, status: e.status
9
+ end
10
+
11
+ def new
12
+ authorize
13
+ end
14
+
15
+ def create
16
+ authorize approved: params[:approve]
17
+ end
18
+
19
+ private
20
+
21
+ def authorize(**opts)
22
+ # TODO: support incoming id_token request object + max_age parameter
23
+ @authorization = Authorization.perform(request.env, **opts)
24
+
25
+ unless @authorization.actor
26
+ session[:return_to] = request.url
27
+
28
+ return redirect_to session_path
29
+ end
30
+
31
+ _status, header, = @authorization.response
32
+
33
+ if header["WWW-Authenticate"].present?
34
+ headers["WWW-Authenticate"] = header["WWW-Authenticate"]
35
+ end
36
+
37
+ if header["Location"]
38
+ redirect_to header["Location"]
39
+ else
40
+ render :new
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+ module Masks
3
+ module OpenID
4
+ class UserInfoController < ApplicationController
5
+ def show
6
+ claims = { sub: openid_client.subject(current_actor) }
7
+
8
+ if access_token.scope?("email")
9
+ claims[:email] = current_actor.primary_email&.email
10
+ end
11
+
12
+ if access_token.scope?("phone")
13
+ claims[:phone] = current_actor.phone_number
14
+ end
15
+
16
+ render json: OpenIDConnect::ResponseObject::UserInfo.new(claims)
17
+ end
18
+
19
+ private
20
+
21
+ def access_token
22
+ @access_token ||= masked_session.extra(:access_token)
23
+ end
24
+
25
+ delegate :openid_client, to: :access_token
26
+ end
27
+ end
28
+ end
@@ -21,7 +21,7 @@ module Masks
21
21
  path =
22
22
  (
23
23
  if masked_session.passed?
24
- masked_session.mask.pass ||
24
+ session.delete(:return_to) || masked_session.mask.pass ||
25
25
  Masks.configuration.site_links[:after_login]
26
26
  else
27
27
  masked_session.mask.fail ||
@@ -53,10 +53,10 @@ module Masks
53
53
  def actor
54
54
  raise Masks::Error::InvalidSession unless session
55
55
 
56
- session.scoped || session.actor
56
+ session.actor
57
57
  end
58
58
 
59
- delegate :configuration,
59
+ delegate :config,
60
60
  :roles,
61
61
  :role?,
62
62
  :role_records,
@@ -10,7 +10,8 @@ module Masks
10
10
 
11
11
  access "actor.password"
12
12
 
13
- def change_password(password)
13
+ def change_password(password, **opts)
14
+ actor = opts[:actor] || self.actor
14
15
  actor.changed_password_at = Time.current
15
16
  actor.password = password
16
17
  actor.save if actor.valid?
@@ -11,8 +11,7 @@ module Masks
11
11
  access "actor.signup"
12
12
 
13
13
  def signup(**opts)
14
- actor =
15
- configuration.build_actor(session, **opts.slice(:nickname, :email))
14
+ actor = config.build_actor(session, **opts.slice(:nickname, :email))
16
15
  actor.password = opts[:password]
17
16
  actor.save if actor.valid?
18
17
  actor
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Credentials
5
+ # Checks :key given a valid Authorization header.
6
+ class AccessToken < Masks::Credential
7
+ checks :access_token
8
+
9
+ def lookup
10
+ access_token =
11
+ session.config.model(:openid_access_token).valid.find_by(token:)
12
+
13
+ return unless access_token&.actor
14
+
15
+ session.extras(access_token:)
16
+ session.scoped = access_token
17
+
18
+ access_token.actor
19
+ end
20
+
21
+ def maskup
22
+ access_token = session.extra(:access_token)
23
+
24
+ if access_token&.actor && access_token&.actor == session&.actor &&
25
+ session.scoped == access_token
26
+ approve!
27
+ else
28
+ deny!
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def token
35
+ return if [header_token, param_token].uniq.compact.length != 1
36
+
37
+ header_token || param_token
38
+ end
39
+
40
+ def header_token
41
+ unless auth_header.provided? && !auth_header.parts.first.nil? &&
42
+ auth_header.scheme.to_s == "bearer"
43
+ return
44
+ end
45
+
46
+ auth_header.params
47
+ end
48
+
49
+ def param_token
50
+ params[:access_token]
51
+ end
52
+
53
+ def auth_header
54
+ return unless session.try(:request)
55
+
56
+ @auth_header = Rack::Auth::AbstractRequest.new(session.request.env)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -31,7 +31,7 @@ module Masks
31
31
  end
32
32
 
33
33
  def backup
34
- session.scoped.touch(:accessed_at) if session&.passed? && accessed
34
+ session.extra(:key).touch(:accessed_at) if session&.passed? && accessed
35
35
  end
36
36
  end
37
37
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Credentials
5
+ # Assigns the request URL to session data, under the key +return_to+.
6
+ #
7
+ # This credential is intended to keep track of an actor's attempts to
8
+ # access protected resources, so it only assigns the value if the session
9
+ # has not passed.
10
+ #
11
+ # In some cases, you may want to selectively disable the functionality
12
+ # of this credential. To do so, set +return_to+ to +false+ in the
13
+ # mask configuration.
14
+ class ReturnTo < Masks::Credential
15
+ def backup
16
+ return if session&.passed?
17
+ return unless session.try(:request) && session.request.get?
18
+ if session.mask.extras.key?(:return_to) &&
19
+ !session.mask.extras[:return_to]
20
+ return
21
+ end
22
+
23
+ session.data[:return_to] = session.request.url
24
+ end
25
+ end
26
+ end
27
+ end
@@ -66,6 +66,10 @@ module Masks
66
66
  # Whether or not to save results of masks
67
67
  # @return [Boolean]
68
68
  attribute :backup, default: true
69
+ # @!attribute [rw] extras
70
+ # Extra attributes and configuration accessible on the mask
71
+ # @return [Hash]
72
+ attribute :extras, default: -> { {} }
69
73
 
70
74
  # @visibility private
71
75
  attribute :config
@@ -77,7 +81,14 @@ module Masks
77
81
  attrs = type.deep_merge(**attrs)
78
82
  end
79
83
 
80
- super(attrs)
84
+ # required for `attribute_names` to work
85
+ super({})
86
+
87
+ extras = attrs.except(*attribute_names.map(&:to_sym))
88
+ attrs = attrs.slice(*attribute_names.map(&:to_sym))
89
+ attrs[:extras] = extras.deep_symbolize_keys
90
+
91
+ assign_attributes(**attrs)
81
92
  end
82
93
 
83
94
  # Returns the class name expected for any actor attached to this session.