Skip to content

Commit 000526a

Browse files
Invalidate tokens. Expire Tokens. And config options. (#7)
* Adding the ability to revoke tokens We keep track of a set of "revoke_tokens", which are stored in the DB, and also in the JWT token. If the revoke token is removed from the DB, and a user tries to use the corrosponding JWT token, then the request will be denied. Also adding messages to a lot of exceptions. * Add some docs * Switching to a single verifier token * Rename revokable to revocable * Renaming to Tokenable::Verifier * Much cleaner way to check if Verifier is included * Adding support for Expiring Tokens + Tokenable config (#8) * Adding support for expiring tokens This is optional, and if the config setting (yet to be built) is set to nil, then the tokens will never expire * Adding config options for lifespan, and secret * No need to catch here, as we catch this exception in `current_user` * We want to call jwt_user_id first, so that the root exception is bubbled up * Catching and throwing more specific JWT errors * Some docs on Config options, and also document that user_id is returned * Specific section on token expiry * Change docs order a bit * Moving to a Config class so we can easily test, and also add a nicer way to access Proc's * Bit nicer way to get proc_reader when needed * Use from_tokenable_params instead of from_params * Use string instead of uuid * Fix this * Some docs
1 parent ce38e24 commit 000526a

File tree

8 files changed

+155
-32
lines changed

8 files changed

+155
-32
lines changed

README.md

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,54 @@ You can chose from:
4444
You can also create your own stragery. This is as simple as creating a method on the User object.
4545

4646
```ruby
47-
def self.from_params(params)
47+
def self.from_tokenable_params(params)
4848
user = User.find_by(something: params[:something])
4949
return nil unless user.present?
50-
50+
5151
return nil unless user.password_valid?(params[:password])
5252
user
5353
end
5454
```
5555

56+
### Invalidate Tokens
57+
58+
If you want to be able to invalidate tokens from the server, then you can add `Tokenable::Verifier`.
59+
60+
```ruby
61+
class User < ApplicationRecord
62+
include Tokenable::Verifier
63+
end
64+
```
65+
66+
And running the following migration:
67+
68+
```bash
69+
rails g migration AddTokenableVerifierToUsers tokenable_verifier:string
70+
```
71+
72+
You can now invalidate all tokens by calling `user.invalidate_tokens!`.
73+
74+
### Token Expiry
75+
76+
By default, tokens will live forever. If you want to change this, you can set a config option (see below for how to set that up).
77+
78+
```ruby
79+
Tokenable::Config.lifespan = 7.days
80+
```
81+
82+
### Configuration Options
83+
84+
Tokenable works out of the box, with no config required, however you can tweak the settings, by creating `config/initializers/tokenable.rb` file.
85+
86+
```ruby
87+
# The secret used to create these tokens. This is then used to verify the
88+
# token is valid. Note: Tokens are not encrypted, and container the user_id.
89+
# Default: Rails.application.secret_key_base
90+
Tokenable::Config.secret = 'a-256-bit-string'
91+
```
92+
93+
### Example Usage
94+
5695
Once you have this setup, you can login. For example, you could login using `axios` in JavaScript:
5796

5897
```js
@@ -62,12 +101,13 @@ const { data } = await axios.post("https://example.com/api/auth", {
62101
});
63102

64103
const token = data.data.token;
104+
const user_id = data.data.user_id;
65105
```
66106

67107
You then use this token in all future API requests:
68108

69109
```js
70-
const { data } = await axios.get("https://example.com/api/user", {
110+
const { data } = await axios.get(`https://example.com/api/user/${user_id}`, {
71111
headers: { Authorization: `Bearer ${token}` },
72112
});
73113
```

lib/tokenable.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
require_relative 'tokenable/version'
44
require_relative 'tokenable/authable'
5+
require_relative 'tokenable/verifier'
6+
require_relative 'tokenable/config'
57
require_relative 'tokenable/railtie' if defined?(Rails)
68

79
module Tokenable

lib/tokenable/authable.rb

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@ module Authable
1414

1515
def user_signed_in?
1616
current_user.present?
17-
rescue Tokenable::Unauthorized
18-
false
1917
end
2018

2119
def current_user
@@ -25,44 +23,65 @@ def current_user
2523
end
2624

2725
def require_tokenable_user!
28-
raise Tokenable::Unauthorized unless user_signed_in?
26+
raise Tokenable::Unauthorized.new('User not found in JWT token') unless jwt_user_id
27+
raise Tokenable::Unauthorized.new('User is not signed in') unless user_signed_in?
28+
raise Tokenable::Unauthorized.new('Token verifier is invalid') if user_class.included_modules.include?(Tokenable::Verifier) && !current_user.valid_verifier?(jwt_verifier)
2929
end
3030

3131
private
3232

3333
def user_class
34-
User
34+
Tokenable::Config.user_class
3535
end
3636

3737
def token_from_header
38-
headers['Authorization'].to_s.split(' ').last
38+
request.authorization.to_s.split(' ').last
3939
end
4040

41-
def token_from_user(user_id)
41+
def token_from_user(user)
4242
jwt_data = {
43-
user_id: user_id,
44-
}
45-
jwt_token = JWT.encode(jwt_data, jwt_secret, 'HS256')
46-
{
47-
user_id: user_id,
48-
token: jwt_token,
43+
data: {
44+
user_id: user.id,
45+
}
4946
}
47+
48+
if jwt_expiry_time
49+
jwt_data[:exp] = jwt_expiry_time
50+
end
51+
52+
if user_class.included_modules.include?(Tokenable::Verifier)
53+
jwt_data[:data][:verifier] = user.current_verifier
54+
end
55+
56+
JWT.encode(jwt_data, jwt_secret, 'HS256')
5057
end
5158

5259
def jwt_user_id
53-
jwt['data']['user_id']
60+
jwt.dig('data', 'user_id')
61+
end
62+
63+
def jwt_verifier
64+
jwt.dig('data', 'verifier')
5465
end
5566

5667
def jwt
57-
raise Tokenable::Unauthorized unless token_from_header.present?
68+
raise Tokenable::Unauthorized.new('Bearer token not provided') unless token_from_header.present?
69+
70+
@jwt ||= JWT.decode(token_from_header, jwt_secret, true, { algorithm: 'HS256' }).first.to_h
71+
rescue JWT::ExpiredSignature
72+
raise Tokenable::Unauthorized.new('Token has expired')
73+
rescue JWT::VerificationError
74+
raise Tokenable::Unauthorized.new('The tokenable secret used in this token does not match the one supplied in Tokenable::Config.secret')
75+
rescue JWT::DecodeError
76+
raise Tokenable::Unauthorized.new('JWT exception thrown')
77+
end
5878

59-
@jwt ||= JWT.decode(token_from_header, jwt_secret, true, { algorithm: 'HS256' }).first
60-
rescue JWT::ExpiredSignature, JWT::DecodeError
61-
raise Tokenable::Unauthorized
79+
def jwt_expiry_time
80+
Tokenable::Config.lifespan
6281
end
6382

6483
def jwt_secret
65-
Rails.application.secret_key_base
84+
Tokenable::Config.secret
6685
end
6786
end
6887
end

lib/tokenable/config.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
module Tokenable
2+
class Config
3+
# How long should the token last before it expires?
4+
# E.G: Tokenable::Config.lifespan = 7.days
5+
mattr_accessor :lifespan, default: nil
6+
7+
# The secret used by JWT to encode the Token.
8+
# We default to Rails secret_key_base
9+
# This can be any 256 bit string.
10+
mattr_writer :secret, default: -> { Rails.application.secret_key_base }
11+
12+
# The user model that we will perform actions on
13+
mattr_writer :user_class, default: -> { User }
14+
15+
# We do this, as some of our defaults need to live in a Proc (as this library is loaded before Rails)
16+
# This means we can return the value when the method is called, instead of the Proc.
17+
def self.method_missing(method_name, *args, &block)
18+
self.class_variable_defined?("@@#{method_name}") ? self.proc_reader(method_name) : super
19+
end
20+
21+
private
22+
23+
def self.proc_reader(key)
24+
value = self.class_variable_get("@@#{key}")
25+
value.is_a?(Proc) ? value.call : value
26+
end
27+
end
28+
end

lib/tokenable/controllers/tokens_controller.rb

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,17 @@ class TokensController < ::ActionController::API
55
include Authable
66

77
def create
8-
user_id, = User.from_params(params)
9-
raise Tokenable::Unauthorized unless user_id
8+
user = User.from_tokenable_params(params)
9+
raise Tokenable::Unauthorized unless user
1010

11-
token = token_from_user(user_id)
12-
render json: { data: token }, status: 201
11+
response = {
12+
data: {
13+
token: token_from_user(user),
14+
user_id: user.id
15+
}
16+
}
17+
18+
render json: response, status: 201
1319
end
1420
end
1521
end

lib/tokenable/strategies/devise.rb

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,15 @@ module Devise
66
extend ActiveSupport::Concern
77

88
class_methods do
9-
# @return [string, nil] Returns user_id + revocation key (not used yet)
10-
def from_params(params)
9+
def from_tokenable_params(params)
1110
email, password = parse_auth_params(params)
1211

1312
user = User.find_by(email: email)
1413
return nil unless user
1514

1615
return nil unless user.valid_password?(password)
1716

18-
[user.id, nil]
17+
user
1918
end
2019

2120
private

lib/tokenable/strategies/secure_password.rb

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,15 @@ module SecurePassword
66
extend ActiveSupport::Concern
77

88
class_methods do
9-
# @return [string, nil] Returns user_id + revocation key (not used yet)
10-
def from_params(params)
9+
def from_tokenable_params(params)
1110
email, password = parse_auth_params(params)
1211

13-
user = User.select(:id, :password_digest).find_by(email: email)
12+
user = User.find_by(email: email)
1413
return nil unless user
1514

1615
return nil unless user.authenticate(password)
1716

18-
[user.id, nil]
17+
user
1918
end
2019

2120
private

lib/tokenable/verifier.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
module Tokenable
2+
module Verifier
3+
extend ActiveSupport::Concern
4+
5+
def valid_verifier?(verifier)
6+
raise Tokenable::Unauthorized.new("#{verifier_key} field is missing") unless self.has_attribute?(verifier_key)
7+
8+
current_verifier == verifier
9+
end
10+
11+
def current_verifier
12+
read_attribute(verifier_key) || issue_verifier!
13+
end
14+
15+
def invalidate_tokens!
16+
issue_verifier!
17+
end
18+
19+
def issue_verifier!
20+
self.update!(verifier_key => SecureRandom.uuid)
21+
read_attribute(verifier_key)
22+
end
23+
24+
private
25+
26+
def verifier_key
27+
:tokenable_verifier
28+
end
29+
end
30+
end

0 commit comments

Comments
 (0)