masks 0.3.1 → 0.4.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.
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.