otp-jwt 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 21a9ead6fc616488ac4b250bddf20522ed33d38b
4
+ data.tar.gz: d2f7ebaf6e27df53693ca7ce9cb733967ede96e7
5
+ SHA512:
6
+ metadata.gz: d6b0faa09edf95831f1874200b28e87bf7da8d156f6f7af28940f28afe5e04b1ecb869d78278d88de2b4a6ae62ffb37dab31cea7110ae42d86f0497155e6f98e
7
+ data.tar.gz: 1983f079088fc8b7b3a222ec6df065483f84d3ca072f369284b643ee024271b20cc865f7aa39ddbf2d84eadb90cbc8120748b69e1624ac0c75556f04e8e4e64e
@@ -0,0 +1,30 @@
1
+ workflow "Tests" {
2
+ on = "push"
3
+ resolves = [
4
+ "rspec-ruby2.6_rails4",
5
+ "rspec-ruby2.6_rails5"
6
+ ]
7
+ }
8
+
9
+ action "rspec-ruby2.6_rails4" {
10
+ uses = "docker://ruby:2.6-alpine"
11
+ env = {
12
+ RAILS_VERSION = "~> 4"
13
+ }
14
+ args = [
15
+ "sh", "-c",
16
+ "apk add -U git build-base sqlite-dev && rm Gemfile.lock && bundle install && rake"
17
+ ]
18
+ }
19
+
20
+ action "rspec-ruby2.6_rails5" {
21
+ uses = "docker://ruby:2.6-alpine"
22
+ needs = ["rspec-ruby2.6_rails4"]
23
+ env = {
24
+ RAILS_VERSION = "~> 5"
25
+ }
26
+ args = [
27
+ "sh", "-c",
28
+ "apk add -U git build-base sqlite-dev && rm Gemfile.lock && bundle install && rake"
29
+ ]
30
+ }
data/.gitignore ADDED
@@ -0,0 +1,2 @@
1
+ coverage
2
+ pkg
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,46 @@
1
+ inherit_gem:
2
+ rubocop-rails_config:
3
+ - config/rails.yml
4
+
5
+ require:
6
+ - rubocop-performance
7
+ - rubocop-rspec
8
+
9
+ Rails:
10
+ Enabled: true
11
+
12
+ RSpec:
13
+ Enabled: true
14
+
15
+ RSpec/MultipleExpectations:
16
+ Enabled: false
17
+
18
+ Performance:
19
+ Enabled: true
20
+
21
+ Bundler:
22
+ Enabled: true
23
+
24
+ Gemspec:
25
+ Enabled: true
26
+
27
+ Style/StringLiterals:
28
+ Enabled: true
29
+ EnforcedStyle: single_quotes
30
+
31
+ Style/FrozenStringLiteralComment:
32
+ Enabled: false
33
+
34
+ Metrics/LineLength:
35
+ Max: 80
36
+
37
+ Metrics/BlockLength:
38
+ Exclude:
39
+ - 'spec/**/*_spec.rb'
40
+ - '**/*.gemspec'
41
+
42
+ Layout/IndentationConsistency:
43
+ EnforcedStyle: normal
44
+
45
+ Style/BlockDelimiters:
46
+ Enabled: true
data/.yardstick.yml ADDED
@@ -0,0 +1,29 @@
1
+ ---
2
+ path: ['lib/**/*.rb']
3
+ threshold: 100
4
+ rules:
5
+ ApiTag::Presence:
6
+ enabled: false
7
+ ApiTag::Inclusion:
8
+ enabled: false
9
+ ApiTag::ProtectedMethod:
10
+ enabled: false
11
+ ApiTag::PrivateMethod:
12
+ enabled: false
13
+ ExampleTag:
14
+ enabled: false
15
+ ReturnTag:
16
+ enabled: true
17
+ exclude: []
18
+ Summary::Presence:
19
+ enabled: true
20
+ exclude: []
21
+ Summary::Length:
22
+ enabled: true
23
+ exclude: []
24
+ Summary::Delimiter:
25
+ enabled: true
26
+ exclude: []
27
+ Summary::SingleLine:
28
+ enabled: true
29
+ exclude: []
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in otp-jwt.gemspec
4
+ gemspec
5
+
6
+ gem 'rails', ENV['RAILS_VERSION']
data/Gemfile.lock ADDED
@@ -0,0 +1,197 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ otp-jwt (0.1.0)
5
+ activesupport
6
+ jwt (~> 2.1)
7
+ rotp (~> 4.1)
8
+
9
+ GEM
10
+ remote: https://rubygems.org/
11
+ specs:
12
+ actioncable (5.2.3)
13
+ actionpack (= 5.2.3)
14
+ nio4r (~> 2.0)
15
+ websocket-driver (>= 0.6.1)
16
+ actionmailer (5.2.3)
17
+ actionpack (= 5.2.3)
18
+ actionview (= 5.2.3)
19
+ activejob (= 5.2.3)
20
+ mail (~> 2.5, >= 2.5.4)
21
+ rails-dom-testing (~> 2.0)
22
+ actionpack (5.2.3)
23
+ actionview (= 5.2.3)
24
+ activesupport (= 5.2.3)
25
+ rack (~> 2.0)
26
+ rack-test (>= 0.6.3)
27
+ rails-dom-testing (~> 2.0)
28
+ rails-html-sanitizer (~> 1.0, >= 1.0.2)
29
+ actionview (5.2.3)
30
+ activesupport (= 5.2.3)
31
+ builder (~> 3.1)
32
+ erubi (~> 1.4)
33
+ rails-dom-testing (~> 2.0)
34
+ rails-html-sanitizer (~> 1.0, >= 1.0.3)
35
+ activejob (5.2.3)
36
+ activesupport (= 5.2.3)
37
+ globalid (>= 0.3.6)
38
+ activemodel (5.2.3)
39
+ activesupport (= 5.2.3)
40
+ activerecord (5.2.3)
41
+ activemodel (= 5.2.3)
42
+ activesupport (= 5.2.3)
43
+ arel (>= 9.0)
44
+ activestorage (5.2.3)
45
+ actionpack (= 5.2.3)
46
+ activerecord (= 5.2.3)
47
+ marcel (~> 0.3.1)
48
+ activesupport (5.2.3)
49
+ concurrent-ruby (~> 1.0, >= 1.0.2)
50
+ i18n (>= 0.7, < 2)
51
+ minitest (~> 5.1)
52
+ tzinfo (~> 1.1)
53
+ addressable (2.6.0)
54
+ public_suffix (>= 2.0.2, < 4.0)
55
+ arel (9.0.0)
56
+ ast (2.4.0)
57
+ builder (3.2.3)
58
+ concurrent-ruby (1.1.5)
59
+ crass (1.0.4)
60
+ diff-lcs (1.3)
61
+ docile (1.3.1)
62
+ erubi (1.8.0)
63
+ ffaker (2.10.0)
64
+ globalid (0.4.2)
65
+ activesupport (>= 4.2.0)
66
+ i18n (1.6.0)
67
+ concurrent-ruby (~> 1.0)
68
+ jaro_winkler (1.5.2)
69
+ json (2.2.0)
70
+ jwt (2.1.0)
71
+ loofah (2.2.3)
72
+ crass (~> 1.0.2)
73
+ nokogiri (>= 1.5.9)
74
+ mail (2.7.1)
75
+ mini_mime (>= 0.1.1)
76
+ marcel (0.3.3)
77
+ mimemagic (~> 0.3.2)
78
+ method_source (0.9.2)
79
+ mimemagic (0.3.3)
80
+ mini_mime (1.0.1)
81
+ mini_portile2 (2.4.0)
82
+ minitest (5.11.3)
83
+ nio4r (2.3.1)
84
+ nokogiri (1.10.2)
85
+ mini_portile2 (~> 2.4.0)
86
+ parallel (1.16.2)
87
+ parser (2.6.2.0)
88
+ ast (~> 2.4.0)
89
+ psych (3.1.0)
90
+ public_suffix (3.0.3)
91
+ rack (2.0.6)
92
+ rack-test (1.1.0)
93
+ rack (>= 1.0, < 3)
94
+ rails (5.2.3)
95
+ actioncable (= 5.2.3)
96
+ actionmailer (= 5.2.3)
97
+ actionpack (= 5.2.3)
98
+ actionview (= 5.2.3)
99
+ activejob (= 5.2.3)
100
+ activemodel (= 5.2.3)
101
+ activerecord (= 5.2.3)
102
+ activestorage (= 5.2.3)
103
+ activesupport (= 5.2.3)
104
+ bundler (>= 1.3.0)
105
+ railties (= 5.2.3)
106
+ sprockets-rails (>= 2.0.0)
107
+ rails-dom-testing (2.0.3)
108
+ activesupport (>= 4.2.0)
109
+ nokogiri (>= 1.6)
110
+ rails-html-sanitizer (1.0.4)
111
+ loofah (~> 2.2, >= 2.2.2)
112
+ railties (5.2.3)
113
+ actionpack (= 5.2.3)
114
+ activesupport (= 5.2.3)
115
+ method_source
116
+ rake (>= 0.8.7)
117
+ thor (>= 0.19.0, < 2.0)
118
+ rainbow (3.0.0)
119
+ rake (12.3.2)
120
+ rotp (4.1.0)
121
+ addressable (~> 2.5)
122
+ rspec-core (3.8.0)
123
+ rspec-support (~> 3.8.0)
124
+ rspec-expectations (3.8.2)
125
+ diff-lcs (>= 1.2.0, < 2.0)
126
+ rspec-support (~> 3.8.0)
127
+ rspec-mocks (3.8.0)
128
+ diff-lcs (>= 1.2.0, < 2.0)
129
+ rspec-support (~> 3.8.0)
130
+ rspec-rails (3.8.2)
131
+ actionpack (>= 3.0)
132
+ activesupport (>= 3.0)
133
+ railties (>= 3.0)
134
+ rspec-core (~> 3.8.0)
135
+ rspec-expectations (~> 3.8.0)
136
+ rspec-mocks (~> 3.8.0)
137
+ rspec-support (~> 3.8.0)
138
+ rspec-support (3.8.0)
139
+ rubocop (0.66.0)
140
+ jaro_winkler (~> 1.5.1)
141
+ parallel (~> 1.10)
142
+ parser (>= 2.5, != 2.5.1.1)
143
+ psych (>= 3.1.0)
144
+ rainbow (>= 2.2.2, < 4.0)
145
+ ruby-progressbar (~> 1.7)
146
+ unicode-display_width (>= 1.4.0, < 1.6)
147
+ rubocop-performance (1.0.0)
148
+ rubocop (>= 0.58.0)
149
+ rubocop-rails_config (0.4.4)
150
+ railties (>= 3.0)
151
+ rubocop (~> 0.58)
152
+ rubocop-rspec (1.32.0)
153
+ rubocop (>= 0.60.0)
154
+ ruby-progressbar (1.10.0)
155
+ simplecov (0.16.1)
156
+ docile (~> 1.1)
157
+ json (>= 1.8, < 3)
158
+ simplecov-html (~> 0.10.0)
159
+ simplecov-html (0.10.2)
160
+ sprockets (3.7.2)
161
+ concurrent-ruby (~> 1.0)
162
+ rack (> 1, < 3)
163
+ sprockets-rails (3.2.1)
164
+ actionpack (>= 4.0)
165
+ activesupport (>= 4.0)
166
+ sprockets (>= 3.0.0)
167
+ sqlite3 (1.3.13)
168
+ thor (0.20.3)
169
+ thread_safe (0.3.6)
170
+ tzinfo (1.2.5)
171
+ thread_safe (~> 0.1)
172
+ unicode-display_width (1.5.0)
173
+ websocket-driver (0.7.0)
174
+ websocket-extensions (>= 0.1.0)
175
+ websocket-extensions (0.1.3)
176
+ yard (0.9.18)
177
+ yardstick (0.9.9)
178
+ yard (~> 0.8, >= 0.8.7.2)
179
+
180
+ PLATFORMS
181
+ ruby
182
+
183
+ DEPENDENCIES
184
+ bundler
185
+ ffaker
186
+ otp-jwt!
187
+ rails (~> 5)
188
+ rspec-rails
189
+ rubocop-performance
190
+ rubocop-rails_config
191
+ rubocop-rspec
192
+ simplecov
193
+ sqlite3 (~> 1.3.6)
194
+ yardstick
195
+
196
+ BUNDLED WITH
197
+ 1.17.3
data/README.md ADDED
@@ -0,0 +1,265 @@
1
+ # OTP JWT ⎆
2
+
3
+ One time password (email, SMS) authentication support for HTTP APIs.
4
+
5
+ > The man who wrote the book on password management has a confession to make:
6
+ > He blew it.
7
+ >
8
+ >— [WSJ.com](https://www.wsj.com/articles/the-man-who-wrote-those-password-rules-has-a-new-tip-n3v-r-m1-d-1502124118)
9
+
10
+ This project provides a couple of mixins to help you build
11
+ applications/HTTP APIs without asking your users to provide passwords.
12
+
13
+ ## About
14
+
15
+ The goal of this project is to provide support for one time passwords
16
+ which are delivered via different channels (email, SMS), along with a
17
+ simple and easy to use JWT authentication.
18
+
19
+ Main goals:
20
+ * No _magic_ please
21
+ * No DSLs please
22
+ * Less code, less maintenance
23
+ * Good docs and test coverage
24
+ * Keep it up-to-date (or at least tell people this is no longer maintained)
25
+
26
+ The available features include:
27
+ * Flexible models support for
28
+ [counter based OTP](https://github.com/mdp/rotp#counter-based-otps)
29
+ * Flexible JWT token generation helpers for models and arbitrary data
30
+ * Pluggable authentication based on the OTP and JWT
31
+ * Pluggable OTP mailer
32
+ * Pluggable OTP SMS background processing job
33
+
34
+
35
+ This little project wouldn't be possible without the previous work on
36
+ [ROTP](https://github.com/mdp/rotp)
37
+ and [JWT](https://github.com/jwt/ruby-jwt/).
38
+
39
+ Thanks to everyone who worked on these amazing projects!
40
+
41
+ ## Installation
42
+
43
+ Add this line to your application's Gemfile:
44
+
45
+ ```ruby
46
+ gem 'otp-jwt'
47
+ ```
48
+
49
+ And then execute:
50
+
51
+ $ bundle
52
+
53
+ Or install it yourself as:
54
+
55
+ $ gem install otp-jwt
56
+
57
+ ## Usage
58
+
59
+ * [OTP for Active Record models](#otp-for-active-record-models)
60
+ * [Mailer support](#mailer-support)
61
+ * [SMS delivery support](#sms-delivery-support)
62
+ * [JWT for Active Record models](#jwt-for-active-record-models)
63
+ * [JWT authorization](#jwt-authorization)
64
+ * [JWT authentication](#jwt-authentication)
65
+
66
+ ---
67
+
68
+ To start using it with Rails, add this to an initializer and configure your
69
+ keys:
70
+
71
+ ```ruby
72
+ # config/initializers/otp-jwt.rb
73
+ require 'otp'
74
+ # To load the JWT related support.
75
+ require 'otp/jwt'
76
+
77
+ OTP::JWT::Token.jwt_signature_key = ENV['YOUR-SIGN-KEY']
78
+ ```
79
+ ### OTP for Active Record models
80
+
81
+ To add support for OTP to your models, use the `OTP::ActiveRecord` concern:
82
+
83
+ ```ruby
84
+ class User < ActiveRecord::Base
85
+ include OTP::ActiveRecord
86
+
87
+ ...
88
+ end
89
+ ```
90
+
91
+ This will provide two new methods which you can use to generate and verify
92
+ one time passwords:
93
+ * `User#otp`
94
+ * `User#verify_otp`
95
+
96
+ This concern expects two attributes to be provided by the model, the:
97
+ * `otp_secret`: of type string, used to store the OTP signature key
98
+ * `otp_counter`: of type integer, used to store the OTP counter
99
+
100
+ A migration to add these two looks like this:
101
+ ```
102
+ $ rails g migration add_otp_to_users otp_secret:string otp_counter:integer
103
+ ```
104
+
105
+ #### Mailer support
106
+
107
+ You can use the built-in mailer to deliver the OTP, just require it and
108
+ overwrite the helper method:
109
+
110
+ ```ruby
111
+ require 'otp/mailer'
112
+
113
+ class User < ActiveRecord::Base
114
+ include OTP::ActiveRecord
115
+
116
+ def email_otp
117
+ OTP::Mailer.otp(email, otp, self).deliver_later
118
+ end
119
+ end
120
+ ```
121
+
122
+ #### SMS delivery support
123
+
124
+ You can use the built-in job to deliver the OTP via SMS, just require it and
125
+ overwrite the helper method:
126
+
127
+ ```ruby
128
+ require 'otp/sms_otp_job'
129
+
130
+ class User < ActiveRecord::Base
131
+ include OTP::ActiveRecord
132
+
133
+ def sms_otp
134
+ OTP::SMSOTPJob.perform_later(phone_number, otp) if phone_number.present?
135
+ end
136
+ end
137
+ ```
138
+
139
+ You will have to provide your model with the phone number attribute if you
140
+ want to deliver the OTPs via SMS.
141
+
142
+ This job requires `aws-sdk-sns` gem to work. You will have to add it manually
143
+ and configure to use the correct keys. The SNS region is fetched from the
144
+ environment variable `AWS_SMS_REGION`.
145
+
146
+ ### JWT for Active Record models
147
+
148
+ To add support for JWT to your models, use the `OTP::JWT::ActiveRecord` concern:
149
+
150
+ ```ruby
151
+ class User < ActiveRecord::Base
152
+ include OTP::JWT::ActiveRecord
153
+
154
+ ...
155
+ end
156
+ ```
157
+
158
+ This will provide two new methods which you can use to generate and verify JWT
159
+ tokens:
160
+ * `User#from_jwt`
161
+ * `User#to_jwt`
162
+
163
+ ### JWT authorization
164
+
165
+ To add support for JWT to your controllers,
166
+ use the `OTP::JWT::ActionController` concern:
167
+
168
+ ```ruby
169
+ class ApplicationController < ActionController::Base
170
+ include OTP::JWT::ActionController
171
+
172
+ private
173
+
174
+ def current_user
175
+ @jwt_user ||= User.from_jwt(request_authorization_header)
176
+ end
177
+
178
+ def current_user!
179
+ current_user || raise('User authentication failed')
180
+ rescue
181
+ head(:unauthorized)
182
+ end
183
+ end
184
+ ```
185
+
186
+ The example from above includes helpers you can use interact with the
187
+ currently authenticated user or just use as part of `before_action` callback.
188
+
189
+ The `request_authorization_header` method is also provided by the concern and
190
+ allows you to customize from where the token is received. A query parameter
191
+ based alternative would look like this:
192
+
193
+ ```ruby
194
+ class ApplicationController < ActionController::Base
195
+ include OTP::JWT::ActionController
196
+
197
+ private
198
+
199
+ def current_user
200
+ @jwt_user ||= User.from_jwt(params[:token])
201
+ end
202
+
203
+ ...
204
+ end
205
+ ```
206
+
207
+ ### JWT authentication
208
+
209
+ The `OTP::JWT::ActionController` concern provides support for handling the
210
+ authentication requests and token generation by using the `jwt_from_otp` method.
211
+
212
+ Here's an example of a tokens controller:
213
+
214
+ ```ruby
215
+ class TokensController < ApplicationController
216
+ def create
217
+ user = User.find_by(email: params[:email])
218
+
219
+ jwt_from_otp(user, params[:otp]) do |auth_user|
220
+ # Let's update the last login date before we send the token...
221
+ # auth_user.update_column(:last_login_at, DateTime.current)
222
+
223
+ render json: { token: auth_user.to_jwt }, status: :created
224
+ end
225
+ end
226
+ end
227
+ ```
228
+
229
+ The `jwt_from_otp` does a couple of things here:
230
+ * It will try to authenticate the user you found by email and respond with
231
+ a valid JWT token
232
+ * It will try to schedule an email or SMS delivery of the OTP and it will
233
+ respond with the 400 HTTP status
234
+ * It will respond with the 403 HTTP status if there's no user
235
+ or the OTP is wrong
236
+
237
+ The OTP delivery is handled by the `User#deliver_otp` method
238
+ and can be customized. By default it will call the `sms_otp` method and
239
+ if nothing is returned, it will proceed with the `email_otp` method.
240
+
241
+ ## Development
242
+
243
+ After checking out the repo, run `bundle` to install dependencies.
244
+
245
+ Then, run `rake` to run the tests.
246
+
247
+ To install this gem onto your local machine, run `bundle exec rake install`.
248
+
249
+ To release a new version, update the version number in `version.rb`, and then
250
+ run `bundle exec rake release`, which will create a git tag for the version,
251
+ push git commits and tags, and push the `.gem` file to
252
+ [rubygems.org](https://rubygems.org).
253
+
254
+ ## Contributing
255
+
256
+ Bug reports and pull requests are welcome on GitHub at
257
+ https://github.com/stas/otp-jwt
258
+
259
+ This project is intended to be a safe, welcoming space for collaboration, and
260
+ contributors are expected to adhere to the
261
+ [Contributor Covenant](http://contributor-covenant.org) code of conduct.
262
+
263
+ ## License
264
+
265
+ TBD
data/Rakefile ADDED
@@ -0,0 +1,30 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+ require 'rubocop/rake_task'
4
+ require 'yaml'
5
+ require 'yardstick'
6
+
7
+ desc('Documentation stats and measurements')
8
+ task('qa:docs') do
9
+ yaml = YAML.load_file(File.expand_path('../.yardstick.yml', __FILE__))
10
+ config = Yardstick::Config.coerce(yaml)
11
+ measure = Yardstick.measure(config)
12
+ measure.puts
13
+ coverage = Yardstick.round_percentage(measure.coverage * 100)
14
+ exit(1) if coverage < config.threshold
15
+ end
16
+
17
+ desc('Codestyle check and linter')
18
+ RuboCop::RakeTask.new('qa:code') do |task|
19
+ task.fail_on_error = true
20
+ task.patterns = [
21
+ 'lib/**/*.rb',
22
+ 'spec/**/*.rb'
23
+ ]
24
+ end
25
+
26
+ desc('Run CI QA tasks')
27
+ task(qa: ['qa:docs', 'qa:code'])
28
+
29
+ RSpec::Core::RakeTask.new(spec: :qa)
30
+ task(default: :spec)
@@ -0,0 +1,81 @@
1
+ require 'rotp'
2
+ require 'active_support/concern'
3
+ require 'active_support/configurable'
4
+
5
+ module OTP
6
+ # [ActiveRecord] concern.
7
+ module ActiveRecord
8
+ include ActiveSupport::Configurable
9
+ extend ActiveSupport::Concern
10
+
11
+ # Length of the generated OTP, defaults to 4.
12
+ OTP_DIGITS = 4
13
+
14
+ included do
15
+ after_initialize :setup_otp
16
+ end
17
+
18
+ # Generates the OTP
19
+ #
20
+ # @return [String] or nil if no OTP is set
21
+ def otp
22
+ return nil unless valid? && persisted?
23
+ otp_digits = self.class.const_get(:OTP_DIGITS)
24
+ hotp = ROTP::HOTP.new(otp_secret, digits: otp_digits)
25
+
26
+ transaction do
27
+ increment!(:otp_counter)
28
+ hotp.at(otp_counter)
29
+ end
30
+ end
31
+
32
+ # Verifies the OTP
33
+ #
34
+ # @return true on success, false on failure
35
+ def verify_otp(otp)
36
+ hotp = ROTP::HOTP.new(otp_secret, digits: OTP_DIGITS)
37
+ transaction do
38
+ otp_status = hotp.verify(otp.to_s, otp_counter)
39
+ increment!(:otp_counter)
40
+ otp_status
41
+ end
42
+ end
43
+
44
+ # Helper to send the OTP using the SMS job
45
+ #
46
+ # Does nothing. Implement your own handler.
47
+ #
48
+ # @return [OTP::API::SMSOTPJob] instance of the job
49
+ def sms_otp
50
+ end
51
+
52
+ # Helper to email the OTP using a job
53
+ #
54
+ # Does nothing. Implement your own handler.
55
+ #
56
+ # @return [OTP::API::Mailer] instance of the job
57
+ def email_otp
58
+ end
59
+
60
+ # Helper to deliver the OTP
61
+ #
62
+ # Will use the SMS job if the phone number is available.
63
+ # Will default to the email delivery.
64
+ #
65
+ # @return [ActiveJob::Base] instance of the job
66
+ def deliver_otp
67
+ return unless persisted?
68
+ sms_otp || email_otp || raise(NotImplementedError, self)
69
+ end
70
+
71
+ private
72
+
73
+ # Provides a default value for the OTP secret attribute
74
+ #
75
+ # @return [String]
76
+ def setup_otp
77
+ self.otp_secret ||= ROTP::Base32.random_base32
78
+ self.otp_counter ||= 0
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,36 @@
1
+ module OTP
2
+ module JWT
3
+ # [ActionController] concern.
4
+ module ActionController
5
+ private
6
+
7
+ # Authenticates a model and responds with a [JWT] token
8
+ #
9
+ # @return [String] with authentication token and country shop ID.
10
+ def jwt_from_otp(model, otp)
11
+ # Send the OTP if the model is trying to authenticate.
12
+ if model.present? && otp.blank?
13
+ job = model.deliver_otp
14
+ return render(json: { job_id: job.job_id }, status: :bad_request)
15
+ elsif model.present? && otp.present? && !model.verify_otp(otp)
16
+ return head(:forbidden)
17
+ elsif model.blank?
18
+ return head(:forbidden)
19
+ end
20
+
21
+ return yield(model) if block_given?
22
+
23
+ render json: { token: model.to_jwt }, status: :created
24
+ end
25
+
26
+ # Extracts a token from the authorization header
27
+ #
28
+ # @return [String] the token present in the header or nothing.
29
+ def request_authorization_header
30
+ return if request.headers['Authorization'].blank?
31
+
32
+ request.headers['Authorization'].split.last
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,29 @@
1
+ require 'active_support/concern'
2
+
3
+ module OTP
4
+ module JWT
5
+ # [ActiveRecord] concern.
6
+ module ActiveRecord
7
+ extend ActiveSupport::Concern
8
+
9
+ class_methods do
10
+ # Returns a record based on the [JWT] token subject
11
+ #
12
+ # @param token [String] representing a [JWT] token
13
+ # @return [ActiveRecord::Base] model
14
+ def from_jwt(token)
15
+ OTP::JWT::Token.decode(token) do |payload|
16
+ self.find_by(id: payload['sub'])
17
+ end
18
+ end
19
+ end
20
+
21
+ # Returns a [JWT] token for this record
22
+ #
23
+ # @return [ActiveRecord::Base] model
24
+ def to_jwt
25
+ OTP::JWT::Token.sign(sub: self.id)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,27 @@
1
+ require 'json'
2
+
3
+ module OTP
4
+ module JWT
5
+ # Helpers to help you test the [JWT] requests.
6
+ module TestHelpers
7
+ # Helper to handle authentication requests easier
8
+ #
9
+ # @return [Hash] the authorization headers
10
+ def jwt_auth_header(entity_or_subject)
11
+ return {} unless entity_or_subject.present?
12
+
13
+ token = entity_or_subject.try(:to_jwt)
14
+ token ||= OTP::JWT::Token.sign(sub: entity_or_subject)
15
+
16
+ { 'Authorization': "Bearer #{token}" }
17
+ end
18
+
19
+ # Parses and returns a deserialized JSON
20
+ #
21
+ # @return [Hash]
22
+ def response_json
23
+ JSON.parse(response.body)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,69 @@
1
+ require 'jwt'
2
+ require 'active_support/configurable'
3
+
4
+ module OTP
5
+ module JWT
6
+ # A configurable set of token helpers to sign/verify an entity JWT token.
7
+ module Token
8
+ include ActiveSupport::Configurable
9
+
10
+ # Resolves possible JWT exception classes
11
+ #
12
+ # Can be removed once #255 is merged.
13
+ # See: https://github.com/jwt/ruby-jwt/pull/255
14
+ JWT_EXCEPTIONS = ::JWT.constants.map do |cname|
15
+ klass = ::JWT.const_get(cname)
16
+ klass if klass.is_a?(Class) && klass <= StandardError
17
+ end.compact
18
+
19
+ # The signature key used to sign the tokens.
20
+ config_accessor :jwt_signature_key, instance_accessor: false
21
+ # The signature key algorithm, defaults to HS256.
22
+ config_accessor(:jwt_algorithm, instance_accessor: false) { 'HS256' }
23
+ # The lifetime of the token, defaults to 1 day.
24
+ config_accessor(:jwt_lifetime, instance_accessor: false) { 60 * 60 * 24 }
25
+
26
+ # Generates a token based on a payload and optional overwritable claims
27
+ #
28
+ # @param payload [Hash], data to be encoded into the token.
29
+ # @param claims [Hash], extra claims to be encoded into the token.
30
+ #
31
+ # @return [String], a JWT token
32
+ def self.sign(payload, claims = {})
33
+ payload = payload.merge(claims)
34
+ claims[:exp] ||= self.jwt_lifetime if self.jwt_lifetime.present?
35
+
36
+ ::JWT.encode(payload, self.jwt_signature_key, self.jwt_algorithm)
37
+ end
38
+
39
+ # Verifies and returns decoded token data upon success
40
+ #
41
+ # @param token [String], token to be decoded.
42
+ # @param options [Hash], extra options to be used during verification.
43
+ #
44
+ # @return [Hash], JWT token payload
45
+ def self.verify(token, options = {})
46
+ lifetime = self.jwt_lifetime
47
+ opts = {}.merge(options)
48
+ opts[:verify_expiration] ||= lifetime if lifetime.present?
49
+
50
+ ::JWT.decode(token.to_s, self.jwt_signature_key, true, opts)
51
+ end
52
+
53
+ # Decodes a valid token into [Hash]
54
+ #
55
+ # Requires a block, yields JWT data. Will catch any JWT exception.
56
+ #
57
+ # @param token [String], token to be decoded.
58
+ # @param options [Hash], extra options to be used during verification.
59
+ # @return [Hash] upon success
60
+ def self.decode(token, options = nil)
61
+ return unless block_given?
62
+ verified, _ = self.verify(token, options || {})
63
+
64
+ yield verified
65
+ rescue *JWT_EXCEPTIONS
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,5 @@
1
+ module OTP
2
+ module JWT
3
+ VERSION = '0.1.0'
4
+ end
5
+ end
data/lib/otp/jwt.rb ADDED
@@ -0,0 +1,10 @@
1
+ require_relative 'jwt/token'
2
+ require_relative 'jwt/active_record'
3
+ require_relative 'jwt/action_controller'
4
+
5
+ module OTP
6
+ # JWT
7
+ module JWT
8
+ # ...
9
+ end
10
+ end
@@ -0,0 +1,6 @@
1
+ Hi there 👋,
2
+
3
+ Please use this magic password to access your account:
4
+ <%= @otp_code %>
5
+
6
+ Thank you!
data/lib/otp/mailer.rb ADDED
@@ -0,0 +1,25 @@
1
+ require 'action_mailer/railtie'
2
+ require_relative '../otp'
3
+
4
+ module OTP
5
+ # OTP mailer.
6
+ class Mailer < ActionMailer::Base
7
+ append_view_path(OTP::PATH)
8
+
9
+ default subject: 'Your magic password 🗝️'
10
+
11
+ # Sends an email containing the OTP
12
+ #
13
+ # @param email [String] the email address to send to
14
+ # @param otp_code [String] the OTP code to include
15
+ # @param model [ActiveRecord::Base] model to expose
16
+ # @param mail_opts [Hash] arbitrary options to pass to `mail()` method
17
+ # @return [Mail] instance
18
+ def otp(email, otp_code, model, mail_opts = {})
19
+ @model = model
20
+ @otp_code = otp_code
21
+
22
+ mail(to: email, **mail_opts)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,28 @@
1
+ require 'active_job/railtie'
2
+ begin
3
+ require 'aws-sdk-sns'
4
+ rescue LoadError => err
5
+ (Rails.logger || Logger.new(STDOUT)).error(err)
6
+ end
7
+
8
+ module OTP
9
+ # Uses the AWS SNS API to send the OTP SMS message.
10
+ class SMSOTPJob < ActiveJob::Base
11
+ # A generic template for the message body.
12
+ TEMPLATE = '%{otp} is your magic password 🗝️'
13
+ # Indicates if the messaging is disabled. Handy for testing purposes.
14
+ ENABLED = true
15
+
16
+ # Sends the SMS message with the OTP code
17
+ #
18
+ # @return nil
19
+ def perform(phone_number, otp_code, template = TEMPLATE)
20
+ message = template % { otp: otp_code }
21
+
22
+ Aws::SNS::Client.new(region: ENV['AWS_SMS_REGION']).publish(
23
+ message: message,
24
+ phone_number: phone_number
25
+ ) if ENABLED
26
+ end
27
+ end
28
+ end
data/lib/otp.rb ADDED
@@ -0,0 +1,6 @@
1
+ require_relative 'otp/active_record'
2
+
3
+ # OTP
4
+ module OTP
5
+ PATH = File.dirname(__FILE__)
6
+ end
data/otp-jwt.gemspec ADDED
@@ -0,0 +1,36 @@
1
+ lib = File.expand_path('../lib', __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+
4
+ require 'otp/jwt/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'otp-jwt'
8
+ spec.version = OTP::JWT::VERSION
9
+ spec.authors = ['Stas Suscov']
10
+ spec.email = ['stas@nerd.ro']
11
+
12
+ spec.summary = 'Passwordless HTTP APIs'
13
+ spec.description = 'OTP (email, SMS) JWT authentication for HTTP APIs.'
14
+ spec.homepage = 'https://github.com/stas/otp-jwt'
15
+ spec.license = 'TBD'
16
+
17
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
18
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(spec)/}) }
19
+ end
20
+ spec.require_paths = ['lib']
21
+
22
+ spec.add_dependency 'activesupport'
23
+ spec.add_dependency 'jwt', '~> 2.1'
24
+ spec.add_dependency 'rotp', '~> 4.1'
25
+
26
+ spec.add_development_dependency 'bundler'
27
+ spec.add_development_dependency 'ffaker'
28
+ spec.add_development_dependency 'rails'
29
+ spec.add_development_dependency 'rspec-rails'
30
+ spec.add_development_dependency 'rubocop-performance'
31
+ spec.add_development_dependency 'rubocop-rails_config'
32
+ spec.add_development_dependency 'rubocop-rspec'
33
+ spec.add_development_dependency 'simplecov'
34
+ spec.add_development_dependency 'sqlite3', '~> 1.3.6'
35
+ spec.add_development_dependency 'yardstick'
36
+ end
metadata ADDED
@@ -0,0 +1,247 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: otp-jwt
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Stas Suscov
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2019-03-31 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: jwt
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rotp
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '4.1'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '4.1'
55
+ - !ruby/object:Gem::Dependency
56
+ name: bundler
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: ffaker
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rails
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rspec-rails
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rubocop-performance
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: rubocop-rails_config
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ - !ruby/object:Gem::Dependency
140
+ name: rubocop-rspec
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
153
+ - !ruby/object:Gem::Dependency
154
+ name: simplecov
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
167
+ - !ruby/object:Gem::Dependency
168
+ name: sqlite3
169
+ requirement: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - "~>"
172
+ - !ruby/object:Gem::Version
173
+ version: 1.3.6
174
+ type: :development
175
+ prerelease: false
176
+ version_requirements: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - "~>"
179
+ - !ruby/object:Gem::Version
180
+ version: 1.3.6
181
+ - !ruby/object:Gem::Dependency
182
+ name: yardstick
183
+ requirement: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - ">="
186
+ - !ruby/object:Gem::Version
187
+ version: '0'
188
+ type: :development
189
+ prerelease: false
190
+ version_requirements: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - ">="
193
+ - !ruby/object:Gem::Version
194
+ version: '0'
195
+ description: OTP (email, SMS) JWT authentication for HTTP APIs.
196
+ email:
197
+ - stas@nerd.ro
198
+ executables: []
199
+ extensions: []
200
+ extra_rdoc_files: []
201
+ files:
202
+ - ".github/main.workflow"
203
+ - ".gitignore"
204
+ - ".rspec"
205
+ - ".rubocop.yml"
206
+ - ".yardstick.yml"
207
+ - Gemfile
208
+ - Gemfile.lock
209
+ - README.md
210
+ - Rakefile
211
+ - lib/otp.rb
212
+ - lib/otp/active_record.rb
213
+ - lib/otp/jwt.rb
214
+ - lib/otp/jwt/action_controller.rb
215
+ - lib/otp/jwt/active_record.rb
216
+ - lib/otp/jwt/test_helpers.rb
217
+ - lib/otp/jwt/token.rb
218
+ - lib/otp/jwt/version.rb
219
+ - lib/otp/mailer.rb
220
+ - lib/otp/mailer/otp.text.erb
221
+ - lib/otp/sms_otp_job.rb
222
+ - otp-jwt.gemspec
223
+ homepage: https://github.com/stas/otp-jwt
224
+ licenses:
225
+ - TBD
226
+ metadata: {}
227
+ post_install_message:
228
+ rdoc_options: []
229
+ require_paths:
230
+ - lib
231
+ required_ruby_version: !ruby/object:Gem::Requirement
232
+ requirements:
233
+ - - ">="
234
+ - !ruby/object:Gem::Version
235
+ version: '0'
236
+ required_rubygems_version: !ruby/object:Gem::Requirement
237
+ requirements:
238
+ - - ">="
239
+ - !ruby/object:Gem::Version
240
+ version: '0'
241
+ requirements: []
242
+ rubyforge_project:
243
+ rubygems_version: 2.5.2.2
244
+ signing_key:
245
+ specification_version: 4
246
+ summary: Passwordless HTTP APIs
247
+ test_files: []