otp-jwt 0.2.4 → 0.3.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 +5 -5
- data/LICENSE.txt +21 -0
- data/README.md +34 -1
- data/lib/otp/active_record.rb +2 -2
- data/lib/otp/jwt/action_controller.rb +0 -1
- data/lib/otp/jwt/active_record.rb +21 -4
- data/lib/otp/jwt/test_helpers.rb +1 -1
- data/lib/otp/jwt/token.rb +4 -1
- data/lib/otp/jwt/version.rb +1 -1
- data/spec/dummy.rb +83 -0
- data/spec/otp/jwt/token_spec.rb +98 -0
- data/spec/spec_helper.rb +71 -0
- data/spec/tokens_controller_spec.rb +50 -0
- data/spec/user_spec.rb +63 -0
- data/spec/users_controller_spec.rb +41 -0
- metadata +46 -20
- data/.github/main.workflow +0 -44
- data/.gitignore +0 -4
- data/.rspec +0 -3
- data/.rubocop.yml +0 -46
- data/.yardstick.yml +0 -29
- data/Gemfile +0 -7
- data/Rakefile +0 -30
- data/otp-jwt.gemspec +0 -36
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 4e4d38ef8b50541c6606645a9f75229e2599e47ec92323637886532853da6baa
|
4
|
+
data.tar.gz: 582df0752fead7e8b96615107a3d2db80a68d87b4fa2d533d6eaf9e2b7aaf035
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 89b0dc892cc73c5a863084a2017f1a23d621cd026a63c75256bb9be6642a909c03ed91be033e74fa28643af2453e662b12eb9ca3a0cbc35530de167348895fe6
|
7
|
+
data.tar.gz: 9e5b016cd192f8d5274257b1173a44868a5378b1cbb05f8e4de93136be887df6a623956054d756be6bd60c1fc30406d818713fee1285775bce5b2e39f990435b
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2021 Stas Suscov
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
CHANGED
@@ -10,6 +10,8 @@ One time password (email, SMS) authentication support for HTTP APIs.
|
|
10
10
|
This project provides a couple of mixins to help you build
|
11
11
|
applications/HTTP APIs without asking your users to provide passwords.
|
12
12
|
|
13
|
+
[Your browser probably can work seamlessly with OTPs](https://web.dev/web-otp/)!!! :heart_eyes:
|
14
|
+
|
13
15
|
## About
|
14
16
|
|
15
17
|
The goal of this project is to provide support for one time passwords
|
@@ -38,6 +40,16 @@ and [JWT](https://github.com/jwt/ruby-jwt/).
|
|
38
40
|
|
39
41
|
Thanks to everyone who worked on these amazing projects!
|
40
42
|
|
43
|
+
|
44
|
+
## Sponsors
|
45
|
+
|
46
|
+
I'm grateful for the following companies for supporting this project!
|
47
|
+
|
48
|
+
<p align="center">
|
49
|
+
<a href="https://www.luneteyewear.com"><img src="https://user-images.githubusercontent.com/112147/136836142-2bfba96e-447f-4eb6-b137-2445aee81b37.png"/></a>
|
50
|
+
<a href="https://www.startuplandia.io"><img src="https://user-images.githubusercontent.com/112147/136836147-93f8ab17-2465-4477-a7ab-e38255483c66.png"/></a>
|
51
|
+
</p>
|
52
|
+
|
41
53
|
## Installation
|
42
54
|
|
43
55
|
Add this line to your application's Gemfile:
|
@@ -75,6 +87,12 @@ require 'otp'
|
|
75
87
|
# To load the JWT related support.
|
76
88
|
require 'otp/jwt'
|
77
89
|
|
90
|
+
# Set to 'none' to disable verification at all.
|
91
|
+
# OTP::JWT::Token.jwt_algorithm = 'HS256'
|
92
|
+
|
93
|
+
# How long the token will be valid.
|
94
|
+
# OTP::JWT::Token.jwt_lifetime = 60 * 60 * 24
|
95
|
+
|
78
96
|
OTP::JWT::Token.jwt_signature_key = ENV['YOUR-SIGN-KEY']
|
79
97
|
```
|
80
98
|
### OTP for Active Record models
|
@@ -97,12 +115,26 @@ one time passwords:
|
|
97
115
|
This concern expects two attributes to be provided by the model, the:
|
98
116
|
* `otp_secret`: of type string, used to store the OTP signature key
|
99
117
|
* `otp_counter`: of type integer, used to store the OTP counter
|
118
|
+
* `expire_jwt_at`: of type datetime, **optional** and used to force a token to expire
|
100
119
|
|
101
120
|
A migration to add these two looks like this:
|
102
121
|
```
|
103
122
|
$ rails g migration add_otp_to_users otp_secret:string otp_counter:integer
|
104
123
|
```
|
105
124
|
|
125
|
+
Generate `opt_secret` by running the following in rails console if you have preexisting user data:
|
126
|
+
```
|
127
|
+
User.all.each do |u|
|
128
|
+
u.save()
|
129
|
+
end
|
130
|
+
```
|
131
|
+
|
132
|
+
##### Force a token to expire
|
133
|
+
|
134
|
+
If there's an `expire_jwt_at` value that is in the past, the user token will
|
135
|
+
be reset and it will require a new authentication to receive a working token.
|
136
|
+
|
137
|
+
This is handy if the user access needs to be scheduled and/or removed.
|
106
138
|
#### Mailer support
|
107
139
|
|
108
140
|
You can use the built-in mailer to deliver the OTP, just require it and
|
@@ -296,4 +328,5 @@ contributors are expected to adhere to the
|
|
296
328
|
|
297
329
|
## License
|
298
330
|
|
299
|
-
|
331
|
+
The gem is available as open source under the terms of the
|
332
|
+
[MIT License](https://opensource.org/licenses/MIT).
|
data/lib/otp/active_record.rb
CHANGED
@@ -36,7 +36,8 @@ module OTP
|
|
36
36
|
def verify_otp(otp)
|
37
37
|
return nil if !valid? || !persisted? || otp_secret.blank?
|
38
38
|
|
39
|
-
|
39
|
+
otp_digits = self.class.const_get(:OTP_DIGITS)
|
40
|
+
hotp = ROTP::HOTP.new(otp_secret, digits: otp_digits)
|
40
41
|
transaction do
|
41
42
|
otp_status = hotp.verify(otp.to_s, otp_counter)
|
42
43
|
increment!(:otp_counter)
|
@@ -72,7 +73,6 @@ module OTP
|
|
72
73
|
end
|
73
74
|
|
74
75
|
private
|
75
|
-
|
76
76
|
# Provides a default value for the OTP secret attribute
|
77
77
|
#
|
78
78
|
# @return [String]
|
@@ -17,13 +17,19 @@ module OTP
|
|
17
17
|
val = payload[claim_name]
|
18
18
|
pk_col = self.column_for_attribute(self.primary_key)
|
19
19
|
|
20
|
-
|
21
|
-
#
|
22
|
-
|
20
|
+
|
21
|
+
# Arel casts the values to the primary key type,
|
22
|
+
# which means that an UUID becomes an integer by default...
|
23
|
+
if self.connection.respond_to?(:lookup_cast_type_from_column)
|
24
|
+
pk_type = self.connection.lookup_cast_type_from_column(pk_col)
|
25
|
+
casted_val = pk_type.serialize(val)
|
26
|
+
else
|
27
|
+
casted_val = self.connection.type_cast(val, pk_col)
|
28
|
+
end
|
23
29
|
|
24
30
|
return if casted_val.to_s != val.to_s.strip
|
25
31
|
|
26
|
-
self.find_by(self.primary_key => val)
|
32
|
+
self.find_by(self.primary_key => val).expire_jwt?
|
27
33
|
end
|
28
34
|
end
|
29
35
|
end
|
@@ -38,6 +44,17 @@ module OTP
|
|
38
44
|
**(claims || {})
|
39
45
|
)
|
40
46
|
end
|
47
|
+
|
48
|
+
# Reset the [JWT] token if it is set to expire
|
49
|
+
#
|
50
|
+
# This method allows you to expire any token, independently from
|
51
|
+
# the JWT flags/payload.
|
52
|
+
#
|
53
|
+
# @return nil if the expiration worked, otherwise returns the model
|
54
|
+
def expire_jwt?
|
55
|
+
return self unless self.respond_to?(:expire_jwt_at)
|
56
|
+
return self unless expire_jwt_at? && expire_jwt_at.past?
|
57
|
+
end
|
41
58
|
end
|
42
59
|
end
|
43
60
|
end
|
data/lib/otp/jwt/test_helpers.rb
CHANGED
@@ -15,7 +15,7 @@ module OTP
|
|
15
15
|
#
|
16
16
|
# @return [Hash] the authorization headers
|
17
17
|
def jwt_auth_header(entity_or_subject)
|
18
|
-
return json_headers
|
18
|
+
return json_headers if entity_or_subject.blank?
|
19
19
|
|
20
20
|
token = entity_or_subject.try(:to_jwt)
|
21
21
|
token ||= OTP::JWT::Token.sign(sub: entity_or_subject)
|
data/lib/otp/jwt/token.rb
CHANGED
@@ -37,7 +37,10 @@ module OTP
|
|
37
37
|
#
|
38
38
|
# @return [Hash], JWT token payload
|
39
39
|
def self.verify(token, opts = nil)
|
40
|
-
|
40
|
+
verify = self.jwt_algorithm != 'none'
|
41
|
+
opts ||= { algorithm: self.jwt_algorithm }
|
42
|
+
|
43
|
+
::JWT.decode(token.to_s, self.jwt_signature_key, verify, opts)
|
41
44
|
end
|
42
45
|
|
43
46
|
# Decodes a valid token into [Hash]
|
data/lib/otp/jwt/version.rb
CHANGED
data/spec/dummy.rb
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
require 'active_record/railtie'
|
2
|
+
require 'action_controller/railtie'
|
3
|
+
require 'global_id/railtie'
|
4
|
+
require 'otp/mailer'
|
5
|
+
|
6
|
+
class Dummy < Rails::Application
|
7
|
+
secrets.secret_key_base = '_'
|
8
|
+
|
9
|
+
config.hosts << 'www.example.com' if config.respond_to?(:hosts)
|
10
|
+
|
11
|
+
config.logger = Logger.new($stdout)
|
12
|
+
config.logger.level = ENV['LOG_LEVEL'] || Logger::WARN
|
13
|
+
|
14
|
+
routes.draw do
|
15
|
+
resources :users, only: [:index]
|
16
|
+
resources :tokens, only: [:create]
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
GlobalID.app = Dummy
|
21
|
+
Rails.logger = Dummy.config.logger
|
22
|
+
ActiveRecord::Base.logger = Dummy.config.logger
|
23
|
+
ActiveRecord::Base.establish_connection(
|
24
|
+
ENV['DATABASE_URL'] || 'sqlite3::memory:'
|
25
|
+
)
|
26
|
+
|
27
|
+
ActiveRecord::Schema.define do
|
28
|
+
create_table :users, force: true do |t|
|
29
|
+
t.string :email
|
30
|
+
t.string :full_name
|
31
|
+
t.string :phone_number
|
32
|
+
t.string :otp_secret
|
33
|
+
t.integer :otp_counter
|
34
|
+
t.timestamp :last_login_at
|
35
|
+
t.timestamp :expire_jwt_at
|
36
|
+
t.timestamps
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
class User < ActiveRecord::Base
|
41
|
+
include GlobalID::Identification
|
42
|
+
include OTP::ActiveRecord
|
43
|
+
include OTP::JWT::ActiveRecord
|
44
|
+
|
45
|
+
def email_otp
|
46
|
+
OTP::Mailer.otp(email, otp, self).deliver_later
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
class ApplicationController < ActionController::Base
|
51
|
+
include OTP::JWT::ActionController
|
52
|
+
|
53
|
+
private
|
54
|
+
def current_user
|
55
|
+
@jwt_user ||= User.from_jwt(request_authorization_header)
|
56
|
+
end
|
57
|
+
|
58
|
+
def current_user!
|
59
|
+
current_user || raise('User authentication failed')
|
60
|
+
rescue
|
61
|
+
head(:unauthorized)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
class UsersController < ApplicationController
|
66
|
+
before_action :current_user!
|
67
|
+
|
68
|
+
def index
|
69
|
+
render json: current_user
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
class TokensController < ApplicationController
|
74
|
+
def create
|
75
|
+
user = User.find_by(email: params[:email])
|
76
|
+
|
77
|
+
jwt_from_otp(user, params[:otp]) do |auth_user|
|
78
|
+
auth_user.update_column(:last_login_at, DateTime.current)
|
79
|
+
|
80
|
+
render json: { token: auth_user.to_jwt }, status: :created
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
RSpec.describe OTP::JWT::Token, type: :model do
|
4
|
+
let(:payload) { { 'sub' => FFaker::Internet.password } }
|
5
|
+
let(:token) do
|
6
|
+
JWT.encode(
|
7
|
+
payload.dup.merge(exp: Time.now.to_i + described_class.jwt_lifetime),
|
8
|
+
described_class.jwt_signature_key,
|
9
|
+
described_class.jwt_algorithm
|
10
|
+
)
|
11
|
+
end
|
12
|
+
|
13
|
+
describe '#sign' do
|
14
|
+
it { expect(described_class.sign(payload)).to eq(token) }
|
15
|
+
|
16
|
+
context 'with the none algorithm' do
|
17
|
+
before do
|
18
|
+
OTP::JWT::Token.jwt_algorithm = 'none'
|
19
|
+
end
|
20
|
+
|
21
|
+
after do
|
22
|
+
OTP::JWT::Token.jwt_algorithm = 'HS256'
|
23
|
+
end
|
24
|
+
|
25
|
+
it { expect(described_class.sign(payload)).to eq(token) }
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
describe '#verify' do
|
30
|
+
it do
|
31
|
+
expect(described_class.verify(token).first).to include(payload)
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'with a bad token' do
|
35
|
+
expect { described_class.verify(FFaker::Internet.password) }
|
36
|
+
.to raise_error(JWT::DecodeError)
|
37
|
+
end
|
38
|
+
|
39
|
+
it 'with an expired token' do
|
40
|
+
token = OTP::JWT::Token.sign(
|
41
|
+
sub: FFaker::Internet.password, exp: DateTime.now.to_i
|
42
|
+
)
|
43
|
+
expect { described_class.verify(token) }
|
44
|
+
.to raise_error(JWT::ExpiredSignature)
|
45
|
+
end
|
46
|
+
|
47
|
+
context 'with an RSA key' do
|
48
|
+
before do
|
49
|
+
OTP::JWT::Token.jwt_signature_key = OpenSSL::PKey::RSA.new(2048)
|
50
|
+
OTP::JWT::Token.jwt_algorithm = 'RS256'
|
51
|
+
end
|
52
|
+
|
53
|
+
after do
|
54
|
+
OTP::JWT::Token.jwt_signature_key = '_'
|
55
|
+
OTP::JWT::Token.jwt_algorithm = 'HS256'
|
56
|
+
end
|
57
|
+
|
58
|
+
it do
|
59
|
+
expect(described_class.verify(token).first).to include(payload)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
describe '#decode' do
|
65
|
+
let(:user) { create_user }
|
66
|
+
let(:payload) { { 'sub' => user.id } }
|
67
|
+
|
68
|
+
it do
|
69
|
+
expect(
|
70
|
+
described_class.decode(token) { |p| User.find(p['sub']) }
|
71
|
+
).to eq(user)
|
72
|
+
end
|
73
|
+
|
74
|
+
context 'with a bad token' do
|
75
|
+
let(:token) { FFaker::Internet.password }
|
76
|
+
|
77
|
+
it do
|
78
|
+
expect(described_class.decode(token)).to eq(nil)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
context 'with the none algorithm' do
|
83
|
+
before do
|
84
|
+
OTP::JWT::Token.jwt_algorithm = 'none'
|
85
|
+
end
|
86
|
+
|
87
|
+
after do
|
88
|
+
OTP::JWT::Token.jwt_algorithm = 'HS256'
|
89
|
+
end
|
90
|
+
|
91
|
+
it do
|
92
|
+
expect(
|
93
|
+
described_class.decode(token) { |p| User.find(p['sub']) }
|
94
|
+
).to eq(user)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,71 @@
|
|
1
|
+
require 'bundler/setup'
|
2
|
+
require 'simplecov'
|
3
|
+
|
4
|
+
SimpleCov.start do
|
5
|
+
add_group 'Lib', 'lib'
|
6
|
+
add_group 'Tests', 'spec'
|
7
|
+
end
|
8
|
+
SimpleCov.minimum_coverage 90
|
9
|
+
|
10
|
+
require 'otp'
|
11
|
+
require 'otp/jwt'
|
12
|
+
require 'otp/jwt/test_helpers'
|
13
|
+
require_relative 'dummy'
|
14
|
+
require 'ffaker'
|
15
|
+
require 'rspec/rails'
|
16
|
+
|
17
|
+
OTP::JWT::Token.jwt_signature_key = '_'
|
18
|
+
OTP::Mailer.default from: '_'
|
19
|
+
ActiveJob::Base.queue_adapter = :test
|
20
|
+
ActionMailer::Base.delivery_method = :test
|
21
|
+
|
22
|
+
module OTP::JWT::FactoryHelpers
|
23
|
+
# Creates an user
|
24
|
+
#
|
25
|
+
# @return [User]
|
26
|
+
def create_user(attrs = {})
|
27
|
+
User.create!(
|
28
|
+
full_name: FFaker::Name.name,
|
29
|
+
email: FFaker::Internet.email,
|
30
|
+
phone_number: FFaker::PhoneNumber.phone_number,
|
31
|
+
**attrs
|
32
|
+
)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
module Rails4RequestMethods
|
37
|
+
[:get, :post, :put, :delete].each do |method_name|
|
38
|
+
define_method(method_name) do |path, named_args|
|
39
|
+
super(
|
40
|
+
path,
|
41
|
+
named_args.delete(:params),
|
42
|
+
named_args.delete(:headers)
|
43
|
+
)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
RSpec.configure do |config|
|
49
|
+
config.use_transactional_fixtures = true
|
50
|
+
config.mock_with :rspec
|
51
|
+
config.filter_run_when_matching :focus
|
52
|
+
config.disable_monkey_patching!
|
53
|
+
|
54
|
+
config.expect_with :rspec do |c|
|
55
|
+
c.syntax = :expect
|
56
|
+
end
|
57
|
+
|
58
|
+
config.include OTP::JWT::TestHelpers, type: :model
|
59
|
+
config.include OTP::JWT::FactoryHelpers, type: :model
|
60
|
+
config.include ActiveJob::TestHelper, type: :model
|
61
|
+
|
62
|
+
config.include OTP::JWT::TestHelpers, type: :request
|
63
|
+
config.include OTP::JWT::FactoryHelpers, type: :request
|
64
|
+
config.include ActiveJob::TestHelper, type: :request
|
65
|
+
config.include Dummy.routes.url_helpers, type: :request
|
66
|
+
|
67
|
+
if ::Rails::VERSION::MAJOR == 4
|
68
|
+
config.include Rails4RequestMethods, type: :request
|
69
|
+
config.include Rails4RequestMethods, type: :controller
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
RSpec.describe TokensController, type: :request do
|
4
|
+
let(:user) { create_user }
|
5
|
+
let(:params) { }
|
6
|
+
|
7
|
+
around do |examp|
|
8
|
+
perform_enqueued_jobs(&examp)
|
9
|
+
end
|
10
|
+
|
11
|
+
before do
|
12
|
+
ActionMailer::Base.deliveries.clear
|
13
|
+
ActiveJob::Base.queue_adapter.performed_jobs.clear
|
14
|
+
post(tokens_path, params: params.to_json, headers: json_headers)
|
15
|
+
end
|
16
|
+
|
17
|
+
it { expect(response).to have_http_status(:forbidden) }
|
18
|
+
|
19
|
+
context 'with good email and no otp' do
|
20
|
+
let(:params) { { email: user.email } }
|
21
|
+
|
22
|
+
it do
|
23
|
+
expect(response).to have_http_status(:bad_request)
|
24
|
+
|
25
|
+
mail = ActionMailer::Base.deliveries.last
|
26
|
+
expect(mail.subject).to eq(OTP::Mailer.default[:subject])
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
context 'with good email and bad otp' do
|
31
|
+
let(:params) { { email: user.email, otp: FFaker::Internet.password } }
|
32
|
+
|
33
|
+
it do
|
34
|
+
expect(response).to have_http_status(:forbidden)
|
35
|
+
expect(ActionMailer::Base.deliveries.size).to eq(0)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
context 'with good email and good otp' do
|
40
|
+
let(:params) { { email: user.email, otp: user.otp } }
|
41
|
+
|
42
|
+
it do
|
43
|
+
expect(response).to have_http_status(:created)
|
44
|
+
expect(User.from_jwt(response_json['token'])).to eq(user)
|
45
|
+
expect(ActionMailer::Base.deliveries.size).to eq(0)
|
46
|
+
|
47
|
+
expect(user.reload.last_login_at).not_to be_blank
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
data/spec/user_spec.rb
ADDED
@@ -0,0 +1,63 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
RSpec.describe User, type: :model do
|
4
|
+
let(:user) { create_user }
|
5
|
+
|
6
|
+
it { expect(User.new.otp_secret).not_to be_blank }
|
7
|
+
it { expect(User.new.deliver_otp).to be_blank }
|
8
|
+
it { expect(User.new.otp).to be_blank }
|
9
|
+
|
10
|
+
describe '#from_jwt' do
|
11
|
+
let(:token) { user.to_jwt }
|
12
|
+
|
13
|
+
it do
|
14
|
+
expect(User.from_jwt(token)).to eq(user)
|
15
|
+
end
|
16
|
+
|
17
|
+
context 'with a cast-able subject value' do
|
18
|
+
let(:token) { OTP::JWT::Token.sign(sub: user.id.to_s + '_text') }
|
19
|
+
|
20
|
+
it do
|
21
|
+
expect(User.from_jwt(token)).to be_nil
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
context 'with a custom claim name' do
|
26
|
+
let(:claim_value) { FFaker::Internet.password }
|
27
|
+
let(:token) { user.to_jwt(my_claim_name: claim_value) }
|
28
|
+
|
29
|
+
it do
|
30
|
+
expect(OTP::JWT::Token.decode(token)['my_claim_name'])
|
31
|
+
.to eq(claim_value)
|
32
|
+
expect(User.from_jwt(token, 'my_claim_name')).to be_nil
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
describe '#otp' do
|
38
|
+
it do
|
39
|
+
expect { user.otp }.to change(user, :otp_counter).by(1)
|
40
|
+
end
|
41
|
+
|
42
|
+
context 'without a secret' do
|
43
|
+
it do
|
44
|
+
user.update_column(:otp_secret, nil)
|
45
|
+
expect(user.otp).to be_nil
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
describe '#verify_otp' do
|
51
|
+
it 'increments the otp counter after verification' do
|
52
|
+
expect(user.verify_otp(user.otp)).to be_truthy
|
53
|
+
expect { user.verify_otp(user.otp) }.to change(user, :otp_counter).by(2)
|
54
|
+
end
|
55
|
+
|
56
|
+
context 'without a secret' do
|
57
|
+
it do
|
58
|
+
user.update_column(:otp_secret, nil)
|
59
|
+
expect(user.verify_otp(rand(1000..2000).to_s)).to be_nil
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
RSpec.describe UsersController, type: :request do
|
4
|
+
let(:user) { nil }
|
5
|
+
|
6
|
+
before do
|
7
|
+
get(users_path, headers: jwt_auth_header(user))
|
8
|
+
end
|
9
|
+
|
10
|
+
it { expect(response).to have_http_status(:unauthorized) }
|
11
|
+
|
12
|
+
context 'with known subject token' do
|
13
|
+
let(:user) { create_user }
|
14
|
+
|
15
|
+
it { expect(response).to have_http_status(:ok) }
|
16
|
+
|
17
|
+
context 'with the expiration for JWT in future' do
|
18
|
+
let(:user) { create_user(expire_jwt_at: DateTime.tomorrow) }
|
19
|
+
|
20
|
+
it do
|
21
|
+
expect(response).to have_http_status(:ok)
|
22
|
+
expect(user.reload.expire_jwt_at).not_to be_nil
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
context 'with the expiration past for JWT' do
|
27
|
+
let(:user) { create_user(expire_jwt_at: DateTime.yesterday) }
|
28
|
+
|
29
|
+
it do
|
30
|
+
expect(response).to have_http_status(:unauthorized)
|
31
|
+
expect(user.reload.expire_jwt_at).not_to be_nil
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
context 'with bad subject' do
|
37
|
+
let(:user) { FFaker::Internet.password }
|
38
|
+
|
39
|
+
it { expect(response).to have_http_status(:unauthorized) }
|
40
|
+
end
|
41
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: otp-jwt
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Stas Suscov
|
8
|
-
autorequire:
|
8
|
+
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2021-10-11 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activesupport
|
@@ -30,28 +30,28 @@ dependencies:
|
|
30
30
|
requirements:
|
31
31
|
- - "~>"
|
32
32
|
- !ruby/object:Gem::Version
|
33
|
-
version: 2
|
33
|
+
version: '2'
|
34
34
|
type: :runtime
|
35
35
|
prerelease: false
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
37
37
|
requirements:
|
38
38
|
- - "~>"
|
39
39
|
- !ruby/object:Gem::Version
|
40
|
-
version: 2
|
40
|
+
version: '2'
|
41
41
|
- !ruby/object:Gem::Dependency
|
42
42
|
name: rotp
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
44
44
|
requirements:
|
45
45
|
- - "~>"
|
46
46
|
- !ruby/object:Gem::Version
|
47
|
-
version: '
|
47
|
+
version: '6'
|
48
48
|
type: :runtime
|
49
49
|
prerelease: false
|
50
50
|
version_requirements: !ruby/object:Gem::Requirement
|
51
51
|
requirements:
|
52
52
|
- - "~>"
|
53
53
|
- !ruby/object:Gem::Version
|
54
|
-
version: '
|
54
|
+
version: '6'
|
55
55
|
- !ruby/object:Gem::Dependency
|
56
56
|
name: bundler
|
57
57
|
requirement: !ruby/object:Gem::Requirement
|
@@ -108,6 +108,20 @@ dependencies:
|
|
108
108
|
- - ">="
|
109
109
|
- !ruby/object:Gem::Version
|
110
110
|
version: '0'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: rubocop
|
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'
|
111
125
|
- !ruby/object:Gem::Dependency
|
112
126
|
name: rubocop-performance
|
113
127
|
requirement: !ruby/object:Gem::Requirement
|
@@ -178,6 +192,20 @@ dependencies:
|
|
178
192
|
- - ">="
|
179
193
|
- !ruby/object:Gem::Version
|
180
194
|
version: '0'
|
195
|
+
- !ruby/object:Gem::Dependency
|
196
|
+
name: tzinfo-data
|
197
|
+
requirement: !ruby/object:Gem::Requirement
|
198
|
+
requirements:
|
199
|
+
- - ">="
|
200
|
+
- !ruby/object:Gem::Version
|
201
|
+
version: '0'
|
202
|
+
type: :development
|
203
|
+
prerelease: false
|
204
|
+
version_requirements: !ruby/object:Gem::Requirement
|
205
|
+
requirements:
|
206
|
+
- - ">="
|
207
|
+
- !ruby/object:Gem::Version
|
208
|
+
version: '0'
|
181
209
|
- !ruby/object:Gem::Dependency
|
182
210
|
name: yardstick
|
183
211
|
requirement: !ruby/object:Gem::Requirement
|
@@ -199,14 +227,8 @@ executables: []
|
|
199
227
|
extensions: []
|
200
228
|
extra_rdoc_files: []
|
201
229
|
files:
|
202
|
-
-
|
203
|
-
- ".gitignore"
|
204
|
-
- ".rspec"
|
205
|
-
- ".rubocop.yml"
|
206
|
-
- ".yardstick.yml"
|
207
|
-
- Gemfile
|
230
|
+
- LICENSE.txt
|
208
231
|
- README.md
|
209
|
-
- Rakefile
|
210
232
|
- lib/otp.rb
|
211
233
|
- lib/otp/active_record.rb
|
212
234
|
- lib/otp/jwt.rb
|
@@ -218,12 +240,17 @@ files:
|
|
218
240
|
- lib/otp/mailer.rb
|
219
241
|
- lib/otp/mailer/otp.text.erb
|
220
242
|
- lib/otp/sms_job.rb
|
221
|
-
-
|
243
|
+
- spec/dummy.rb
|
244
|
+
- spec/otp/jwt/token_spec.rb
|
245
|
+
- spec/spec_helper.rb
|
246
|
+
- spec/tokens_controller_spec.rb
|
247
|
+
- spec/user_spec.rb
|
248
|
+
- spec/users_controller_spec.rb
|
222
249
|
homepage: https://github.com/stas/otp-jwt
|
223
250
|
licenses:
|
224
|
-
-
|
251
|
+
- MIT
|
225
252
|
metadata: {}
|
226
|
-
post_install_message:
|
253
|
+
post_install_message:
|
227
254
|
rdoc_options: []
|
228
255
|
require_paths:
|
229
256
|
- lib
|
@@ -238,9 +265,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
238
265
|
- !ruby/object:Gem::Version
|
239
266
|
version: '0'
|
240
267
|
requirements: []
|
241
|
-
|
242
|
-
|
243
|
-
signing_key:
|
268
|
+
rubygems_version: 3.2.22
|
269
|
+
signing_key:
|
244
270
|
specification_version: 4
|
245
271
|
summary: Passwordless HTTP APIs
|
246
272
|
test_files: []
|
data/.github/main.workflow
DELETED
@@ -1,44 +0,0 @@
|
|
1
|
-
workflow "Tests" {
|
2
|
-
on = "push"
|
3
|
-
resolves = [
|
4
|
-
"rspec-ruby2.6_rails4",
|
5
|
-
"rspec-ruby2.6_rails5",
|
6
|
-
"rspec-ruby2.6_rails6"
|
7
|
-
]
|
8
|
-
}
|
9
|
-
|
10
|
-
action "rspec-ruby2.6_rails4" {
|
11
|
-
uses = "docker://ruby:2.6-alpine"
|
12
|
-
env = {
|
13
|
-
RAILS_VERSION = "~> 4"
|
14
|
-
SQLITE3_VERSION = "~> 1.3.6"
|
15
|
-
}
|
16
|
-
args = [
|
17
|
-
"sh", "-c",
|
18
|
-
"apk add -U git build-base sqlite-dev && bundle install && rake"
|
19
|
-
]
|
20
|
-
}
|
21
|
-
|
22
|
-
action "rspec-ruby2.6_rails5" {
|
23
|
-
uses = "docker://ruby:2.6-alpine"
|
24
|
-
needs = ["rspec-ruby2.6_rails4"]
|
25
|
-
env = {
|
26
|
-
RAILS_VERSION = "~> 5"
|
27
|
-
}
|
28
|
-
args = [
|
29
|
-
"sh", "-c",
|
30
|
-
"apk add -U git build-base sqlite-dev && bundle install && rake"
|
31
|
-
]
|
32
|
-
}
|
33
|
-
|
34
|
-
action "rspec-ruby2.6_rails6" {
|
35
|
-
uses = "docker://ruby:2.6-alpine"
|
36
|
-
needs = ["rspec-ruby2.6_rails5"]
|
37
|
-
env = {
|
38
|
-
RAILS_VERSION = "~> 6.0.0.rc1"
|
39
|
-
}
|
40
|
-
args = [
|
41
|
-
"sh", "-c",
|
42
|
-
"apk add -U git build-base sqlite-dev && bundle install && rake"
|
43
|
-
]
|
44
|
-
}
|
data/.gitignore
DELETED
data/.rspec
DELETED
data/.rubocop.yml
DELETED
@@ -1,46 +0,0 @@
|
|
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
DELETED
@@ -1,29 +0,0 @@
|
|
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
DELETED
data/Rakefile
DELETED
@@ -1,30 +0,0 @@
|
|
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)
|
data/otp-jwt.gemspec
DELETED
@@ -1,36 +0,0 @@
|
|
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.2.0.pre.beta.0'
|
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', ENV['SQLITE3_VERSION']
|
35
|
-
spec.add_development_dependency 'yardstick'
|
36
|
-
end
|