Skip to content
This repository was archived by the owner on Mar 22, 2021. It is now read-only.

Commit d78f248

Browse files
committed
[v1.5] Multiple entities authentication (#72)
Multi model authentication - Add multi model authentication - Add JSON serialization to AuthToken - Add token controller generator
1 parent 3c10357 commit d78f248

38 files changed

Lines changed: 635 additions & 72 deletions

CHANGELOG.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,19 @@
22
All notable changes to this project will be documented in this file.
33
This project adheres to [Semantic Versioning](http://semver.org/).
44

5-
## Unreleased
6-
## Added
5+
## [Unreleased] - Unreleased
6+
### Added
7+
- Exception configuration option `Knock.not_found_exception_class_name`
8+
- Multiple entity authentication (e.g. User, Admin, etc)
79
- Possibility to have permanent tokens
8-
- adding config options for exception class
10+
- Adding config options for exception class
11+
- Generator for token controller. E.g. `rails g knock:token_controller user`
12+
13+
### Changed
14+
- Deprecated `Authenticable#authenticate` in favor of `Authenticable#authenticate_user`
15+
- Deprecated use of `Knock.current_user_from_token` in favor of `User.from_token_payload`
16+
- Deprecated use of direct route to `AuthTokenController` in favor of generating a token controller
17+
- No need to mount the engine in `config/routes.rb` anymore
918

1019
## [1.4.2] - 2016-01-29
1120
### Fixed

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Contributing Guidelines
22

33
Contribution is always highly appreciated. Here are a few guidelines to make it
4-
easier and faster for pull requests to get merged and issues to get fixed.
4+
easier and faster for pull requests to get merged and issues to get fixed.
55

66
### Issues
77

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ gemspec
1414
# gem 'byebug', group: [:development, :test]
1515

1616
gem "codeclimate-test-reporter", group: :test, require: nil
17+
gem "simplecov", require: false, group: :test

README.md

Lines changed: 95 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,6 @@ Knock is an authentication solution for Rails API-only application based on JSON
2626

2727
Yes.
2828

29-
### Upcoming features & improvements
30-
31-
- Easy way to authenticate multiple user types (User, Admin, ...)
32-
- Remove ActiveRecord dependency
33-
34-
Really want some feature? Don't hesitate to open an issue :)
35-
3629
## Getting Started
3730

3831
### Installation
@@ -43,7 +36,7 @@ Add this line to your application's Gemfile:
4336
gem 'knock'
4437
```
4538

46-
And then execute:
39+
Then execute:
4740

4841
$ bundle install
4942

@@ -54,6 +47,13 @@ Finally, run the install generator:
5447
It will create the following initializer `config/initializers/knock.rb`.
5548
This file contains all the informations about the existing configuration options.
5649

50+
If you don't use an external authentication solution like Auth0, you also need to provide a way for users to sign in:
51+
52+
$ rails generate knock:token_controller user
53+
54+
This will generate the controller `user_token_controller.rb` and add the required route to your `config/routes.rb` file.
55+
You can also provide another entity instead of `user`. E.g. `admin`
56+
5757
### Requirements
5858

5959
Knock makes one assumption about your user model:
@@ -70,30 +70,20 @@ Using `has_secure_password` is recommended, but you don't have to as long as you
7070

7171
### Usage
7272

73-
Mount the `Knock::Engine` in your `config/routes.rb`
74-
75-
```ruby
76-
Rails.application.routes.draw do
77-
mount Knock::Engine => "/knock"
78-
79-
# your routes ...
80-
end
81-
```
82-
83-
Then include the `Knock::Authenticable` module in your `ApplicationController`
73+
Include the `Knock::Authenticable` module in your `ApplicationController`
8474

8575
```ruby
8676
class ApplicationController < ActionController::API
8777
include Knock::Authenticable
8878
end
8979
```
9080

91-
You can now protect your resources by adding the `authenticate` before_action
92-
to your controllers like this:
81+
You can now protect your resources by calling `authenticate_user` as a before_action
82+
inside your controllers:
9383

9484
```ruby
95-
class MyResourcesController < ApplicationController
96-
before_action :authenticate
85+
class SecuredController < ApplicationController
86+
before_action :authenticate_user
9787

9888
def index
9989
# etc...
@@ -103,33 +93,102 @@ class MyResourcesController < ApplicationController
10393
end
10494
```
10595

96+
You can access the current user in your controller with `current_user`.
97+
10698
If no valid token is passed with the request, Knock will respond with:
10799

108100
```
109101
head :unauthorized
110102
```
111103

112-
If you just want to read the `current_user`, without actually authenticating, you can also do that:
104+
You also have access directly to `current_user` which will try to authenticate or return `nil`:
105+
106+
```ruby
107+
def index
108+
if current_user
109+
# do something
110+
else
111+
# do something else
112+
end
113+
end
114+
```
115+
116+
Note: the `authenticate_user` method uses the `current_user` method. Overwriting `current_user` may cause unexpected behaviour.
117+
118+
You can do the exact same thing for any entity. E.g. for `Admin`, use `authenticate_admin` and `current_admin` instead.
119+
120+
### Customization
121+
122+
#### Via the entity model
123+
124+
The entity model (e.g. `User`) can implement specific methods to provide
125+
customization over different parts of the authentication process.
126+
127+
- **Find the entity when creating the token (when signing in)**
128+
129+
By default, Knock tries to find the entity by email. If you want to modify this
130+
behaviour, implement within your entity model a class method `from_token_request`
131+
that takes the request in argument.
132+
133+
E.g.
134+
135+
```ruby
136+
class User < ActiveRecord::Base
137+
def self.from_token_request request
138+
# Returns a valid user, `nil` or raise `Knock.not_found_exception_class_name`
139+
# e.g.
140+
# email = request.params["auth"] && request.params["auth"]["email"]
141+
# self.find_by email: email
142+
end
143+
end
144+
```
145+
146+
- **Find the authenticated entity from the token payload (when authenticating a request)**
147+
148+
By default, Knock assumes the payload as a subject (`sub`) claim containing the entity's id
149+
and calls `find` on the model. If you want to modify this behaviour, implement within
150+
your entity model a class method `from_token_payload` that takes the
151+
payload in argument.
152+
153+
E.g.
113154

114155
```ruby
115-
class CurrentUsersController < ApplicationController
116-
def show
117-
if current_user
118-
head :ok
119-
else
120-
head :not_found
121-
end
156+
class User < ActiveRecord::Base
157+
def self.from_token_payload payload
158+
# Returns a valid user, `nil` or raise
159+
# e.g.
160+
# self.find payload["sub"]
122161
end
123162
end
124163
```
125164

126-
Note that the `authenticate` method depends upon the `current_user` method. Overwriting `current_user` in the controller may break the `authenticate` method.
165+
- **Modify the token payload**
166+
167+
By default the token payload contains the entity's id inside the subject (`sub`) claim.
168+
If you want to modify this behaviour, implement within your entity model an instance method
169+
`to_token_payload` that returns a hash representing the payload.
170+
171+
E.g.
172+
173+
```ruby
174+
class User < ActiveRecord::Base
175+
def to_token_payload
176+
# Returns the payload as a hash
177+
end
178+
end
179+
```
180+
181+
#### Via the initializer
182+
183+
The initializer [config/initializers/knock.rb](https://github.com/nsarno/knock/blob/master/lib/generators/templates/knock.rb)
184+
is generated when `rails g knock:install` is executed. Each configuration variable is
185+
documented with comments in the initializer itself.
127186

128-
### Authenticating from a web or mobile application:
187+
### Authenticating from a web or mobile application
129188

130189
Example request to get a token from your API:
131190
```
132-
POST /knock/auth_token
191+
POST /user_auth_token
133192
{"auth": {"email": "[email protected]", "password": "secret"}}
134193
```
135194

@@ -139,7 +198,7 @@ Example response from the API:
139198
{"jwt": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9"}
140199
```
141200

142-
To make an authenticated request to your API, you need to pass the token in the request header:
201+
To make an authenticated request to your API, you need to pass the token via the request header:
143202
```
144203
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
145204
GET /my_resources
@@ -157,7 +216,7 @@ To authenticate within your tests:
157216
e.g.
158217

159218
```ruby
160-
class MyResourcesControllerTest < ActionController::TestCase
219+
class SecuredControllerTest < ActionController::TestCase
161220
def authenticate
162221
token = Knock::AuthToken.new(payload: { sub: users(:one).id }).token
163222
request.env['HTTP_AUTHORIZATION'] = "Bearer #{token}"

app/controllers/knock/auth_token_controller.rb

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,48 @@
22

33
module Knock
44
class AuthTokenController < ApplicationController
5-
before_action :authenticate!
5+
before_action :authenticate
66

77
def create
8-
render json: { jwt: auth_token.token }, status: :created
8+
render json: auth_token, status: :created
99
end
1010

1111
private
12-
def authenticate!
13-
raise Knock.not_found_exception_class unless user.authenticate(auth_params[:password])
12+
def authenticate
13+
unless entity.present? && entity.authenticate(auth_params[:password])
14+
raise Knock.not_found_exception_class
15+
end
1416
end
1517

1618
def auth_token
17-
AuthToken.new payload: { sub: user.id }
19+
if entity.respond_to? :to_token_payload
20+
AuthToken.new payload: entity.to_token_payload
21+
else
22+
AuthToken.new payload: { sub: entity.id }
23+
end
1824
end
1925

20-
def user
21-
Knock.current_user_from_handle.call auth_params[Knock.handle_attr]
26+
def entity
27+
@entity ||=
28+
if self.class.name == "Knock::AuthTokenController"
29+
warn "[DEPRECATION]: Routing to `AuthTokenController` directly is deprecated. Please use `<Entity Name>TokenController` inheriting from it instead. E.g. `UserTokenController`"
30+
warn "[DEPRECATION]: Relying on `Knock.current_user_from_handle` is deprecated. Please implement `User#from_token_request` instead."
31+
Knock.current_user_from_handle.call auth_params[Knock.handle_attr]
32+
else
33+
if entity_class.respond_to? :from_token_request
34+
entity_class.from_token_request request
35+
else
36+
entity_class.find_by email: auth_params[:email]
37+
end
38+
end
39+
end
40+
41+
def entity_class
42+
entity_name.constantize
43+
end
44+
45+
def entity_name
46+
self.class.name.split('TokenController').first
2247
end
2348

2449
def auth_params

app/model/knock/auth_token.rb

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,21 @@ def initialize payload: {}, token: nil
1717
end
1818
end
1919

20-
def current_user
21-
@current_user ||= Knock.current_user_from_token.call @payload
20+
def entity_for entity_class
21+
if entity_class.respond_to? :from_token_payload
22+
entity_class.from_token_payload @payload
23+
else
24+
if entity_class.to_s == "User" && Knock.respond_to?(:current_user_from_token)
25+
warn "[DEPRECATION]: `Knock.current_user_from_token` is deprecated. Please implement `User.from_token_payload` instead."
26+
Knock.current_user_from_token.call @payload
27+
else
28+
entity_class.find @payload['sub']
29+
end
30+
end
31+
end
32+
33+
def to_json options = {}
34+
{jwt: @token}.to_json
2235
end
2336

2437
private
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
module Knock
2+
class TokenControllerGenerator < Rails::Generators::Base
3+
source_root File.expand_path("../../templates", __FILE__)
4+
argument :name, type: :string
5+
6+
desc <<-DESC
7+
Creates a Knock token controller for the given entity
8+
and add the corresponding routes.
9+
DESC
10+
11+
def copy_controller_file
12+
template 'entity_token_controller.rb.erb', "app/controllers/#{name.underscore}_token_controller.rb"
13+
end
14+
15+
def add_route
16+
route "post '#{name.underscore}_token' => '#{name.underscore}_token#create'"
17+
end
18+
19+
private
20+
21+
def entity_name
22+
name
23+
end
24+
end
25+
end
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
class <%= entity_name.camelize %>TokenController < Knock::AuthTokenController
2+
end

lib/generators/templates/knock.rb

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
Knock.setup do |config|
22

3+
## [DEPRECATED]
4+
## This is deprecated in favor of `User.from_token_request`.
5+
##
36
## User handle attribute
47
## ---------------------
58
##
@@ -8,6 +11,9 @@
811
## Default:
912
# config.handle_attr = :email
1013

14+
## [DEPRECATED]
15+
## This is deprecated in favor of `User.from_token_request`.
16+
##
1117
## Current user retrieval from handle when signing in
1218
## --------------------------------------------------
1319
##
@@ -25,6 +31,9 @@
2531
## Default:
2632
# config.current_user_from_handle = -> (handle) { User.find_by! Knock.handle_attr => handle }
2733

34+
## [DEPRECATED]
35+
## This is depreacted in favor of `User.from_token_payload`.
36+
##
2837
## Current user retrieval when validating token
2938
## --------------------------------------------
3039
##
@@ -92,8 +101,7 @@
92101
## Exception Class
93102
## ---------------
94103
##
95-
## Configure the Exception to be used (raised and rescued) for User Not Found.
96-
## note: change this if ActiveRecord is not being used.
104+
## Configure the exception to be used when user cannot be found.
97105
##
98106
## Default:
99107
# config.not_found_exception_class_name = 'ActiveRecord::RecordNotFound'

0 commit comments

Comments
 (0)