jwt_sessions 1.3.0 → 2.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +33 -20
- data/lib/jwt_sessions/access_token.rb +2 -2
- data/lib/jwt_sessions/authorization.rb +7 -3
- data/lib/jwt_sessions/refresh_token.rb +3 -3
- data/lib/jwt_sessions/session.rb +24 -10
- data/lib/jwt_sessions/token.rb +7 -0
- data/lib/jwt_sessions/version.rb +1 -1
- data/test/units/jwt_sessions/test_session.rb +56 -3
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6ab2b9607a84bdecaea7ec538b3ba194ae0d57e4
|
4
|
+
data.tar.gz: 3ade912f52efd6078fb676697187145c23e39024
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7761b2f89cf64203f8f82cef4e95c28f6ea602cee0a22d56318bbfd1299cb6e2c3b50ad41b840fddd458dc53b0b249b3d7820a99405b67b2ffb532b900f15d51
|
7
|
+
data.tar.gz: d4e087880bfc893c983c7187d2c206c521efe3beb0c0c245161e0c614bc59868a05c474c9479a36f778966c4ab017c220fd9e866448afd83f9444c59f5f5b33c
|
data/README.md
CHANGED
@@ -341,7 +341,7 @@ class UsersController < ApplicationController
|
|
341
341
|
end
|
342
342
|
```
|
343
343
|
|
344
|
-
Claims are also supported by `JWTSessions::Session`, you can pass `access_claims` and `refresh_claims` options in the initializer
|
344
|
+
Claims are also supported by `JWTSessions::Session`, you can pass `access_claims` and `refresh_claims` options in the initializer.
|
345
345
|
|
346
346
|
##### Request headers and cookies names
|
347
347
|
|
@@ -367,7 +367,7 @@ JWTSessions.refresh_exp_time = 604800 # 1 week in seconds
|
|
367
367
|
#### CSRF and cookies
|
368
368
|
|
369
369
|
In case when you use cookies as your tokens transport it gets vulnerable to CSRF. That's why both login and refresh methods of the `Session` class produce CSRF tokens for you. `Authorization` mixin expects that this token is sent with all requests except GET and HEAD in a header specified among this gem's settings (X-CSRF-Token by default). Verification will be done automatically and `Authorization` exception will be raised in case of mismatch between the token from the header and the one stored in session. \
|
370
|
-
Although you don't need to mitigate BREACH attacks it's still possible to generate a new masked token with the access token
|
370
|
+
Although you don't need to mitigate BREACH attacks it's still possible to generate a new masked token with the access token.
|
371
371
|
|
372
372
|
```ruby
|
373
373
|
session = JWTSessions::Session.new
|
@@ -376,15 +376,13 @@ session.masked_csrf(access_token)
|
|
376
376
|
|
377
377
|
##### Refresh with access token
|
378
378
|
|
379
|
-
Sometimes it's not secure enough to store the refresh tokens in web / JS clients.
|
380
|
-
That's why you have a possibility to operate only
|
381
|
-
Session accepts `refresh_by_access_allowed: true` setting, which links the access token to the according refresh token.
|
379
|
+
Sometimes it's not secure enough to store the refresh tokens in web / JS clients. \
|
380
|
+
That's why you have a possibility to operate only by an access token, and to not pass the refresh to the client at all. \
|
381
|
+
Session accepts `refresh_by_access_allowed: true` setting, which links the access token to the according refresh token. \
|
382
382
|
Example Rails login controller, which passes an access token token via cookies and renders CSRF.
|
383
383
|
|
384
384
|
```ruby
|
385
385
|
class LoginController < ApplicationController
|
386
|
-
include ActionController::Cookies
|
387
|
-
|
388
386
|
def create
|
389
387
|
user = User.find_by!(email: params[:email])
|
390
388
|
if user.authenticate(params[:password])
|
@@ -392,7 +390,10 @@ class LoginController < ApplicationController
|
|
392
390
|
payload = { user_id: user.id }
|
393
391
|
session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
|
394
392
|
tokens = session.login
|
395
|
-
|
393
|
+
response.set_cookie(JWTSessions.access_cookie,
|
394
|
+
value: tokens[:access],
|
395
|
+
httponly: true,
|
396
|
+
secure: Rails.env.production?)
|
396
397
|
|
397
398
|
render json: { csrf: tokens[:csrf] }
|
398
399
|
else
|
@@ -405,22 +406,26 @@ end
|
|
405
406
|
The gem provides an ability to refresh the session by access token.
|
406
407
|
|
407
408
|
```ruby
|
408
|
-
|
409
|
+
session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
|
410
|
+
tokens = session.refresh_by_access_payload
|
409
411
|
```
|
410
412
|
|
411
|
-
In case of token forgery and successful refresh performed by an atacker - the original user will have to logout.
|
412
|
-
To protect the endpoint use before_action `authorize_refresh_by_access_request!`.
|
413
|
-
Example Rails refresh by access controller with cookies as token transport.
|
413
|
+
In case of token forgery and successful refresh performed by an atacker - the original user will have to logout. \
|
414
|
+
To protect the endpoint use before_action `authorize_refresh_by_access_request!`. \
|
415
|
+
Example Rails refresh by access controller with cookies as token transport. \
|
416
|
+
As refresh should be performed once the access token is already expired we need to use `claimless_payload` method in order to skip JWT expiration validation (and other claims) so we can proceed.
|
414
417
|
|
415
418
|
```ruby
|
416
419
|
class RefreshController < ApplicationController
|
417
|
-
include ActionController::Cookies
|
418
420
|
before_action :authorize_refresh_by_access_request!
|
419
421
|
|
420
422
|
def create
|
421
|
-
session = JWTSessions::Session.new(payload:
|
422
|
-
tokens
|
423
|
-
|
423
|
+
session = JWTSessions::Session.new(payload: claimless_payload, refresh_by_access_allowed: true)
|
424
|
+
tokens = session.refresh_by_access_payload
|
425
|
+
response.set_cookie(JWTSessions.access_cookie,
|
426
|
+
value: tokens[:access],
|
427
|
+
httponly: true,
|
428
|
+
secure: Rails.env.production?)
|
424
429
|
|
425
430
|
render json: { csrf: tokens[:csrf] }
|
426
431
|
end
|
@@ -440,7 +445,7 @@ session.refresh(refresh_token) { |refresh_token_uid, access_token_expiration| ..
|
|
440
445
|
|
441
446
|
## Flush Sessions
|
442
447
|
|
443
|
-
Flush session by refresh token. The method returns number of flushed sessions.
|
448
|
+
Flush a session by its refresh token. The method returns number of flushed sessions.
|
444
449
|
|
445
450
|
```ruby
|
446
451
|
session = JWTSessions::Session.new
|
@@ -448,6 +453,14 @@ tokens = session.login
|
|
448
453
|
session.flush_by_token(tokens[:refresh]) # => 1
|
449
454
|
```
|
450
455
|
|
456
|
+
Flush a session by its access token.
|
457
|
+
|
458
|
+
```ruby
|
459
|
+
session = JWTSessions::Session.new(refresh_by_access_allowed: true)
|
460
|
+
tokens = session.login
|
461
|
+
session.flush_by_access_token(tokens[:access]) # => 1
|
462
|
+
```
|
463
|
+
|
451
464
|
Or by refresh token UID
|
452
465
|
|
453
466
|
```ruby
|
@@ -476,9 +489,9 @@ JWTSessions::Session.flush_all
|
|
476
489
|
|
477
490
|
##### Logout
|
478
491
|
|
479
|
-
To logout you need to remove both access and refresh tokens from the store.
|
480
|
-
Flush sessions methods can be used to perform logout.
|
481
|
-
Refresh token or refresh token UID is required to flush a session.
|
492
|
+
To logout you need to remove both access and refresh tokens from the store. \
|
493
|
+
Flush sessions methods can be used to perform logout. \
|
494
|
+
Refresh token or refresh token UID is required to flush a session. \
|
482
495
|
To logout with an access token `refresh_by_access_allowed` setting should be set to true on an access token creation. If logout by access token is allowed it's recommended to ignore the expiration claim and to allow to logout with expired access token.
|
483
496
|
|
484
497
|
## Examples
|
@@ -8,7 +8,7 @@ module JWTSessions
|
|
8
8
|
@csrf = csrf
|
9
9
|
@uid = uid
|
10
10
|
@expiration = expiration
|
11
|
-
@payload = payload
|
11
|
+
@payload = payload.merge('uid' => uid, 'exp' => expiration.to_i)
|
12
12
|
@store = store
|
13
13
|
end
|
14
14
|
|
@@ -25,7 +25,7 @@ module JWTSessions
|
|
25
25
|
end
|
26
26
|
|
27
27
|
def token
|
28
|
-
Token.encode(payload
|
28
|
+
Token.encode(payload)
|
29
29
|
end
|
30
30
|
|
31
31
|
class << self
|
@@ -27,9 +27,8 @@ module JWTSessions
|
|
27
27
|
rescue Errors::Unauthorized
|
28
28
|
cookie_based_auth(:access)
|
29
29
|
end
|
30
|
-
|
31
|
-
invalid_authorization
|
32
|
-
check_csrf(:access)
|
30
|
+
|
31
|
+
invalid_authorization if should_check_csrf? && @_csrf_check && !JWTSessions::Session.new.valid_access_request?(retrieve_csrf, claimless_payload)
|
33
32
|
end
|
34
33
|
|
35
34
|
private
|
@@ -103,5 +102,10 @@ module JWTSessions
|
|
103
102
|
claims = respond_to?(:token_claims) ? token_claims : {}
|
104
103
|
@_payload ||= Token.decode(found_token, claims).first
|
105
104
|
end
|
105
|
+
|
106
|
+
# retrieves tokens payload without JWT claims validation
|
107
|
+
def claimless_payload
|
108
|
+
@_claimless_payload ||= Token.decode!(found_token).first
|
109
|
+
end
|
106
110
|
end
|
107
111
|
end
|
@@ -16,7 +16,7 @@ module JWTSessions
|
|
16
16
|
@uid = options.fetch(:uid, SecureRandom.uuid)
|
17
17
|
@expiration = options.fetch(:expiration, JWTSessions.refresh_expiration)
|
18
18
|
@namespace = options.fetch(:namespace, nil)
|
19
|
-
@token = Token.encode(options.fetch(:payload, {}).merge(uid
|
19
|
+
@token = Token.encode(options.fetch(:payload, {}).merge('uid' => uid, 'exp' => expiration.to_i))
|
20
20
|
end
|
21
21
|
|
22
22
|
class << self
|
@@ -33,7 +33,7 @@ module JWTSessions
|
|
33
33
|
end
|
34
34
|
end
|
35
35
|
|
36
|
-
def find(uid, store, namespace)
|
36
|
+
def find(uid, store, namespace = nil)
|
37
37
|
token_attrs = store.fetch_refresh(uid, namespace)
|
38
38
|
raise Errors::Unauthorized, 'Refresh token not found' if token_attrs.empty?
|
39
39
|
build_with_token_attrs(store, uid, token_attrs, namespace)
|
@@ -61,7 +61,7 @@ module JWTSessions
|
|
61
61
|
@csrf = csrf
|
62
62
|
@access_uid = access_uid
|
63
63
|
@access_expiration = access_expiration
|
64
|
-
store.update_refresh(uid,
|
64
|
+
store.update_refresh(uid, access_expiration, access_uid, csrf, namespace)
|
65
65
|
end
|
66
66
|
|
67
67
|
def destroy
|
data/lib/jwt_sessions/session.rb
CHANGED
@@ -50,12 +50,19 @@ module JWTSessions
|
|
50
50
|
refresh_by_uid(&block)
|
51
51
|
end
|
52
52
|
|
53
|
-
def
|
54
|
-
|
53
|
+
def refresh_by_access_payload(&block)
|
54
|
+
raise Errors::InvalidPayload if payload.nil?
|
55
|
+
ruid = retrive_ruid_from(payload)
|
55
56
|
retrieve_refresh_token(ruid)
|
56
57
|
refresh_by_uid(&block)
|
57
58
|
end
|
58
59
|
|
60
|
+
def flush_by_access_payload
|
61
|
+
raise Errors::InvalidPayload if payload.nil?
|
62
|
+
ruid = retrive_ruid_from(payload)
|
63
|
+
flush_by_uid(ruid)
|
64
|
+
end
|
65
|
+
|
59
66
|
def flush_by_token(token)
|
60
67
|
uid = token_uid(token, :refresh, @refresh_claims)
|
61
68
|
flush_by_uid(uid)
|
@@ -85,6 +92,19 @@ module JWTSessions
|
|
85
92
|
end.count
|
86
93
|
end
|
87
94
|
|
95
|
+
def valid_access_request?(external_csrf_token, external_payload)
|
96
|
+
ruid = external_payload.fetch('ruid', nil)
|
97
|
+
uid = external_payload.fetch('uid', nil)
|
98
|
+
if ruid.nil? || uid.nil?
|
99
|
+
message = 'Token payload is invalid'
|
100
|
+
raise Errors::InvalidPayload, message
|
101
|
+
end
|
102
|
+
refresh_token = RefreshToken.find(ruid, JWTSessions.token_store)
|
103
|
+
return false unless uid == refresh_token.access_uid
|
104
|
+
|
105
|
+
CSRFToken.new(refresh_token.csrf).valid_authenticity_token?(external_csrf_token)
|
106
|
+
end
|
107
|
+
|
88
108
|
private
|
89
109
|
|
90
110
|
def valid_access_csrf?(access_token, csrf_token)
|
@@ -133,14 +153,7 @@ module JWTSessions
|
|
133
153
|
uid
|
134
154
|
end
|
135
155
|
|
136
|
-
def
|
137
|
-
token_payload = JWTSessions::Token.decode(token, @access_claims).first
|
138
|
-
|
139
|
-
# ensure access token exists in the store
|
140
|
-
uid = token_payload.fetch('uid', nil)
|
141
|
-
data = store.fetch_access(uid)
|
142
|
-
raise Errors::Unauthorized, 'Access token not found' if data.empty?
|
143
|
-
|
156
|
+
def retrive_ruid_from(token_payload)
|
144
157
|
ruid = token_payload.fetch('ruid', nil)
|
145
158
|
if ruid.nil?
|
146
159
|
message = "Access token payload does not contain refresh uid"
|
@@ -186,6 +199,7 @@ module JWTSessions
|
|
186
199
|
return unless refresh_by_access_allowed
|
187
200
|
@_access.refresh_uid = @_refresh.uid
|
188
201
|
@access_token = @_access.token
|
202
|
+
@payload = @_access.payload
|
189
203
|
end
|
190
204
|
|
191
205
|
def create_csrf_token
|
data/lib/jwt_sessions/token.rb
CHANGED
@@ -19,6 +19,13 @@ module JWTSessions
|
|
19
19
|
raise Errors::Unauthorized, 'could not decode a token'
|
20
20
|
end
|
21
21
|
|
22
|
+
def decode!(token)
|
23
|
+
decode_options = { algorithm: JWTSessions.algorithm }
|
24
|
+
JWT.decode(token, JWTSessions.public_key, false, decode_options)
|
25
|
+
rescue StandardError
|
26
|
+
raise Errors::Unauthorized, 'could not decode a token'
|
27
|
+
end
|
28
|
+
|
22
29
|
def meta
|
23
30
|
{ exp: JWTSessions.access_expiration }
|
24
31
|
end
|
data/lib/jwt_sessions/version.rb
CHANGED
@@ -33,14 +33,54 @@ class TestSession < Minitest::Test
|
|
33
33
|
assert_equal payload[:test], decoded_access['test']
|
34
34
|
end
|
35
35
|
|
36
|
-
def
|
36
|
+
def test_refresh_by_access_payload
|
37
37
|
session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
|
38
|
-
|
39
|
-
|
38
|
+
session.login
|
39
|
+
access1 = session.instance_variable_get('@_access')
|
40
|
+
sleep(1)
|
41
|
+
refreshed_tokens = session.refresh_by_access_payload
|
42
|
+
access2 = session.instance_variable_get('@_access')
|
40
43
|
decoded_access = JWTSessions::Token.decode(refreshed_tokens[:access]).first
|
41
44
|
assert_equal EXPECTED_KEYS, refreshed_tokens.keys.sort
|
42
45
|
assert_equal payload[:test], decoded_access['test']
|
43
46
|
assert_equal session.instance_variable_get('@_refresh').uid, decoded_access['ruid']
|
47
|
+
assert_equal access2.expiration > access1.expiration, true
|
48
|
+
end
|
49
|
+
|
50
|
+
def test_refresh_by_access_payload_expired
|
51
|
+
JWTSessions.access_exp_time = 0
|
52
|
+
session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
|
53
|
+
session.login
|
54
|
+
refreshed_tokens = session.refresh_by_access_payload
|
55
|
+
decoded_access = JWTSessions::Token.decode!(refreshed_tokens[:access]).first
|
56
|
+
JWTSessions.access_exp_time = 3600
|
57
|
+
assert_equal EXPECTED_KEYS, refreshed_tokens.keys.sort
|
58
|
+
assert_equal payload[:test], decoded_access['test']
|
59
|
+
assert_equal session.instance_variable_get('@_refresh').uid, decoded_access['ruid']
|
60
|
+
end
|
61
|
+
|
62
|
+
def test_refresh_by_access_payload_with_block_expired
|
63
|
+
JWTSessions.access_exp_time = 0
|
64
|
+
session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
|
65
|
+
session.login
|
66
|
+
refreshed_tokens = session.refresh_by_access_payload do
|
67
|
+
raise JWTSessions::Errors::Unauthorized
|
68
|
+
end
|
69
|
+
decoded_access = JWTSessions::Token.decode!(refreshed_tokens[:access]).first
|
70
|
+
JWTSessions.access_exp_time = 3600
|
71
|
+
assert_equal EXPECTED_KEYS, refreshed_tokens.keys.sort
|
72
|
+
assert_equal payload[:test], decoded_access['test']
|
73
|
+
assert_equal session.instance_variable_get('@_refresh').uid, decoded_access['ruid']
|
74
|
+
end
|
75
|
+
|
76
|
+
def test_refresh_by_access_payload_with_block_not_expired
|
77
|
+
session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
|
78
|
+
session.login
|
79
|
+
assert_raises JWTSessions::Errors::Unauthorized do
|
80
|
+
session.refresh_by_access_payload do
|
81
|
+
raise JWTSessions::Errors::Unauthorized
|
82
|
+
end
|
83
|
+
end
|
44
84
|
end
|
45
85
|
|
46
86
|
def test_refresh_with_block_not_expired
|
@@ -76,6 +116,19 @@ class TestSession < Minitest::Test
|
|
76
116
|
end
|
77
117
|
end
|
78
118
|
|
119
|
+
def test_flush_by_access_token
|
120
|
+
session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
|
121
|
+
session.login
|
122
|
+
refresh_token = session.instance_variable_get(:"@_refresh")
|
123
|
+
uid = refresh_token.uid
|
124
|
+
|
125
|
+
session.flush_by_access_payload
|
126
|
+
|
127
|
+
assert_raises JWTSessions::Errors::Unauthorized do
|
128
|
+
JWTSessions::RefreshToken.find(uid, JWTSessions.token_store, nil)
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
79
132
|
def test_flush_by_uid
|
80
133
|
refresh_token = @session.instance_variable_get(:"@_refresh")
|
81
134
|
uid = refresh_token.uid
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: jwt_sessions
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 2.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Yulia Oletskaya
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2018-06-
|
11
|
+
date: 2018-06-17 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: jwt
|