punk 0.1.4 → 0.2.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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/.editorconfig +9 -0
  3. data/.github/workflows/test.yml +26 -1
  4. data/.rdoc_options +23 -0
  5. data/.rgignore +1 -0
  6. data/.rspec +2 -0
  7. data/Gemfile +5 -6
  8. data/Gemfile.lock +16 -29
  9. data/README.md +1 -1
  10. data/VERSION +1 -1
  11. data/app/migrations/001_lets_punk.rb +3 -0
  12. data/app/routes/hello.rb +4 -0
  13. data/env/.gitignore +3 -0
  14. data/env/spec/test.sh +3 -0
  15. data/lib/punk/actions/.keep +0 -0
  16. data/lib/punk/actions/groups/list.rb +24 -0
  17. data/lib/punk/actions/sessions/clear.rb +21 -0
  18. data/lib/punk/actions/sessions/create.rb +64 -0
  19. data/lib/punk/actions/sessions/list.rb +18 -0
  20. data/lib/punk/actions/sessions/verify.rb +24 -0
  21. data/lib/punk/actions/tenants/list.rb +18 -0
  22. data/lib/punk/actions/users/list_group.rb +18 -0
  23. data/lib/punk/actions/users/list_tenant.rb +18 -0
  24. data/lib/punk/actions/users/show.rb +18 -0
  25. data/lib/punk/commands/list.rb +12 -6
  26. data/lib/punk/config/defaults.json +3 -0
  27. data/lib/punk/config/schema.json +3 -0
  28. data/lib/punk/core/app.rb +4 -6
  29. data/lib/punk/core/commander.rb +7 -4
  30. data/lib/punk/core/exec.rb +2 -0
  31. data/lib/punk/core/load.rb +0 -1
  32. data/lib/punk/framework/command.rb +5 -1
  33. data/lib/punk/framework/plugins/validation.rb +0 -14
  34. data/lib/punk/helpers/loggable.rb +1 -1
  35. data/lib/punk/migrations/001_punk.rb +103 -0
  36. data/lib/punk/models/.keep +0 -0
  37. data/lib/punk/models/group.rb +20 -0
  38. data/lib/punk/models/group_user_metadata.rb +17 -0
  39. data/lib/punk/models/identity.rb +29 -0
  40. data/lib/punk/models/session.rb +89 -0
  41. data/lib/punk/models/tenant.rb +19 -0
  42. data/lib/punk/models/tenant_user_metadata.rb +17 -0
  43. data/lib/punk/models/user.rb +31 -0
  44. data/lib/punk/routes/groups.rb +31 -0
  45. data/lib/punk/routes/plivo.rb +4 -0
  46. data/lib/punk/routes/sessions.rb +108 -0
  47. data/lib/punk/routes/swagger.rb +9 -0
  48. data/lib/punk/routes/tenants.rb +29 -0
  49. data/lib/punk/routes/users.rb +36 -0
  50. data/lib/punk/services/.keep +0 -0
  51. data/lib/punk/services/challenge_claim.rb +46 -0
  52. data/lib/punk/services/create_identities.rb +25 -0
  53. data/lib/punk/services/generate_swagger.rb +25 -0
  54. data/lib/punk/services/prove_claim.rb +29 -0
  55. data/lib/punk/services/secret.rb +9 -0
  56. data/lib/punk/templates/groups/list.jbuilder +7 -0
  57. data/lib/punk/templates/plivo.slim +16 -0
  58. data/lib/punk/templates/sessions/list.jbuilder +6 -0
  59. data/lib/punk/templates/sessions/pending.jbuilder +4 -0
  60. data/lib/punk/templates/tenants/list.jbuilder +7 -0
  61. data/lib/punk/templates/tenants/list.slim +8 -0
  62. data/lib/punk/templates/users/list.jbuilder +7 -0
  63. data/lib/punk/templates/users/list.rcsv +4 -0
  64. data/lib/punk/templates/users/show.jbuilder +5 -0
  65. data/lib/punk/views/groups/list.rb +22 -0
  66. data/lib/punk/views/plivo_store.rb +15 -0
  67. data/lib/punk/views/sessions/list.rb +22 -0
  68. data/lib/punk/views/sessions/pending.rb +28 -0
  69. data/lib/punk/views/tenants/list.rb +22 -0
  70. data/lib/punk/views/users/list.rb +22 -0
  71. data/lib/punk/views/users/show.rb +22 -0
  72. data/lib/punk/workers/.keep +0 -0
  73. data/lib/punk/workers/expire_sessions.rb +9 -0
  74. data/lib/punk/workers/geocode_session_worker.rb +48 -0
  75. data/lib/punk/workers/identify_session_worker.rb +45 -0
  76. data/lib/punk/workers/secret.rb +18 -0
  77. data/lib/punk/workers/send_email_worker.rb +51 -0
  78. data/lib/punk/workers/send_sms_worker.rb +40 -0
  79. data/punk.gemspec +140 -14
  80. data/schema.psql +345 -0
  81. data/spec/actions/groups/punk/list_groups_action_spec.rb +36 -0
  82. data/spec/actions/sessions/punk/clear_session_action_spec.rb +29 -0
  83. data/spec/actions/sessions/punk/create_session_action_spec.rb +33 -0
  84. data/spec/actions/sessions/punk/list_sessions_action_spec.rb +26 -0
  85. data/spec/actions/sessions/punk/verify_session_action_spec.rb +59 -0
  86. data/spec/actions/tenants/punk/list_tenants_action_spec.rb +25 -0
  87. data/spec/actions/users/punk/list_group_users_action_spec.rb +26 -0
  88. data/spec/actions/users/punk/list_tenant_users_action_spec.rb +26 -0
  89. data/spec/factories/group.rb +12 -0
  90. data/spec/factories/group_user_metadata.rb +10 -0
  91. data/spec/factories/identity.rb +19 -0
  92. data/spec/factories/session.rb +12 -0
  93. data/spec/factories/tenant.rb +10 -0
  94. data/spec/factories/tenant_user_metadata.rb +10 -0
  95. data/spec/factories/user.rb +12 -0
  96. data/spec/lib/commands/auth_spec.rb +11 -0
  97. data/spec/lib/commands/generate_spec.rb +7 -0
  98. data/spec/lib/commands/http_spec.rb +23 -0
  99. data/spec/lib/commands/list_spec.rb +7 -0
  100. data/spec/lib/commands/swagger_spec.rb +7 -0
  101. data/spec/lib/engine/punk_env_spec.rb +13 -0
  102. data/spec/lib/engine/punk_exec_spec.rb +9 -0
  103. data/spec/lib/engine/punk_init_spec.rb +9 -0
  104. data/spec/lib/engine/punk_store_spec.rb +10 -0
  105. data/spec/lib/punk.env +7 -0
  106. data/spec/models/punk/group_spec.rb +50 -0
  107. data/spec/models/punk/group_user_metadata_spec.rb +61 -0
  108. data/spec/models/punk/identity_spec.rb +61 -0
  109. data/spec/models/punk/session_spec.rb +156 -0
  110. data/spec/models/punk/tenant_spec.rb +51 -0
  111. data/spec/models/punk/tenant_user_metadata_spec.rb +61 -0
  112. data/spec/models/punk/user_spec.rb +115 -0
  113. data/spec/routes/groups/get_groups_spec.rb +33 -0
  114. data/spec/routes/plivo/get_plivo_spec.rb +11 -0
  115. data/spec/routes/sessions/delete_session_spec.rb +11 -0
  116. data/spec/routes/sessions/get_sessions_spec.rb +30 -0
  117. data/spec/routes/sessions/patch_session_spec.rb +11 -0
  118. data/spec/routes/sessions/post_session_spec.rb +11 -0
  119. data/spec/routes/swagger/get_swagger_spec.rb +12 -0
  120. data/spec/routes/tenants/get_tenants_spec.rb +31 -0
  121. data/spec/routes/users/get_users_spec.rb +60 -0
  122. data/spec/services/punk/challenge_claim_service_spec.rb +7 -0
  123. data/spec/services/punk/create_identities_service_spec.rb +14 -0
  124. data/spec/services/punk/generate_swagger_service_spec.rb +7 -0
  125. data/spec/services/punk/prove_claim_service_spec.rb +7 -0
  126. data/spec/services/punk/secret_service_spec.rb +7 -0
  127. data/spec/spec_helper.rb +122 -0
  128. data/spec/vcr_cassettes/PUNK_GeocodeSessionWorker/updates_the_session_data.yml +57 -0
  129. data/spec/vcr_cassettes/PUNK_IdentifySessionWorker/updates_the_session_data.yml +112 -0
  130. data/spec/views/punk/plivo_store_spec.rb +7 -0
  131. data/spec/views/sessions/punk/list_sessions_view_spec.rb +7 -0
  132. data/spec/views/sessions/punk/pending_session_view_spec.rb +7 -0
  133. data/spec/views/tenants/punk/list_tenants_view_spec.rb +7 -0
  134. data/spec/views/users/punk/list_groups_view_spec.rb +7 -0
  135. data/spec/views/users/punk/list_users_view_spec.rb +7 -0
  136. data/spec/workers/punk/expire_sessions_worker_spec.rb +31 -0
  137. data/spec/workers/punk/geocode_session_worker_spec.rb +14 -0
  138. data/spec/workers/punk/identify_session_worker_spec.rb +15 -0
  139. data/spec/workers/punk/secret_worker_spec.rb +20 -0
  140. data/spec/workers/punk/send_email_worker_spec.rb +46 -0
  141. data/spec/workers/punk/send_sms_worker_spec.rb +33 -0
  142. metadata +148 -11
  143. data/lib/punk/views/all.rb +0 -4
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PUNK
4
+ # @model
5
+ # @property slug(required) [string] a unique identifier for the session while it is being challenged
6
+ # @property message(required) [string] a message to be displayed to the user to let them know what to do
7
+ class Session < PUNK::Model
8
+ alias to_s state
9
+
10
+ many_to_one :identity
11
+ one_through_one :user, join_table: :identities, left_key: :id, left_primary_key: :identity_id
12
+
13
+ symbolize :state
14
+
15
+ aasm :state do
16
+ state :created, initial: true
17
+ state :pending
18
+ state :active
19
+ state :expired
20
+ state :deleted
21
+
22
+ event :challenge do
23
+ transitions from: :created, to: :pending, guard: :current?
24
+ end
25
+
26
+ event :verify, after: :erase do
27
+ transitions from: :pending, to: :active, guard: :current?
28
+ end
29
+
30
+ event :timeout, after: :erase do
31
+ transitions from: [:created, :pending, :active], to: :expired
32
+ end
33
+
34
+ event :clear, after: :erase do
35
+ transitions from: :active, to: :deleted
36
+ end
37
+ end
38
+
39
+ dataset_module do
40
+ def created
41
+ where(state: 'created')
42
+ end
43
+
44
+ def pending
45
+ where(state: 'pending')
46
+ end
47
+
48
+ def active
49
+ where(state: 'active')
50
+ end
51
+
52
+ def expired
53
+ where(state: 'expired')
54
+ end
55
+
56
+ def deleted
57
+ where(state: 'deleted')
58
+ end
59
+
60
+ def expiring
61
+ where { Sequel.&({ state: ['created', 'pending'] }, (created_at < 5.minutes.ago)) }.or { Sequel.&({ state: 'active' }, ((updated_at < 1.month.ago) | (created_at < 1.year.ago))) }
62
+ end
63
+ end
64
+
65
+ def validate
66
+ validates_presence :identity
67
+ validates_includes [:created, :pending, :active, :expired, :deleted], :state
68
+ validates_integer :attempt_count
69
+ validates_includes [0, 1, 2, 3], :attempt_count
70
+ end
71
+
72
+ def current?
73
+ !timeout?
74
+ end
75
+
76
+ def timeout?
77
+ timeout! if (created? || pending?) && created_at < 5.minutes.ago || active? && (updated_at < 1.month.ago || created_at < 1.year.ago)
78
+ expired?
79
+ end
80
+
81
+ def erase
82
+ update(slug: nil, salt: nil, hash: nil)
83
+ end
84
+
85
+ def increment_attempts
86
+ update(attempt_count: attempt_count + 1)
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PUNK
4
+ # @model
5
+ # @property id(required) [string] a unique identifier for the tenant
6
+ # @property name(required) [string] the name of the tenant
7
+ # @property icon(required) [string] an image URL
8
+ class Tenant < PUNK::Model
9
+ alias to_s name
10
+
11
+ many_to_many :users
12
+ one_to_many :groups
13
+
14
+ def validate
15
+ validates_presence :name
16
+ validates_url :icon, allow_blank: true
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PUNK
4
+ class TenantUserMetadata < PUNK::Model(:tenants_users)
5
+ many_to_one :tenant
6
+ many_to_one :user
7
+
8
+ def validate
9
+ validates_presence :tenant
10
+ validates_presence :user
11
+ end
12
+
13
+ def to_s
14
+ "#{tenant_id}|#{user_id}"
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PUNK
4
+ # @model
5
+ # @property id(required) [string] a unique identifier for the user
6
+ # @property name(required) [string] the name of the user
7
+ # @property icon(required) [string] an image URL
8
+ class User < Model
9
+ alias to_s name
10
+
11
+ many_to_many :tenants
12
+ many_to_many :groups
13
+ one_to_many :identities
14
+ many_through_many :sessions, through: [[:identities, :user_id, :id], [:sessions, :identity_id, :id]]
15
+
16
+ def validate
17
+ validates_presence :name
18
+ validates_url :icon, allow_blank: true
19
+ validates_presence :email if phone.blank?
20
+ validates_presence :phone if email.blank?
21
+ validates_email :email, allow_blank: true
22
+ validates_phone :phone, allow_blank: true
23
+ validates_unique :email, allow_blank: true
24
+ validates_unique :phone, allow_blank: true
25
+ end
26
+
27
+ def active_sessions
28
+ sessions_dataset.where(Sequel.lit('"sessions"."state"') => 'active')
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @resource Groups
4
+ #
5
+ # Each tenant can have many groups.
6
+ PUNK.route('groups') do
7
+ require_session!
8
+ require_tenant!
9
+
10
+ # Retrieve the list of groups visible to the authenticated user for a specific tenant.
11
+ # @path [GET] /groups
12
+ # @parameter tenant_id(required) [string] An email address or mobile phone number.
13
+ # @response [Array<Group>] 200 List of groups
14
+ # @method get
15
+ # @example 200
16
+ # [{
17
+ # "id": "deadbeef-1234-5678-abcd-000000000000",
18
+ # "name": "Cool Group",
19
+ # "icon": "https://some.image/url"
20
+ # }]
21
+ # @example 401
22
+ # {
23
+ # "message": "You must specify a tenant.",
24
+ # "errors": ["Cannot find tenant"]
25
+ # }
26
+ #
27
+ # route: GET /groups
28
+ get do
29
+ perform PUNK::ListGroupsAction, user: current_user, tenant: current_tenant
30
+ end
31
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # route: GET /plivo
4
+ PUNK.route('plivo') { present PUNK::PlivoStore }
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @resource Sessions
4
+ #
5
+ # Handle the authentication flow.
6
+ PUNK.route('sessions') do
7
+ # Challenge the claim of someone having a particular email address or phone number.
8
+ # @path [POST] /sessions
9
+ # @parameter claim(required) [string] An email address or mobile phone number.
10
+ # @response [Session] 201 Pending session created
11
+ # @response [Error] 400 Invalid claim, or the user is already authenticated
12
+ # @method post
13
+ # @example 201
14
+ # {
15
+ # "slug": "deadbeef-1234-5678-abcd-000000000000",
16
+ # "message": "A code has been sent to your email address."
17
+ # }
18
+ # @example 400
19
+ # {
20
+ # "message": "Validation failed",
21
+ # "errors": ["Claim is not an email or phone."]
22
+ # }
23
+ #
24
+ # route: POST /sessions
25
+ post do
26
+ require_anonymous!
27
+ perform PUNK::CreateSessionAction, args.merge(remote_addr: request.ip || PUNK::Session.default_values[:remote_addr].to_s, user_agent: request.env['HTTP_USER_AGENT'] || PUNK::Session.default_values[:user_agent])
28
+ end
29
+
30
+ # route: GET /sessions/current
31
+ on "current" do
32
+ require_session!
33
+ get do
34
+ perform PUNK::ShowUserAction, user: current_user
35
+ end
36
+ end
37
+
38
+ # route: PATCH /sessions/:slug
39
+ on :id do |slug|
40
+ require_anonymous!
41
+ user_session = PUNK::Session.find(slug: slug)
42
+ # Verify a pending session by providing proof of access to the email address or phone number.
43
+ # @path [PATCH] /sessions/{slug}
44
+ # @parameter secret(required) [string] The verification code sent by email or sms.
45
+ # @response [Info] 200 Session verified and cookie created
46
+ # @response [Error] 400 Invalid secret
47
+ # @method patch
48
+ # @example 200
49
+ # {
50
+ # "message": "You are now logged in."
51
+ # }
52
+ # @example 400
53
+ # {
54
+ # "message": "Validation failed",
55
+ # "errors": ["Secret does not match."]
56
+ # }
57
+ patch do
58
+ view = perform PUNK::VerifySessionAction, args.merge(session: user_session)
59
+ request.session[:session_id] = user_session.id if user_session&.active?
60
+ view
61
+ end
62
+ end
63
+
64
+ # Allow the current user to access their active sessions.
65
+ # @path [GET] /sessions
66
+ # @response [Array<Session>] 200 List of sessions
67
+ # @response [Error] 401 The user was not authenticated
68
+ # @method get
69
+ # @example 200
70
+ # [{
71
+ # "id": "deadbeef-1234-5678-abcd-000000000000",
72
+ # "client": {...}
73
+ # }]
74
+ # @example 401
75
+ # {
76
+ # "message": "you are not authenticated.",
77
+ # "errors": ["cannot find session"]
78
+ # }
79
+ #
80
+ # route: GET /tenants
81
+ get do
82
+ require_session!
83
+ perform PUNK::ListSessionsAction, user: current_user
84
+ end
85
+
86
+ # Allow the current user to logout.
87
+ # @path [DELETE] /sessions
88
+ # @response [Info] 200 Session destroyed and cookie cleared
89
+ # @response [Error] 401 The user was not authenticated
90
+ # @method destroy
91
+ # @example 200
92
+ # {
93
+ # "message": "You have logged out."
94
+ # }
95
+ # @example 401
96
+ # {
97
+ # "message": "You are not authenticated.",
98
+ # "errors": ["No session exists"]
99
+ # }
100
+ #
101
+ # route: DELETE /sessions
102
+ delete do
103
+ require_session!
104
+ view = perform PUNK::ClearSessionAction, session: current_session
105
+ clear_session if current_session&.deleted?
106
+ view
107
+ end
108
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # route: GET /swagger.json
4
+ PUNK.route('swagger') do
5
+ result = PUNK::GenerateSwaggerService.run.result
6
+ response.status = 200
7
+ response['Content-Type'] = 'application/json'
8
+ result
9
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @resource Tenants
4
+ #
5
+ # All resources in the system are relative to a particular tenant application.
6
+ PUNK.route('tenants') do
7
+ require_session!
8
+
9
+ # Retrieve the list of tenants visible to the authenticated user.
10
+ # @path [GET] /tenants
11
+ # @response [Array<Tenant>] 200 List of tenants
12
+ # @method get
13
+ # @example 200
14
+ # [{
15
+ # "id": "deadbeef-1234-5678-abcd-000000000000",
16
+ # "name": "Cool Tenant",
17
+ # "icon": "https://some.image/url"
18
+ # }]
19
+ # @example 401
20
+ # {
21
+ # "message": "You are not authenticated.",
22
+ # "errors": ["Cannot find session"]
23
+ # }
24
+ #
25
+ # route: GET /tenants
26
+ get do
27
+ perform PUNK::ListTenantsAction, user: current_user
28
+ end
29
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @resource Users
4
+ #
5
+ # Users can belong to many tenants and many groups.
6
+ PUNK.route('users') do
7
+ require_session!
8
+ require_tenant!
9
+
10
+ # Retrieve the list of users visible to the authenticated user for a specific tenant or group.
11
+ # @path [GET] /users
12
+ # @parameter tenant_id(required) [string] The ID of a tenant visible to the authenticated user.
13
+ # @parameter group_id [string] The ID of a group that belongs to the tenant.
14
+ # @response [Array<User>] 200 List of users
15
+ # @method get
16
+ # @example 200
17
+ # [{
18
+ # "id": "deadbeef-1234-5678-abcd-000000000000",
19
+ # "name": "John Smith",
20
+ # "icon": "https://some.image/url"
21
+ # }]
22
+ # @example 401
23
+ # {
24
+ # "message": "You must specify a tenant.",
25
+ # "errors": ["Cannot find tenant"]
26
+ # }
27
+ #
28
+ # route: GET /users
29
+ get do
30
+ if args[:group_id]
31
+ perform PUNK::ListGroupUsersAction, group: current_user.groups_dataset[tenant: current_tenant, id: args[:group_id]]
32
+ else
33
+ perform PUNK::ListTenantUsersAction, tenant: current_tenant
34
+ end
35
+ end
36
+ end
File without changes
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbnacl'
4
+
5
+ module PUNK
6
+ class ChallengeClaimService < Service
7
+ args :session
8
+
9
+ def validate
10
+ validates_not_null :session
11
+ validates_not_empty :session
12
+ return if session.blank?
13
+ validates_type Session, :session
14
+ validates_state :session, :created
15
+ validates_event :session, :challenge
16
+ end
17
+
18
+ def process
19
+ secret = SecretService.run.result
20
+ salt = RbNaCl::Random.random_bytes(RbNaCl::PasswordHash::SCrypt::SALTBYTES)
21
+ hash = RbNaCl::PasswordHash.scrypt(secret, salt, 1_048_576, 16_777_216)
22
+ session.update(salt: salt, hash: hash)
23
+ session.challenge!
24
+ identity = session.identity
25
+ case identity.claim_type
26
+ when :email
27
+ SendEmailWorker.perform_async(
28
+ from: 'GroupFire Accounts <noreply@groupfire.com>',
29
+ to: identity.claim,
30
+ subject: '[GroupFire] Verification Code',
31
+ template: 'verify',
32
+ tags: [:auth],
33
+ variables: {
34
+ name: identity.user&.name || 'New User',
35
+ secret: secret
36
+ }
37
+ )
38
+ when :phone
39
+ SendSmsWorker.perform_async(
40
+ to: identity.claim,
41
+ body: "Your GroupFire verification code is: #{secret}."
42
+ )
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PUNK
4
+ class CreateIdentitiesService < Service
5
+ def process
6
+ User.each do |user|
7
+ if user.email.present?
8
+ Identity.find_or_create(claim: user.email) do |i|
9
+ i.claim_type = :email
10
+ i.user = user
11
+ end
12
+ end
13
+ if user.phone.present?
14
+ Identity.find_or_create(claim: user.phone) do |i|
15
+ i.claim_type = :phone
16
+ i.user = user
17
+ end
18
+ end
19
+ rescue Sequel::ValidationFailed => e
20
+ logger.warn e.message
21
+ end
22
+ nil
23
+ end
24
+ end
25
+ end