otp-jwt 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []