jwt_sessions 0.0.1
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 +7 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +27 -0
- data/LICENSE +21 -0
- data/README.md +2 -0
- data/Rakefile +11 -0
- data/jwt_sessions.gemspec +24 -0
- data/lib/jwt_sessions.rb +73 -0
- data/lib/jwt_sessions/access_token.rb +29 -0
- data/lib/jwt_sessions/authorization.rb +99 -0
- data/lib/jwt_sessions/csrf_token.rb +70 -0
- data/lib/jwt_sessions/errors.rb +8 -0
- data/lib/jwt_sessions/rails_authorization.rb +13 -0
- data/lib/jwt_sessions/redis_token_store.rb +82 -0
- data/lib/jwt_sessions/refresh_token.rb +54 -0
- data/lib/jwt_sessions/session.rb +109 -0
- data/lib/jwt_sessions/token.rb +36 -0
- data/lib/jwt_sessions/version.rb +5 -0
- data/test/units/jwt_sessions/test_session.rb +50 -0
- data/test/units/jwt_sessions/test_token.rb +40 -0
- data/test/units/test_jwt_sessions.rb +22 -0
- metadata +108 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 4b639d9a9560b309205c8cc09a55d4e29ade5af7
|
4
|
+
data.tar.gz: 4936d31724cfe88af332f0edf5c29faefdd8ed74
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 54ba0f9e9826cebeba55de7687e5215255a827e3765b360913cf460569d5f121657a9d0b6ecdab72149403ad2d2c15e594e55a508516b4bb9f1dd10c31568727
|
7
|
+
data.tar.gz: 0d91952a603ef98df693c9daa3603c52523a7fa045a9a6c7d9ba90c9a15c21706305a657ee0870a74977ffd51ea3596b2fff9148b74313eb7916b7f755e5a997
|
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
jwt_sessions (0.0.0)
|
5
|
+
jwt (~> 1.4)
|
6
|
+
redis (~> 3)
|
7
|
+
|
8
|
+
GEM
|
9
|
+
remote: https://rubygems.org/
|
10
|
+
specs:
|
11
|
+
coderay (1.1.2)
|
12
|
+
jwt (1.5.6)
|
13
|
+
method_source (0.9.0)
|
14
|
+
pry (0.11.3)
|
15
|
+
coderay (~> 1.1.0)
|
16
|
+
method_source (~> 0.9.0)
|
17
|
+
redis (3.3.5)
|
18
|
+
|
19
|
+
PLATFORMS
|
20
|
+
ruby
|
21
|
+
|
22
|
+
DEPENDENCIES
|
23
|
+
jwt_sessions!
|
24
|
+
pry
|
25
|
+
|
26
|
+
BUNDLED WITH
|
27
|
+
1.16.1
|
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2018 Yulia Oletskaya
|
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 all
|
13
|
+
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 THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
data/Rakefile
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'jwt_sessions/version'
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = 'jwt_sessions'
|
7
|
+
s.version = JWTSessions::VERSION
|
8
|
+
s.date = '2018-03-08'
|
9
|
+
s.summary = 'JWT Sessions'
|
10
|
+
s.description = 'XSS/CSRF safe JWT auth designed for SPA'
|
11
|
+
s.authors = ['Yulia Oletskaya']
|
12
|
+
s.email = 'yulia.oletskaya@gmail.com'
|
13
|
+
s.homepage = 'http://rubygems.org/gems/jwt_sessions'
|
14
|
+
s.license = 'MIT'
|
15
|
+
|
16
|
+
s.files = Dir['*', 'lib/**/*', 'LICENSE', 'README.md']
|
17
|
+
s.test_files = Dir['test/units/*', 'test/units/**/*']
|
18
|
+
s.require_paths = ['lib']
|
19
|
+
|
20
|
+
s.add_dependency 'jwt', '~>1.4'
|
21
|
+
s.add_dependency 'redis', '~>3'
|
22
|
+
|
23
|
+
s.add_development_dependency 'pry', '~>0.11'
|
24
|
+
end
|
data/lib/jwt_sessions.rb
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'securerandom'
|
4
|
+
|
5
|
+
require 'jwt_sessions/errors'
|
6
|
+
require 'jwt_sessions/token'
|
7
|
+
require 'jwt_sessions/redis_token_store'
|
8
|
+
require 'jwt_sessions/refresh_token'
|
9
|
+
require 'jwt_sessions/csrf_token'
|
10
|
+
require 'jwt_sessions/access_token'
|
11
|
+
require 'jwt_sessions/session'
|
12
|
+
require 'jwt_sessions/authorization'
|
13
|
+
require 'jwt_sessions/version'
|
14
|
+
|
15
|
+
module JWTSessions
|
16
|
+
extend self
|
17
|
+
|
18
|
+
attr_writer :token_store
|
19
|
+
|
20
|
+
DEFAULT_SETTINGS_KEYS = %i[redis_host
|
21
|
+
redis_port
|
22
|
+
redis_db_name
|
23
|
+
token_prefix
|
24
|
+
algorithm
|
25
|
+
exp_time
|
26
|
+
refresh_exp_time].freeze
|
27
|
+
DEFAULT_REDIS_HOST = '127.0.0.1'
|
28
|
+
DEFAULT_REDIS_PORT = '6379'
|
29
|
+
DEFAULT_REDIS_DB_NAME = 'jwtokens'
|
30
|
+
DEFAULT_TOKEN_PREFIX = 'jwt_'
|
31
|
+
DEFAULT_ALGORITHM = 'HS256'
|
32
|
+
DEFAULT_EXP_TIME = 3600 # 1 hour in seconds
|
33
|
+
DEFAULT_REFRESH_EXP_TIME = 604800 # 1 week in seconds
|
34
|
+
|
35
|
+
|
36
|
+
DEFAULT_SETTINGS_KEYS.each do |setting|
|
37
|
+
var_name = :"@#{setting}"
|
38
|
+
|
39
|
+
define_method(setting) do
|
40
|
+
if instance_variables.include?(var_name)
|
41
|
+
instance_variable_get(var_name)
|
42
|
+
else
|
43
|
+
instance_variable_set(var_name,
|
44
|
+
const_get("DEFAULT_#{setting.upcase}"))
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
define_method("#{setting}=") do |val|
|
49
|
+
instance_variable_set(var_name, val)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def token_store
|
54
|
+
RedisTokenStore.instance(redis_host, redis_port, redis_db_name, token_prefix)
|
55
|
+
end
|
56
|
+
|
57
|
+
def encryption_key
|
58
|
+
raise Errors::Malconfigured, 'encryption_key is not specified' unless @encryption_key
|
59
|
+
@encryption_key
|
60
|
+
end
|
61
|
+
|
62
|
+
def access_expiration
|
63
|
+
Time.now.to_i + exp_time.to_i
|
64
|
+
end
|
65
|
+
|
66
|
+
def refresh_expiration
|
67
|
+
Time.now.to_i + refresh_exp_time.to_i
|
68
|
+
end
|
69
|
+
|
70
|
+
def encryption_key=(key)
|
71
|
+
@encryption_key = key
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module JWTSessions
|
2
|
+
class AccessToken
|
3
|
+
attr_reader :token, :payload, :uid, :expiration, :csrf
|
4
|
+
|
5
|
+
def initialize(csrf, payload, store, uid = SecureRandom.uuid, expiration = JWTSessions.access_expiration)
|
6
|
+
@csrf = csrf
|
7
|
+
@uid = uid
|
8
|
+
@expiration = expiration
|
9
|
+
@payload = payload
|
10
|
+
@token = Token.encode(payload.merge(uid: uid, exp: expiration.to_i))
|
11
|
+
end
|
12
|
+
|
13
|
+
def destroy
|
14
|
+
store.destroy_access(uid)
|
15
|
+
end
|
16
|
+
|
17
|
+
class << self
|
18
|
+
def create(csrf, payload, store)
|
19
|
+
new(csrf, payload, store).tap do |inst|
|
20
|
+
store.persist_access(inst.uid, inst.csrf, inst.expiration)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def destroy(uid, store)
|
25
|
+
store.destroy_access(uid)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module JWTSessions
|
4
|
+
module Authorization
|
5
|
+
CSRF_SAFE_METHODS = ['GET', 'HEAD'].freeze
|
6
|
+
|
7
|
+
protected
|
8
|
+
|
9
|
+
def authenticate_request!
|
10
|
+
begin
|
11
|
+
cookieless_auth
|
12
|
+
rescue Errors::Unauthorized
|
13
|
+
cookie_based_auth
|
14
|
+
end
|
15
|
+
|
16
|
+
invalid_authentication unless Token.valid_payload?(payload)
|
17
|
+
check_csrf
|
18
|
+
end
|
19
|
+
|
20
|
+
def invalid_authentication
|
21
|
+
raise Errors::Unauthorized
|
22
|
+
end
|
23
|
+
|
24
|
+
def get_from_payload(key)
|
25
|
+
payload[key]
|
26
|
+
end
|
27
|
+
|
28
|
+
def check_csrf
|
29
|
+
invalid_authentication if should_check_csrf? && @_csrf_check && !valid_csrf_token?(retrieve_csrf)
|
30
|
+
end
|
31
|
+
|
32
|
+
def should_check_csrf?
|
33
|
+
!CSRF_SAFE_METHODS.include?(request_method)
|
34
|
+
end
|
35
|
+
|
36
|
+
def token_header
|
37
|
+
raise Errors::Malconfigured, 'token_header is not implemented'
|
38
|
+
end
|
39
|
+
|
40
|
+
def token_cookie
|
41
|
+
raise Errors::Malconfigured, 'token_cookie is not implemented'
|
42
|
+
end
|
43
|
+
|
44
|
+
def csrf_header
|
45
|
+
raise Errors::Malconfigured, 'csrf_header is not implemented'
|
46
|
+
end
|
47
|
+
|
48
|
+
def request_headers
|
49
|
+
raise Errors::Malconfigured, 'request_headers is not implemented'
|
50
|
+
end
|
51
|
+
|
52
|
+
def request_cookies
|
53
|
+
raise Errors::Malconfigured, 'request_cookies is not implemented'
|
54
|
+
end
|
55
|
+
|
56
|
+
def request_method
|
57
|
+
raise Errors::Malconfigured, 'request_method is not implemented'
|
58
|
+
end
|
59
|
+
|
60
|
+
def valid_csrf_token?(csrf_token)
|
61
|
+
JWTSessions::Session.new.valid_csrf?(@_raw_token, csrf_token)
|
62
|
+
end
|
63
|
+
|
64
|
+
def cookieless_auth
|
65
|
+
@_csrf_check = false
|
66
|
+
@_raw_token = token_from_headers
|
67
|
+
end
|
68
|
+
|
69
|
+
def cookie_based_auth
|
70
|
+
@_csrf_check = true
|
71
|
+
@_raw_token = token_from_cookies
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
def retrieve_csrf
|
77
|
+
token = requset_headers[csrf_header]
|
78
|
+
raise Errors::Unauthorized, 'CSRF token is not found' unless token
|
79
|
+
token
|
80
|
+
end
|
81
|
+
|
82
|
+
def token_from_headers
|
83
|
+
raw_token = request_headers[token_header]
|
84
|
+
token = raw_token.split(' ')[-1]
|
85
|
+
raise Errors::Unauthorized, 'Token is not found among request headers' unless token
|
86
|
+
token
|
87
|
+
end
|
88
|
+
|
89
|
+
def token_from_cookies
|
90
|
+
token = request_cookies[token_cookie]
|
91
|
+
raise Errors::Unauthorized, 'Token is not found among cookies' unless token
|
92
|
+
token
|
93
|
+
end
|
94
|
+
|
95
|
+
def payload
|
96
|
+
@_payload ||= Token.decode(@token).first
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module JWTSessions
|
4
|
+
class CSRFToken
|
5
|
+
CSRF_LENGTH = 32
|
6
|
+
|
7
|
+
attr_reader :encoded, :token
|
8
|
+
|
9
|
+
def initialize(csrf_token = nil)
|
10
|
+
@encoded = csrf_token || SecureRandom.base64(CSRF_LENGTH)
|
11
|
+
@token = masked_token
|
12
|
+
end
|
13
|
+
|
14
|
+
def valid_authenticity_token?(encoded_masked_token)
|
15
|
+
if encoded_masked_token.nil? || encoded_masked_token.empty? || !encoded_masked_token.is_a?(String)
|
16
|
+
return false
|
17
|
+
end
|
18
|
+
|
19
|
+
begin
|
20
|
+
masked_token = Base64.strict_decode64(encoded_masked_token)
|
21
|
+
rescue ArgumentError
|
22
|
+
return false
|
23
|
+
end
|
24
|
+
|
25
|
+
if masked_token.length == AUTHENTICITY_TOKEN_LENGTH
|
26
|
+
secure_compare(masked_token, raw_token)
|
27
|
+
elsif masked_token.length == AUTHENTICITY_TOKEN_LENGTH * 2
|
28
|
+
csrf_token = unmask_token(masked_token)
|
29
|
+
secure_compare(csrf_token, raw_token)
|
30
|
+
else
|
31
|
+
false
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def secure_compare(a, b)
|
38
|
+
return false unless a.bytesize == b.bytesize
|
39
|
+
|
40
|
+
l = a.unpack "C#{a.bytesize}"
|
41
|
+
|
42
|
+
res = 0
|
43
|
+
b.each_byte { |byte| res |= byte ^ l.shift }
|
44
|
+
res == 0
|
45
|
+
end
|
46
|
+
|
47
|
+
def unmask_token(masked_token)
|
48
|
+
one_time_pad = masked_token[0...CSRF_LENGTH]
|
49
|
+
encrypted_csrf_token = masked_token[CSRF_LENGTH..-1]
|
50
|
+
xor_byte_strings(one_time_pad, encrypted_csrf_token)
|
51
|
+
end
|
52
|
+
|
53
|
+
def masked_token
|
54
|
+
one_time_pad = SecureRandom.random_bytes(CSRF_LENGTH)
|
55
|
+
encrypted_csrf_token = xor_byte_strings(one_time_pad, raw_token)
|
56
|
+
masked_token = one_time_pad + encrypted_csrf_token
|
57
|
+
Base64.strict_encode64(masked_token)
|
58
|
+
end
|
59
|
+
|
60
|
+
def raw_token
|
61
|
+
Base64.strict_decode64(encoded)
|
62
|
+
end
|
63
|
+
|
64
|
+
def xor_byte_strings(s1, s2)
|
65
|
+
s2_bytes = s2.bytes
|
66
|
+
s1.each_byte.with_index { |c1, i| s2_bytes[i] ^= c1 }
|
67
|
+
s2_bytes.pack("C*")
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'redis'
|
4
|
+
|
5
|
+
module JWTSessions
|
6
|
+
class RedisTokenStore
|
7
|
+
class << self
|
8
|
+
def instance(redis_host, redis_port, redis_db_name, prefix)
|
9
|
+
@_tokens_store ||= Redis.new(url: "redis://#{redis_host}:#{redis_port}/#{redis_db_name}")
|
10
|
+
@_token_prefix ||= prefix
|
11
|
+
|
12
|
+
new(@_tokens_store, @_token_prefix)
|
13
|
+
end
|
14
|
+
|
15
|
+
def clear
|
16
|
+
@_tokens_store = nil
|
17
|
+
@_token_prefix = nil
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def new(store, prefix)
|
23
|
+
super(store, prefix)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
attr_reader :store, :prefix
|
28
|
+
|
29
|
+
def initialize(store, prefix)
|
30
|
+
@store = store
|
31
|
+
@prefix = prefix
|
32
|
+
end
|
33
|
+
|
34
|
+
def fetch_access(uid)
|
35
|
+
csrf = store.get(access_key(uid))
|
36
|
+
return {} if csrf.nil?
|
37
|
+
{ csrf: csrf }
|
38
|
+
end
|
39
|
+
|
40
|
+
def persist_access(uid, csrf, expiration)
|
41
|
+
key = access_key(uid)
|
42
|
+
store.set(key, csrf)
|
43
|
+
store.expireat(key, expiration)
|
44
|
+
end
|
45
|
+
|
46
|
+
def fetch_refresh(uid)
|
47
|
+
keys = [:csrf, :access_uid, :access_expiration, :expiration]
|
48
|
+
values = store.hmget(refresh_key(uid), *keys)
|
49
|
+
return {} if values.empty?
|
50
|
+
keys.each_with_index.inject({}) { |acc, (key, index)| acc[key] = values[index]; acc }
|
51
|
+
end
|
52
|
+
|
53
|
+
def persist_refresh(uid, access_expiration, access_uid, csrf, expiration)
|
54
|
+
key = refresh_key(uid)
|
55
|
+
update_refresh(uid, access_expiration, access_uid, csrf)
|
56
|
+
store.hset(key, :expiration, expiration)
|
57
|
+
store.expireat(key, expiration)
|
58
|
+
end
|
59
|
+
|
60
|
+
def update_refresh(uid, access_expiration, access_uid, csrf)
|
61
|
+
store.hmset(refresh_key(uid), :csrf, csrf, :access_expiration, access_expiration, :access_uid, access_uid)
|
62
|
+
end
|
63
|
+
|
64
|
+
def destroy_refresh(uid)
|
65
|
+
store.del(refresh_key(uid))
|
66
|
+
end
|
67
|
+
|
68
|
+
def destroy_access(uid)
|
69
|
+
store.del(access_key(uid))
|
70
|
+
end
|
71
|
+
|
72
|
+
private
|
73
|
+
|
74
|
+
def access_key(uid)
|
75
|
+
"#{prefix}_access_#{uid}"
|
76
|
+
end
|
77
|
+
|
78
|
+
def refresh_key(uid)
|
79
|
+
"#{prefix}_refresh_#{uid}"
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module JWTSessions
|
4
|
+
class RefreshToken
|
5
|
+
attr_reader :expiration, :uid, :token, :csrf, :access_uid, :access_expiration, :store
|
6
|
+
|
7
|
+
def initialize(csrf, access_uid, access_expiration, store, uid = SecureRandom.uuid, expiration = JWTSessions.refresh_expiration)
|
8
|
+
@csrf = csrf
|
9
|
+
@access_uid = access_uid
|
10
|
+
@access_expiration = access_expiration
|
11
|
+
@uid = uid
|
12
|
+
@expiration = expiration
|
13
|
+
@store = store
|
14
|
+
@token = Token.encode(uid: uid, exp: expiration.to_i)
|
15
|
+
end
|
16
|
+
|
17
|
+
class << self
|
18
|
+
def create(csrf, access_uid, access_expiration, store)
|
19
|
+
inst = new(csrf, access_uid, access_expiration, store)
|
20
|
+
inst.send(:persist_in_store)
|
21
|
+
inst
|
22
|
+
end
|
23
|
+
|
24
|
+
def find(uid, store)
|
25
|
+
token_attrs = store.fetch_refresh(uid)
|
26
|
+
raise Errors::Unauthorized, 'Refresh token not found' if token_attrs.empty?
|
27
|
+
new(token_attrs[:csrf],
|
28
|
+
token_attrs[:access_uid],
|
29
|
+
token_attrs[:access_expiration],
|
30
|
+
store,
|
31
|
+
uid,
|
32
|
+
token_attrs[:expiration])
|
33
|
+
end
|
34
|
+
|
35
|
+
def destroy(uid, store)
|
36
|
+
store.destroy_refresh(uid)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def update(access_uid, access_expiration, csrf)
|
41
|
+
store.update_refresh(uid, access_uid, access_expiration, csrf)
|
42
|
+
end
|
43
|
+
|
44
|
+
def destroy
|
45
|
+
store.destroy_refresh(uid)
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def persist_in_store
|
51
|
+
store.persist_refresh(uid, access_expiration, access_uid, csrf, expiration)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,109 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module JWTSessions
|
4
|
+
class Session
|
5
|
+
attr_reader :access_token, :refresh_token, :csrf_token
|
6
|
+
attr_accessor :payload, :store
|
7
|
+
|
8
|
+
def initialize(options = {})
|
9
|
+
@store = options.fetch(:store, JWTSessions.token_store)
|
10
|
+
@payload = options.fetch(:payload, {})
|
11
|
+
end
|
12
|
+
|
13
|
+
def login
|
14
|
+
create_csrf_token
|
15
|
+
create_access_token
|
16
|
+
create_refresh_token
|
17
|
+
|
18
|
+
tokens_hash
|
19
|
+
end
|
20
|
+
|
21
|
+
def valid_csrf?(access_token, csrf_token)
|
22
|
+
csrf(access_token).valid_authenticity_token?(csrf_token)
|
23
|
+
end
|
24
|
+
|
25
|
+
def masked_csrf(access_token)
|
26
|
+
csrf(access_token).token
|
27
|
+
end
|
28
|
+
|
29
|
+
def refresh(refresh_token, &block)
|
30
|
+
refresh_token_data(refresh_token)
|
31
|
+
refresh_by_uid(&block)
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def refresh_by_uid(&block)
|
37
|
+
check_refresh_on_time(&block) if block_given?
|
38
|
+
AccessToken.destroy(@_refresh.access_uid, store)
|
39
|
+
issue_tokens_after_refresh
|
40
|
+
end
|
41
|
+
|
42
|
+
def csrf(access_token)
|
43
|
+
token_data = access_token_data(access_token)
|
44
|
+
raise Errors::Unauthorized, 'Access token not found' if token_data.empty?
|
45
|
+
CSRFToken.new(token_data[:csrf])
|
46
|
+
end
|
47
|
+
|
48
|
+
def access_token_data(token)
|
49
|
+
uid = token_uid(token, :access)
|
50
|
+
store.fetch_access(uid)
|
51
|
+
end
|
52
|
+
|
53
|
+
def refresh_token_data(token)
|
54
|
+
uid = token_uid(token, :refresh)
|
55
|
+
retrieve_refresh_token(uid)
|
56
|
+
end
|
57
|
+
|
58
|
+
def token_uid(token, type)
|
59
|
+
token_payload = JWTSessions::Token.decode(refresh_token).first
|
60
|
+
uid = token_payload.fetch('uid', nil)
|
61
|
+
if uid.nil?
|
62
|
+
message = "#{type.to_s.capitalize} token payload does not contain token uid"
|
63
|
+
raise Errors::InvalidPayload, message
|
64
|
+
end
|
65
|
+
uid
|
66
|
+
end
|
67
|
+
|
68
|
+
def retrieve_refresh_token(uid)
|
69
|
+
@_refresh = RefreshToken.find(uid, store)
|
70
|
+
end
|
71
|
+
|
72
|
+
def tokens_hash
|
73
|
+
{ csrf: csrf_token, access: access_token, refresh: refresh_token }
|
74
|
+
end
|
75
|
+
|
76
|
+
def check_refresh_on_time
|
77
|
+
expiration = @_refresh.access_expiration
|
78
|
+
yield @_refresh.uid, expiration if expiration.to_i > Time.now.to_i
|
79
|
+
end
|
80
|
+
|
81
|
+
def issue_tokens_after_refresh
|
82
|
+
create_csrf_token
|
83
|
+
create_access_token
|
84
|
+
update_refresh_token
|
85
|
+
|
86
|
+
tokens_hash
|
87
|
+
end
|
88
|
+
|
89
|
+
def update_refresh_token
|
90
|
+
@_refresh.update(@_access.uid, @_access.expiration, @_csrf.encoded)
|
91
|
+
@refresh_token = @_refresh.token
|
92
|
+
end
|
93
|
+
|
94
|
+
def create_csrf_token
|
95
|
+
@_csrf = CSRFToken.new
|
96
|
+
@csrf_token = @_csrf.token
|
97
|
+
end
|
98
|
+
|
99
|
+
def create_refresh_token
|
100
|
+
@_refresh = RefreshToken.create(@_csrf.encoded, @_access.uid, @_access.expiration, store)
|
101
|
+
@refresh_token = @_refresh.token
|
102
|
+
end
|
103
|
+
|
104
|
+
def create_access_token
|
105
|
+
@_access = AccessToken.create(@_csrf.encoded, payload, store)
|
106
|
+
@access_token = @_access.token
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'jwt'
|
4
|
+
|
5
|
+
module JWTSessions
|
6
|
+
class Token
|
7
|
+
class << self
|
8
|
+
def encode(payload)
|
9
|
+
exp_payload = meta.merge(payload)
|
10
|
+
JWT.encode(exp_payload, JWTSessions.encryption_key, JWTSessions.algorithm)
|
11
|
+
end
|
12
|
+
|
13
|
+
def decode(token)
|
14
|
+
JWT.decode(token, JWTSessions.encryption_key, true, { algorithm: JWTSessions.algorithm, verify_expiration: false })
|
15
|
+
rescue JWT::DecodeError => e
|
16
|
+
raise Errors::Unauthorized, e.message
|
17
|
+
rescue StandardError
|
18
|
+
raise Errors::Unauthorized, 'could not decode a token'
|
19
|
+
end
|
20
|
+
|
21
|
+
def valid_payload?(payload)
|
22
|
+
!expired?(payload)
|
23
|
+
end
|
24
|
+
|
25
|
+
def meta
|
26
|
+
{ exp: JWTSessions.access_expiration }
|
27
|
+
end
|
28
|
+
|
29
|
+
def expired?(payload)
|
30
|
+
Time.at(payload['exp']) < Time.now
|
31
|
+
rescue StandardError
|
32
|
+
raise Errors::Unauthorized, 'invalid payload expiration time'
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'minitest/autorun'
|
4
|
+
require 'jwt_sessions'
|
5
|
+
|
6
|
+
class TestSession < Minitest::Test
|
7
|
+
attr_accessor :session, :payload, :tokens
|
8
|
+
EXPECTED_KEYS = [:access, :csrf, :refresh].freeze
|
9
|
+
|
10
|
+
def setup
|
11
|
+
JWTSessions.encryption_key = '65994c7b523a3232e7aba54d8cbf'
|
12
|
+
@payload = { test: 'test' }
|
13
|
+
@session = JWTSessions::Session.new(payload: payload)
|
14
|
+
@tokens = session.login
|
15
|
+
end
|
16
|
+
|
17
|
+
def test_login
|
18
|
+
decoded_access = JWTSessions::Token.decode(tokens[:access]).first
|
19
|
+
assert_equal EXPECTED_KEYS, tokens.keys.sort
|
20
|
+
assert_equal payload[:test], decoded_access['test']
|
21
|
+
end
|
22
|
+
|
23
|
+
def test_refresh
|
24
|
+
refreshed_tokens = session.refresh(tokens[:refresh])
|
25
|
+
decoded_access = JWTSessions::Token.decode(refreshed_tokens[:access]).first
|
26
|
+
assert_equal EXPECTED_KEYS, refreshed_tokens.keys.sort
|
27
|
+
assert_equal payload[:test], decoded_access['test']
|
28
|
+
end
|
29
|
+
|
30
|
+
def test_refresh_with_block_not_expired
|
31
|
+
assert_raises JWTSessions::Errors::Unauthorized do
|
32
|
+
session.refresh(tokens[:refresh]) do
|
33
|
+
raise JWTSessions::Errors::Unauthorized
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def test_refresh_with_block_expired
|
39
|
+
JWTSessions.exp_time = 0
|
40
|
+
@session = JWTSessions::Session.new(payload: payload)
|
41
|
+
@tokens = session.login
|
42
|
+
refreshed_tokens = session.refresh(tokens[:refresh]) do
|
43
|
+
raise JWTSessions::Errors::Unauthorized
|
44
|
+
end
|
45
|
+
JWTSessions.exp_time = 3600
|
46
|
+
decoded_access = JWTSessions::Token.decode(refreshed_tokens[:access]).first
|
47
|
+
assert_equal EXPECTED_KEYS, refreshed_tokens.keys.sort
|
48
|
+
assert_equal payload[:test], decoded_access['test']
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'minitest/autorun'
|
4
|
+
require 'jwt_sessions'
|
5
|
+
|
6
|
+
class TestToken < Minitest::Test
|
7
|
+
attr_accessor :payload
|
8
|
+
|
9
|
+
def setup
|
10
|
+
JWTSessions.encryption_key = '65994c7b523a3232e7aba54d8cbf'
|
11
|
+
@payload = { 'user_id' => 1 }
|
12
|
+
end
|
13
|
+
|
14
|
+
def test_valid_token_decode
|
15
|
+
token = JWTSessions::Token.encode(payload)
|
16
|
+
assert_equal payload['user_id'], JWTSessions::Token.decode(token).first['user_id']
|
17
|
+
end
|
18
|
+
|
19
|
+
def test_invalid_token_decode
|
20
|
+
assert_raises JWTSessions::Errors::Unauthorized do
|
21
|
+
JWTSessions::Token.decode('abc')
|
22
|
+
end
|
23
|
+
assert_raises JWTSessions::Errors::Unauthorized do
|
24
|
+
JWTSessions::Token.decode('')
|
25
|
+
end
|
26
|
+
assert_raises JWTSessions::Errors::Unauthorized do
|
27
|
+
JWTSessions::Token.decode(nil)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def test_payload_exp_time
|
32
|
+
assert_raises JWTSessions::Errors::Unauthorized do
|
33
|
+
JWTSessions::Token.valid_payload?(payload)
|
34
|
+
end
|
35
|
+
payload['exp'] = Time.now - (3600 * 24)
|
36
|
+
assert_equal false, JWTSessions::Token.valid_payload?(payload)
|
37
|
+
payload['exp'] = Time.now + (3600 * 24)
|
38
|
+
assert_equal true, JWTSessions::Token.valid_payload?(payload)
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'minitest/autorun'
|
4
|
+
require 'jwt_sessions'
|
5
|
+
|
6
|
+
class TestJWTSessions < Minitest::Test
|
7
|
+
def test_default_settings
|
8
|
+
assert_equal JWTSessions::DEFAULT_REDIS_HOST, JWTSessions.redis_host
|
9
|
+
assert_equal JWTSessions::DEFAULT_REDIS_DB_NAME, JWTSessions.redis_db_name
|
10
|
+
assert_equal JWTSessions::DEFAULT_TOKEN_PREFIX, JWTSessions.token_prefix
|
11
|
+
assert_equal JWTSessions::DEFAULT_ALGORITHM, JWTSessions.algorithm
|
12
|
+
assert_equal JWTSessions::DEFAULT_EXP_TIME, JWTSessions.exp_time
|
13
|
+
assert_equal JWTSessions::DEFAULT_REFRESH_EXP_TIME, JWTSessions.refresh_exp_time
|
14
|
+
end
|
15
|
+
|
16
|
+
def test_encryption_key
|
17
|
+
JWTSessions.encryption_key = nil
|
18
|
+
assert_raises JWTSessions::Errors::Malconfigured do
|
19
|
+
JWTSessions.encryption_key
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
metadata
ADDED
@@ -0,0 +1,108 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: jwt_sessions
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Yulia Oletskaya
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2018-03-08 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: jwt
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.4'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.4'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: redis
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '3'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '3'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: pry
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0.11'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0.11'
|
55
|
+
description: XSS/CSRF safe JWT auth designed for SPA
|
56
|
+
email: yulia.oletskaya@gmail.com
|
57
|
+
executables: []
|
58
|
+
extensions: []
|
59
|
+
extra_rdoc_files: []
|
60
|
+
files:
|
61
|
+
- Gemfile
|
62
|
+
- Gemfile.lock
|
63
|
+
- LICENSE
|
64
|
+
- README.md
|
65
|
+
- Rakefile
|
66
|
+
- jwt_sessions.gemspec
|
67
|
+
- lib/jwt_sessions.rb
|
68
|
+
- lib/jwt_sessions/access_token.rb
|
69
|
+
- lib/jwt_sessions/authorization.rb
|
70
|
+
- lib/jwt_sessions/csrf_token.rb
|
71
|
+
- lib/jwt_sessions/errors.rb
|
72
|
+
- lib/jwt_sessions/rails_authorization.rb
|
73
|
+
- lib/jwt_sessions/redis_token_store.rb
|
74
|
+
- lib/jwt_sessions/refresh_token.rb
|
75
|
+
- lib/jwt_sessions/session.rb
|
76
|
+
- lib/jwt_sessions/token.rb
|
77
|
+
- lib/jwt_sessions/version.rb
|
78
|
+
- test/units/jwt_sessions/test_session.rb
|
79
|
+
- test/units/jwt_sessions/test_token.rb
|
80
|
+
- test/units/test_jwt_sessions.rb
|
81
|
+
homepage: http://rubygems.org/gems/jwt_sessions
|
82
|
+
licenses:
|
83
|
+
- MIT
|
84
|
+
metadata: {}
|
85
|
+
post_install_message:
|
86
|
+
rdoc_options: []
|
87
|
+
require_paths:
|
88
|
+
- lib
|
89
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
90
|
+
requirements:
|
91
|
+
- - ">="
|
92
|
+
- !ruby/object:Gem::Version
|
93
|
+
version: '0'
|
94
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
95
|
+
requirements:
|
96
|
+
- - ">="
|
97
|
+
- !ruby/object:Gem::Version
|
98
|
+
version: '0'
|
99
|
+
requirements: []
|
100
|
+
rubyforge_project:
|
101
|
+
rubygems_version: 2.6.13
|
102
|
+
signing_key:
|
103
|
+
specification_version: 4
|
104
|
+
summary: JWT Sessions
|
105
|
+
test_files:
|
106
|
+
- test/units/test_jwt_sessions.rb
|
107
|
+
- test/units/jwt_sessions/test_session.rb
|
108
|
+
- test/units/jwt_sessions/test_token.rb
|