Autenticação e controle de acesso com Access Control List - ACL
ACL ou Access Control List, é o termo utilizado na engenharia de software para falar de Controle de Acesso a Sistemas. A maioria das implementações de ACL seguem um padrão estruturado, onde se define as ações que usuários podem executar no sistema, como por exemplo:
1 {
2 John: read,
3 Meg: admin
4 }
Assim o sistema checa as permissões do usuário e decide se permite ou não que ele execute determinada ação. No Exemplo acima o usuário John tem permissão de leitura e Meg tem permissão de administrador.
O padrão de ACL pode ser aplicado a quase todo tipo de sistema onde é necessário controlar ações de usuários, incluindo APIs.
Como nossa API utiliza express, vamos usar o módulo express-acl para implementar o ACL em nossa aplicação.
Express ACL
O express-acl é um módulo feito para o express que realiza checagem de acesso em runtime e pode ser configurado via JSON ou YAML (aqui vamos optar por JSON). Vamos começar instalando o módulo:
1 $ npm install express-acl@2.0.2
Vamos criar um arquivo chamado nacl.json no diretório de configuração config/nacl.json e adicione o seguinte trecho de código:
1 [
2 {
3 "group": "admin",
4 "permissions": [
5 {
6 "resource": "*",
7 "methods": "*",
8 "action": "allow"
9 }
10 ]
11 },
12 {
13 "group": "user",
14 "permissions": [
15 {
16 "resource": "products",
17 "methods": [
18 "GET"
19 ],
20 "action": "allow"
21 }
22 ]
23 }
24 ]
Ainda não temos todas as configurações necessárias mas já vamos deixar pronta a parte do acl. O express acl funciona por grupos: permitindo ou não ações para um determinado grupo a um determinado recurso da API.
Por exemplo, o primeiro grupo será o de administrador: admin, os administradores terão acesso a todos os recursos da API, sem limitação alguma, para isso é utilizado * com a "action":"allow", isso significa que a ação será de “permitir” em todos os recursos (resource) e metodos (methods).
O segundo grupo é o de usuário (user), usuários somente terão acesso para leitura dos produtos da API.Como já vimos,a leitura é feita pelo verbo http GET, então para usuários precisamos permitir acesso ao método GET do recurso products. Como feito neste bloco:
1 "group": "user",
2 "permissions": [
3 {
4 "resource": "products",
5 "methods": [
6 "GET"
7 ],
8 "action": "allow"
9 }
Agora vamos atualizar o arquivo src/app.js para adicionar a chamada ao acl.
1 + import acl from 'express-acl';
2
3 const app = express();
4
5 +acl.config({
6 + baseUrl: '/',
7 + path: 'config'
8 +});
9
10 const configureExpress = () => {
11 app.use(bodyParser.json());
12 + app.use(acl.authorize.unless({path:['/users/authenticate']}));
13
14 app.use('/', routes);
15 app.database = database;
16
17 return app;
18 };
Note que no app.use(acl.authorize.unless({path:['/users/authenticate']})); estamos passando o express-acl como um middleware para o express, além disso adicionamos a configuração authorize.unless que diz para o express acl validar todos os recursos da API unless (a não ser que) seja /users/authenticate, essa rota será pública e utilizada para gerar o token de autenticação.
Autenticação com JSON Web Token
Primeiro falamos de autorização com express ACL e agora vamos trabalhar na autenticação. A diferença entre autenticação e autorização é que na autenticação verificamos a identidade de um usuário e na autorização verificamos se o usuário autenticado tem privilégios para executar determinada ação. Autenticação pode ser feita de várias maneiras, a mais utilizada é o login com usuário e senha que cria uma sessão. Como APIs devem ser stateless, ou seja, não devem armazenar estado, não é possível ter sessão, dado que para isso ela teria que ser armazenada no servidor.
Quando nada é armazenado no servidor fica mais fácil escalar a aplicação pois ela não tem estado em lugar algum, o usuário que controla o estado. Para resolver esse problema foram criados os tokens (também é comum utilizar cookies para autenticação). Utilizando tokens o usuário que faz a requisição autêntica uma vez com as credenciais, (no nosso caso email e senha) e recebe um token que será usado para fazer requisições para a API.
Existem várias maneiras de fazer autenticação baseada em token, como JSON Web Token e OAuth. Aqui utilizamos JSON Web Token.
JSON Web Token, JWT, é um padrão aberto RFC 7519 que define uma maneira compacta de transportar objetos JSON seguramente entre partes. A confiança e segurança é alcançada por meio de assinatura digital utilizando algoritmos como HMAC ou chave pública/privada RSA ou ECDSA. Utilizaremos um modulo npm chamado jsonwebtoken que implementa a spec oficial do JWT e nos permite gerar e validar tokens no Node.js. Vamos começar pela instalação do módulo:
1 $ npm install jsonwebtoken@8.3.0
Adicionaremos duas propriedades necessárias para a configuração em config/default.json, primeiro a propriedade key dentro do objeto auth, essa será a chave secreta utilizada para assinar o token, e a propriedade tokenExpiresIn que se refere ao tempo de expiração do token. Na configuração abaixo definimos 7 dias, após esse prazo o usuário precisa gerar um novo token.
1 {
2 "database": {
3 "mongoUrl": "mongodb://localhost:27017/shop"
4 },
5 + "auth": {
6 + "key": "thisisaverysecurekey",
7 + "tokenExpiresIn": "7d"
8 + }
9 }
Criando Middlewares
Como será necessário validar o token do usuário em todas requisições vamos criar um middleware responsável por validar se a requisição possui um token, se sim, vamos decodificar o token e transformá-lo em um objeto que será adicionado na requisição para ser utilizado posteriormente pelo express-acl. Antes de tudo vamos começar pelo teste de unidade do middleware criando um arquivo em test/unit/middlewares/auth_spec.js.
1 import authMiddleware from '../../../src/middlewares/auth';
2 import jwt from 'jsonwebtoken';
3 import config from 'config';
4
5 describe('AuthMiddleware', () => {
6 it('should verify a JWT token and call the next middleware', done => {
7 const jwtToken = jwt.sign({ data: 'fake' }, config.get('auth.key'));
8 const reqFake = {
9 headers: {
10 'x-access-token': jwtToken
11 }
12 };
13 const resFake = {};
14 authMiddleware(reqFake, resFake, done);
15 });
16 });
Aqui começamos pelo primeiro happy path, o middleware deve receber uma requisição, verificar o token e chamar o próximo middleware. Note que aqui jwt.sign({ data: 'fake' }, config.get('auth.key')); geramos um token fake para ser utilizado no teste, esse token segue a mesma lógica que a aplicação utiliza.
Para validar o teste chamamos authMiddleware(reqFake, resFake, done); passamos o reqFake, que simula uma requisição contendo o header com o JWT, e um resFake vazio simulando o objeto de response que o middleware espera. Passamos também o callback done do Mocha como o next do middleware, dessa maneira quando o authMiddleware chamar o próximo middleware ele vai estar chamando o done do Mocha finalizando o teste.
Executando os testes de unidade agora teremos um erro
1 $ npm run test:unit
2
3 Error: Cannot find module '../../../src/middlewares/auth'
4 at Function.Module._resolveFilename (internal/modules/cjs/loader.js:548:15)
5 at Function.Module._load (internal/modules/cjs/loader.js:475:25)
O arquivo não foi encontrado, crie o arquivo em /src/middlewares/auth.js com o seguinte código:
1 export default (req, res, next) => {
2 next()
3 };
Aqui adicionamos lógica somente para o teste passar, passo green do TDD (espero que ainda lembrem!).
Executando os testes novamente a saída será:
1 AuthMiddleware
2 ✓ should verify a JWT token and call the next middleware
Agora é hora de aplicar a lógica de verdade. Altere o arquivo auth.js como abaixo:
1 +import jwt from 'jsonwebtoken';
2 +import config from 'config';
3
4 export default (req, res, next) => {
5 + const token = req.headers['x-access-token'];
6 +
7 + jwt.verify(token, config.get('auth.key'), (err, decoded) => {
8 + req.decoded = decoded;
9 + next(err);
10 + });
11 };
Primeiro passo pegamos o token x-access-token do header da requisição que e depois o verificamos utilizando o módulo jsonwebtoken. O primeiro parâmetro é o token, o segundo é a chave secreta da nossa aplicação para poder decodificar o token e o terceiro parâmetro é o callback que o jsonwebtoken espera.
Em seguida adicionamos o token decodificado ao objeto req referente a requisição req.decoded = decoded e chamamos o próximo middleware com next. Note que o err é passado como parâmetro para o próximo middleware, isso significa que se ocorrer algum erro na hora de decodificar o token o jsonwebtoken vai passar esse erro para nós e nós vamos passá-lo adiante para o próximo middleware, no futuro teremos um middleware somente para tratar erros.
Executando os testes de unidade novamente:
1 $ npm run test:unit
2
3 AuthMiddleware
4 ✓ should verify a JWT token and call the next middleware
É um pouco confuso passar o err para o próximo middleware, certo? Isso significa que nosso código possui dois caminhos, um de sucesso e um de falha, então devemos testar ambos. Vamos escrever um teste que simula um caso de erro adicionando o seguinte caso de teste em test/unit/middlewares/auth_spec.js.
1 import authMiddleware from '../../../src/middlewares/auth';
2 import jwt from 'jsonwebtoken';
3 import config from 'config';
4
5 describe('AuthMiddleware', () => {
6
7 it('should verify a JWT token and call the next middleware', done => {
8 const jwtToken = jwt.sign({ data: 'fake' }, config.get('auth.key'));
9 const reqFake = {
10 headers: {
11 'x-access-token': jwtToken
12 }
13 };
14 const resFake = {};
15 authMiddleware(reqFake, resFake, done);
16 });
17
18 + it('should call the next middleware passing an error when the token validation \
19 fails', done => {
20 + const reqFake = {
21 + headers: {
22 + 'x-access-token': 'invalid token'
23 + }
24 + };
25 + const resFake = {};
26 + authMiddleware(reqFake, resFake, err => {
27 + expect(err.message).to.eq('jwt malformed');
28 + done();
29 + });
30 + });
31 });
Passando um valor qualquer no header x-access-token fará com que o jsonwebtoken falhe e o nosso código var chamar o next passando o erro recebido pelo jsonwebtoken. No teste basta checarmos a mensagem:
expect(err.message).to.eq('jwt malformed')
“jwt malformed’ é a mensagem lançada pelo jsonwebtoken quando ele recebe um token que não segue o padrão do JWT.
Executando os testes, a saída será:
1 AuthMiddleware
2 ✓ should verify a JWT token and call the next middleware
3 ✓ should call the next middleware passing an error when the token validation fai\
4 ls
Ainda temos um caso para testar: na implementação atual o código espera que toda a requisição envie um token, mas a requisição para gerar o token não tem como passar um token!. Sendo assim, nosso código precisa verificar se existe token na requisição e chamar o próximo middleware sem executar a lógica de decodificação do jsonwebtoken. Vamos para o teste:
1 it('should call next middleware if theres no token', done => {
2 const reqFake = {
3 headers: {}
4 };
5 const resFake = {};
6 authMiddleware(reqFake, resFake, done);
7 });
Adicione o teste acima no teste unitário do auth middleware. Nele não var ser passado o header x-access-token, executando o teste:
1 $npm run test:unit
2
3 AuthMiddleware
4 ✓ should verify a JWT token and call the next middleware
5 ✓ should call the next middleware passing an error when the token validation fai\
6 ls
7 1) should call next middleware if theres no token
8
9
10 2 passing (16ms)
11 1 failing
12
13 1) AuthMiddleware should call next middleware if theres no token:
14 JsonWebTokenError: jwt must be provided
15 at Object.module.exports [as verify] (node_modules/jsonwebtoken/verify.js:39:1\
16 7)
17 at exports.default (src/middlewares/auth.js:8:7)
18 at Context.done (test/unit/middlewares/auth_spec.js:36:9)
O teste vai quebrar. A mensagem é jwt must be provided o que significa que o código tentou verificar o token, vamos alterar o código para não verificar o token quando ele não estiver na requisição. Altere o middleware auth.js:
1 import jwt from 'jsonwebtoken';
2 import config from 'config';
3
4 export default (req, res, next) => {
5 const token = req.headers['x-access-token'];
6 + if (!token) {
7 + return next();
8 + };
9 jwt.verify(token, config.get('auth.key'), (err, decoded) => {
10 req.decoded = decoded;
11 next(err);
12 });
13 };
Simples! Se não tiver token chamamos o próximo middleware. Os testes agora devem estar passando
1 AuthMiddleware
2 ✓ should verify a JWT token and call the next middleware
3 ✓ should call the next middleware passing an error when the token validation fai\
4 ls
5 ✓ should call next middleware if theres no token
Nosso middleware esta pronto.
O próximo passo é alterar o app.js adicionando o auth middleware ao express.
1 import express from 'express';
2 import bodyParser from 'body-parser';
3 import acl from 'express-acl';
4 import routes from './routes';
5 import database from '../config/database';
6 +import authMiddleware from './middlewares/auth.js';
7
8 const app = express();
9
10 acl.config({
11 baseUrl:'/',
12 path: 'config'
13 });
14
15 const configureExpress = () => {
16 app.use(bodyParser.json());
17 + app.use(authMiddleware);
18 app.use(acl.authorize.unless({path:['/users/authenticate']}));
19
20 app.use('/', routes);
21 app.database = database;
22
23 return app;
24 };
Os testes de integração não estão passando pois ainda não implementamos a autenticação. Vamos começar a implementação da autenticação pelo teste da rota em test/integration/users_spec.js
1 +describe('POST /users/authenticate', () => {
2 + context('when authenticating an user', () => {
3 + it('should generate a valid token', done => {
4 +
5 + request
6 + .post(`/users/authenticate`)
7 + .send({
8 + email: 'jhon@mail.com',
9 + password: '123password'
10 + })
11 + .end((err, res) => {
12 + expect(res.body).to.have.key('token');
13 + expect(res.status).to.eql(200);
14 + done(err);
15 + });
16 + });
17
18 + it('should return unauthorized when the password does not match', done => {
19
20 + request
21 + .post(`/users/authenticate`)
22 + .send({
23 + email: 'jhon@mail.com',
24 + password: 'wrongpassword'
25 + })
26 + .end((err, res) => {
27 + expect(res.status).to.eql(401);
28 + done(err);
29 + });
30 + });
31 + });
32 + });
Adicionamos 2 testes de integração, um para o caso de sucesso, onde espera-se que o usuário seja autenticado, e um para o caso de falha, onde não foi possível autenticar o usuário. Vamos executar os testes de integração:
1 $ npm run test:integration
Ignore os outros testes nesse momento, vamos focar apenas nos novos testes:
1 9) Routes: Users POST /users/authenticate when authenticating a user should generate\
2 a valid token:
3
4 Uncaught AssertionError: expected {} to have key 'token'
5 + expected - actual
6
7 -[]
8 +[
9 + "token"
10 +]
11
12 at Test.request.post.send.end (test/integration/routes/users_spec.js:120:38)
13 at Test.assert (node_modules/supertest/lib/test.js:179:6)
14 at Server.assert (node_modules/supertest/lib/test.js:131:12)
15 at emitCloseNT (net.js:1656:8)
16 at process._tickCallback (internal/process/next_tick.js:178:19)
17
18 10) Routes: Users POST /users/authenticate when authenticating an user should retu\
19 rn unauthorized when the password does not match:
20
21 Uncaught AssertionError: expected 404 to deeply equal 401
22 + expected - actual
23
24 -404
25 +401
26
27 at Test.request.post.send.end (test/integration/routes/users_spec.js:135:35)
28 at Test.assert (node_modules/supertest/lib/test.js:179:6)
29 at Server.assert (node_modules/supertest/lib/test.js:131:12)
30 at emitCloseNT (net.js:1656:8)
31 at process._tickCallback (internal/process/next_tick.js:178:19)
Estamos recebendo 404 por que a rota não existe, vamos criá-la em src/users/routes/users.js adicionando o seguinte código:
1 import express from 'express';
2 import UsersController from '../controllers/users';
3 import User from '../models/user';
4
5 const router = express.Router();
6 const usersController = new UsersController(User);
7
8 router.get('/', (req, res) => usersController.get(req, res));
9 router.get('/:id', (req, res) => usersController.getById(req, res));
10 router.post('/', (req, res) => usersController.create(req, res));
11 router.put('/:id', (req, res) => usersController.update(req, res));
12 router.delete('/:id', (req, res) => usersController.remove(req, res));
13 +router.post('/authenticate', (req, res) => usersController.authenticate(req, res));
14
15 export default router;
Se executarmos os testes novamente a saída será:
1 9) Routes: Users POST /users/authenticate when authenticating an user should generat\
2 e a valid token:
3
4 Uncaught AssertionError: expected {} to have key 'token'
5 + expected - actual
6
7 -[]
8 +[
9 + "token"
10 +]
11
12 at Test.request.post.send.end (test/integration/routes/users_spec.js:120:38)
13 at Test.assert (node_modules/supertest/lib/test.js:179:6)
14 at Server.assert (node_modules/supertest/lib/test.js:131:12)
15 at emitCloseNT (net.js:1656:8)
16 at process._tickCallback (internal/process/next_tick.js:178:19)
17
18 10) Routes: Users POST /users/authenticate when authenticating a user should retur\
19 n unauthorized when the password does not match:
20
21 Uncaught AssertionError: expected 500 to deeply equal 401
22 + expected - actual
23
24 -500
25 +401
26
27 at Test.request.post.send.end (test/integration/routes/users_spec.js:135:35)
28 at Test.assert (node_modules/supertest/lib/test.js:179:6)
29 at Server.assert (node_modules/supertest/lib/test.js:131:12)
30 at emitCloseNT (net.js:1656:8)
31 at process._tickCallback (internal/process/next_tick.js:178:19)
Executando os testes de integração novamente a saída será um erro 500, pois não temos o método authenticate no controller de users, vamos criá-lo começando com um teste unitário em test/unit/controllers/users_spec.js. O método authenticate recebe os parâmetros req e res. Esperamos que método chame o objeto res do express passando o token como resposta. Abaixo vamos ver como testar esse cenário:
1 describe('authenticate', () => {
2 it('should authenticate a user', done => {
3 const fakeReq = {
4 body: {}
5 };
6 const fakeRes = {
7 send: token => {
8 expect(token).to.eql({ token: 'fake-token' });
9 done();
10 }
11 };
12 const usersController = new UsersController({});
13 usersController
14 .authenticate(fakeReq, fakeRes);
15 });
16 });
O teste é conciso pois neste momento não sabemos como nosso futuro código será, apenas sabemos que a saída deve ser um token. Aqui seguiremos o TDD de forma evolutiva para entender como o design do código muda durante o desenvolvimento. É importante atentar no código acima para o fakeRes ele possui um método send que imita o objeto real do express e dentro desse método adicionamos o expect do teste. Esse método é um callback então quando ele for chamado no final do fluxo o expect será executado e saberemos se o teste passou.
Agora vamos implementar o metodo authenticate em src/controllers/users
1 async authenticate(req, res) {
2 return res.send({ token: 'fake-token' });
3 }
Aqui estamos no passo GREEN do TDD: o suficiente para o teste passar. Nosso código ainda não está gerando o token, vamos para o REFACTOR para aplicar a lógica necessária. Nesse caso eu já fiz alguns testes e imagino como a implementação vai ser, por isso posso escrever o teste:
Primeiramente vamos adicionar os seguintes imports:
1 import UsersController from '../../../src/controllers/users';
2 import sinon from 'sinon';
3 +import jwt from 'jsonwebtoken';
4 +import config from 'config';
5 +import bcrypt from 'bcrypt';
6 import User from '../../../src/models/user';
7
8
9 it('should authenticate a user', async() => {
10 const fakeUserModel = {
11 findOne: sinon.stub()
12 };
13 const user = {
14 name: 'Jhon Doe',
15 email: 'jhondoe@mail.com',
16 password: '12345',
17 role: 'admin'
18 };
19 const userWithEncryptedPassword = {...user, password: bcrypt.hashSync(user.pas\
20 sword, 10) };
21 fakeUserModel.findOne.withArgs({ email: user.email }).resolves({
22 ...userWithEncryptedPassword,
23 toJSON: () => ({ email: user.email })
24 });
25
26 const jwtToken = jwt.sign(userWithEncryptedPassword, config.get('auth.key'), {
27 expiresIn: config.get('auth.tokenExpiresIn')
28 });
29 const fakeReq = {
30 body: user
31 };
32 const fakeRes = {
33 send: sinon.spy()
34 };
35 const usersController = new UsersController(fakeUserModel);
36 await usersController.authenticate(fakeReq, fakeRes);
37 sinon.assert.calledWith(fakeRes.send, { token: jwtToken });
38 });
Quando enviados usuário e senha um json web token é gerado, este é o cenário que o teste acima representa. Se o teste não estiver fazendo sentido, não se preocupe, essa parte será mais fácil de entender quando chegarmos na implementação. Dois pontos são importantes no teste acima:
1 const userWithEncryptedPassword = {...user, password: bcrypt.hashSync(user.password,\
2 10) };
Aqui geramos um hash da senha para poder simular um usuário no banco de dados, para nao tem que escrever todas as propriedades do objeto user novamente utilizamos spread operator para clonar o objecto utilizando … na frente do objeto que queremos clonar e substituindo a propriedade password pela nova com o valor encriptado pelo bcrypt.
Na sequência, usando o stub no método findOne do fakeUserModel o usuário é retornado com o password. O método toJSON é usado para simular o método existente no Mongoose.
1 fakeUserModel.findOne.withArgs({ email: user.email }).resolves({
2 ...userWithEncryptedPassword,
3 toJSON: () => ({ email: user.email })
4 });
O próximo é:
1 const jwtToken = jwt.sign(userWithEncryptedPassword, config.get('auth.key'), {
2 expiresIn: config.get('auth.tokenExpiresIn')
3 });
Aqui é gerado um JWT baseado nos dados falsos de usuário e com a senha encriptada, assim devemos ter o mesmo JWT que o código vai gerar e será possível comparar o token no teste.
Agora vamos fazer a implementação do código no usersController, começando pela importação das bibliotecas:
1 + import jwt from 'jsonwebtoken';
2 + import config from 'config';
3 + import bcrypt from 'bcrypt';
Agora, vamos reescrever o método authenticate:
1 async authenticate(req, res) {
2 const { email, password } = req.body;
3 const user = await this.User.findOne({ email });
4 if(!user.password == bcrypt.compareSync(password, user.password)) {
5 // To be implemented
6 return;
7 }
8 const token = jwt.sign(
9 {
10 name: user.name,
11 email: user.email,
12 password: user.password,
13 role: user.role
14 },
15 config.get('auth.key'),
16 {
17 expiresIn: config.get('auth.tokenExpiresIn')
18 }
19 );
20 res.send({ token });
21 }
O código a seguir busca um usuário no banco de dados baseado no email provido, em seguida compara as senhas e caso sejam iguais será gerado um token para o usuário, caso contrário retornará um erro. Agora os testes de unidade devem estar passando.
1 $ npm run test:unit
2
3 Controller: Users
4 authenticate
5 ✓ should authenticate a user
Agora vamos aos testes de integração:
1 $ npm run test:integration
A saida sera:
1 3 passing (689ms)
2 9 failing
Os testes devem estar falhando pois as rotas esperam receber um token. Vamos deixar os testes de integração de lado nesse momento e seguir nossa implementação interna. Os testes de integração servem para garantir que a integração entre os componentes esta funcionando, vamos voltar a eles quando terminarmos a implementação interna dos testes de unidade.
Voltando aos testes unitários, o próximo passo é testar um caminho de erro. Um caminho de erro pode ser quando o usuário não for encontrado ou quando a senha não bater. Vamos adicionar um novo caso de teste:
1 it('should return 401 when the user can not be found', async () => {
2 const fakeUserModel = {
3 findOne: sinon.stub()
4 };
5 fakeUserModel.findOne.resolves(null);
6 const user = {
7 name: 'Jhon Doe',
8 email: 'jhondoe@mail.com',
9 password: '12345',
10 role: 'admin'
11 };
12 const fakeReq = {
13 body: user
14 };
15 const fakeRes = {
16 sendStatus: sinon.spy()
17 };
18 const usersController = new UsersController(fakeUserModel);
19
20 await usersController.authenticate(fakeReq, fakeRes);
21 sinon.assert.calledWith(fakeRes.sendStatus, 401);
22 });
Executando o teste teremos o seguinte erro:
1 25 passing (264ms)
2 1 failing
3
4 1) Controller: should return 401 when the user can not be found:
5 TypeError: Cannot read property 'toJSON' of null
6 at User.findOne.then.user (src/controllers/users.js:62:37)
Vamos à implementação:
1 async authenticate(req, res) {
2 const { email, password } = req.body;
3 + try {
4 const user = await this.User.findOne({ email });
5 if (!user.password == bcrypt.compareSync(password, user.password)) {
6 - // To be implemented
7 - return;
8 + throw new Error('User Unauthorized');
9 }
10 const token = jwt.sign(
11 {
12 name: user.name,
13 email: user.email,
14 password: user.password,
15 role: user.role
16 },
17 config.get('auth.key'),
18 {
19 expiresIn: config.get('auth.tokenExpiresIn')
20 }
21 );
22 res.send({ token });
23 + } catch (err) {
24 + res.sendStatus(401);
25 + }
26 }
Não estamos passando pelo passo GREEN do TDD, estamos fazendo a implementação direta pois, neste caso, é bem simples. Aqui verificamos se o usuário existe, se não existir retornamos um erro 401.
Os testes devem estar passando agora, podemos seguir em frente e testar o comportamento da senha:
1 it('should return 401 when the password does not match', async () => {
2 const fakeUserModel = {
3 findOne: sinon.stub()
4 };
5 const user = {
6 name: 'Jhon Doe',
7 email: 'jhondoe@mail.com',
8 password: '12345',
9 role: 'admin'
10 };
11 const userWithDifferentPassword = {
12 ...user,
13 password: bcrypt.hashSync('another_password', 10)
14 };
15 fakeUserModel.findOne.withArgs({ email: user.email }).resolves({
16 ...userWithDifferentPassword
17 });
18 const fakeReq = {
19 body: user
20 };
21 const fakeRes = {
22 sendStatus: sinon.spy()
23 };
24 const usersController = new UsersController(fakeUserModel);
25
26 await usersController.authenticate(fakeReq, fakeRes);
27 sinon.assert.calledWith(fakeRes.sendStatus, 401);
28 });
A única parte diferente nesse bloco de código é a seguinte:
1 const userWithDifferentPassword = {
2 ...user,
3 password: bcrypt.hashSync('another_password', 10)
4 };
5 fakeUserModel.findOne.withArgs({ email: user.email }).resolves({
6 ...userWithDifferentPassword
7 });
Aqui simulamos um cenário onde as senhas não batem. Quando isso acontece devemos retornar um erro 401 para o usuário informando que ele não pôde ser autenticado.
Neste momento os testes devem estar passando.
O próximo passo será extrair a lógica de autenticação para um serviço separado, para isso vamos começar pelo teste unitário em test/unit/services/auth_spec.js:
1 import AuthService from '../../../src/services/auth';
2
3
4 describe('Service: Auth', () => {
5 context('authenticate', () => {
6 it('should authenticate an user', () => {
7 });
8 });
9 });
No primeiro passo criamos o arquivo do teste com apenas o mínimo e por isso teremos o seguinte erro:
1 Error: Cannot find module '../../../src/services/auth'
Vamos criar o AuthService em src/services/auth.js, e então vamos melhorar nosso teste adicionando o comportamento que esperamos:
1 import AuthService from '../../../src/services/auth';
2 import bcrypt from 'bcrypt';
3 import Util from 'util';
4 import sinon from 'sinon';
5
6 const hashAsync = Util.promisify(bcrypt.hash);
7
8 describe('Service: Auth', () => {
9 context('authenticate', () => {
10 it('should authenticate a user', async() => {
11 const fakeUserModel = {
12 findOne: sinon.stub()
13 };
14 const user = {
15 name: 'John',
16 email: 'jhondoe@mail.com',
17 password: '12345'
18 };
19
20 const authService = new AuthService(fakeUserModel);
21 const hashedPassword = await hashAsync('12345', 10);
22 const userFromDatabase = { ...user,
23 password: hashedPassword
24 };
25
26 fakeUserModel.findOne.withArgs({ email: 'jhondoe@mail.com' }).resolves(userFro\
27 mDatabase);
28
29 const res = await authService.authenticate(user);
30
31 expect(res).to.eql(userFromDatabase);
32 });
33 });
34 });
Não há nada de novo nesse cenário, é o mesmo teste feito no users controller authenticate, então vamos copiar a lógica do users_controller no método authenticate. O código deve ficar assim no AuthService:
1 import bcrypt from 'bcrypt';
2 import jwt from 'jsonwebtoken';
3 import config from 'config';
4
5 class Auth {
6 constructor(User) {
7 this.User = User;
8 }
9
10 async authenticate(data) {
11 const user = await this.User.findOne({email: data.email});
12
13 if(!user || !(await bcrypt.compare(data.password, user.password))) {
14 return false;
15 }
16
17 return user;
18 }
19 }
20
21 export default Auth;
Os testes de unidade agora devem estar passando:
1 Service: Auth
2 authenticate
3 ✓ should authenticate an user
Vamos adicionar mais um teste de unidade para o caso onde as senhas não batem:
1 it('should return false when the password does not match', async () => {
2 const user = {
3 email: 'jhondoe@mail.com',
4 password: '12345'
5 };
6 const fakeUserModel = {
7 findOne: sinon.stub()
8 };
9 fakeUserModel.findOne.resolves({ email: user.email, password: 'aFakeHashedPass\
10 word' });
11 const authService = new AuthService(fakeUserModel);
12 const response = await authService.authenticate(user);
13
14 expect(response).to.be.false;
15 });
Com os teste de unidade prontos o próximo passo será atualizar o código no método authenticate do users controller, para utilizar o AuthService. Para isso serão necessárias algumas alterações, pois precisamos passar o AuthService para o UsersController como dependência. Vamos começar alterando o arquivo de rota onda o UsersController é construído adicionando a dependência ao construtor.
1 import express from 'express';
2 import UsersController from '../controllers/users';
3 import User from '../models/user';
4 +import AuthService from '../services/auth';
5
6 const router = express.Router();
7 - const usersController = new UsersController(User);
8 + const usersController = new UsersController(User, AuthService);
Próximo é passo alterar o Users Controller:
1 class UsersController {
2 - constructor(User) {
3 + constructor(User, AuthService) {
4 this.User = User;
5 + this.AuthService = AuthService;
6 };
Agora vamos alterar o metodo authenticate para utilizar o AuthService, o método deve ficar assim:
1 async authenticate(req, res) {
2 const authService = new this.AuthService(this.User);
3 const user = await authService.authenticate(req.body);
4 if(!user) {
5 return res.sendStatus(401);
6 }
7 const token = jwt.sign({
8 name: user.name,
9 email: user.email,
10 password: user.password,
11 role: user.role
12 }, config.get('auth.key'), {
13 expiresIn: config.get('auth.tokenExpiresIn')
14 });
15 return res.send({ token });
16 }
Se executarmos os testes de unidade os testes do método authenticate do users controller estarão quebrando, pois mudamos o código, antes de qualquer alteração nesse teste vamos executar os testes de integração para garantir que a resposta final ainda é a mesma. Se executarmos o teste de integração agora vamos receber um erro do nacl dizendo que não temos permissão para fazer a request, nesse momento entramos em um dilema clássico no mundo dos testes onde é necessário fazer mais de uma alteração para conseguir seguir em frente. Para que os nossos testes de integração passem precisamos gerar um JWT e adicionar na request, já possuímos um AuthService então vamos adicionar essa lógica lá para que seja reutilizável no futuro. Sempre começando pelo teste, vamos adicionar o seguinte no teste de unidade do AuthService:
1 import AuthService from '../../../src/services/auth';
2 import bcrypt from 'bcrypt';
3 import Util from 'util';
4 import sinon from 'sinon';
5 + import jwt from 'jsonwebtoken';
6 + import config from 'config';
Abaixo do contexto do authenticate adicione o seguinte caso de teste:
1 context('generateToken', () => {
2 it('should generate a JWT token from a payload', () => {
3 const payload = {
4 name: 'John',
5 email: 'jhondoe@mail.com',
6 password: '12345'
7 };
8 const expectedToken = jwt.sign(payload, config.get('auth.key'), {
9 expiresIn: config.get('auth.tokenExpiresIn')
10 });
11 const generatedToken = AuthService.generateToken(payload);
12 expect(generatedToken).to.eql(expectedToken);
13 });
14 });
Nada de novo aqui, essa á a mesma lógica utilizada para gerar o token no authenticate do UsersController, no futuro vamos refatorar este método para usar o generateToken do AuthService também. Executando os testes de unidade agora eles estão quebrando, afinal esse método ainda não existe no AuthService, vamos criá-lo agora:
1 import bcrypt from 'bcrypt';
2 + import jwt from 'jsonwebtoken';
3 + import config from 'config';
Adicione o seguinte método, logo abaixo do método authenticate.
1 static generateToken(payload) {
2 return jwt.sign(payload, config.get('auth.key'), {
3 expiresIn: config.get('auth.tokenExpiresIn')
4 });
5 }
Agora os testes de unidade para o AuthService devem estar passando (os testes do UsersController vão estar falhando e isso é esperado):
1 Service: Auth
2 generateToken
3 ✓ should generate a JWT token from a payload
Agora vamos adicionar a lógica de gerar token aos testes de integração de user:
1 import User from '../../../src/models/user';
2 + import AuthService from '../../../src/services/auth';
Adicione a seguinte linha às definições de constants:
1 const expectedAdminUser = {
2 _id: defaultId,
3 name: 'Jhon Doe',
4 email: 'jhon@mail.com',
5 role: 'admin'
6 };
7 + const authToken = AuthService.generateToken(expectedAdminUser);
Aqui estamos gerando um JWT manualmente, ele está sendo adicionado às requisições logo abaixo.
1 request
2 .get('/users')
3 + .set({'x-access-token': authToken})
4
5 request
6 .get(`/users/${defaultId}`)
7 + .set({'x-access-token': authToken})
8
9 request
10 .post('/users')
11 + .set({'x-access-token': authToken})
12
13 request
14 .put(`/users/${defaultId}`)
15 + .set({'x-access-token': authToken})
16
17
18 request
19 .delete(`/users/${defaultId}`)
20 + .set({'x-access-token': authToken})
Depois dessa alteração os testes de integração de users devem estar passando:
1 Routes: Products
2 GET /products
3 1) should return a list of products
4 when an id is specified
5 2) should return 200 with one product
6 POST /products
7 when posting a product
8 3) should return a new product with status code 201
9 PUT /products/:id
10 when editing a product
11 ✓ should update the product and return 200 as status code
12 DELETE /products/:id
13 when deleting a product
14 4) should delete a product and return 204 as status code
15
16 Routes: Users
17 GET /users
18 ✓ should return a list of users
19 when an id is specified
20 ✓ should return 200 with one user
21 POST /users
22 when posting an user
23 ✓ should return a new user with status code 201
24 PUT /users/:id
25 when editing an user
26 ✓ should update the user and return 200 as status code
27 DELETE /users/:id
28 when deleting an user
29 ✓ should delete an user and return 204 as status code
30 when authenticating an user
31 ✓ should generate a valid token
32 ✓ should return unauthorized when the password does not match
33
34
35 8 passing (994ms)
36 4 failing
Não vamos nos preocupar com products agora, o que queremos ver conseguimos, o teste end 2 end está funcionando e isso significa que o users controller está com a lógica certa, podemos alterar o teste de unidade do método authenticate agora para que volte a passar, os testes devem ficar assim:
1 describe('authenticate', () => {
2 it('should authenticate a user', async () => {
3 const fakeUserModel = {};
4 const user = {
5 name: 'Jhon Doe',
6 email: 'jhondoe@mail.com',
7 password: '12345',
8 role: 'admin'
9 };
10 const userWithEncryptedPassword = {
11 ...user,
12 password: bcrypt.hashSync(user.password, 10)
13 };
14 class FakeAuthService {
15 authenticate() {
16 return Promise.resolve(userWithEncryptedPassword)
17 }
18 };
19
20 const jwtToken = jwt.sign(
21 userWithEncryptedPassword,
22 config.get('auth.key'),
23 {
24 expiresIn: config.get('auth.tokenExpiresIn')
25 }
26 );
27 const fakeReq = {
28 body: user
29 };
30 const fakeRes = {
31 send: sinon.spy()
32 };
33 const usersController = new UsersController(fakeUserModel, FakeAuthService);
34 await usersController.authenticate(fakeReq, fakeRes);
35 sinon.assert.calledWith(fakeRes.send, { token: jwtToken });
36 });
37
38
39 it('should return 401 when the user can not be found', async () => {
40 const fakeUserModel = {};
41 class FakeAuthService {
42 authenticate() {
43 return Promise.resolve(false)
44 }
45 };
46 const user = {
47 name: 'Jhon Doe',
48 email: 'jhondoe@mail.com',
49 password: '12345',
50 role: 'admin'
51 };
52 const fakeReq = {
53 body: user
54 };
55 const fakeRes = {
56 sendStatus: sinon.spy()
57 };
58 const usersController = new UsersController(fakeUserModel, FakeAuthService);
59
60 await usersController.authenticate(fakeReq, fakeRes);
61 sinon.assert.calledWith(fakeRes.sendStatus, 401);
62 });
O caso de teste “should return 401 when the password does not match” precisa ser removido pois ele nao está ciente da adição do AuthService, no mundo do TDD para fazer esse tipo de alteração precisamos estar seguros. Em nosso caso a maneira mais simples de ficarmos seguros é testar a lógica que compara as senhas no AuthService.
Os testes de unidade devem estar passando, isso que significa que o AuthService está tratando esse caso, agora estamos seguros para remover o caso de teste do users controller.
1 - it('should return 401 when the password does not match', async () => {
2 - const fakeUserModel = {
3 - findOne: sinon.stub()
4 - };
5 - const user = {
6 - name: 'Jhon Doe',
7 - email: 'jhondoe@mail.com',
8 - password: '12345',
9 - role: 'admin'
10 - };
11 - const userWithDifferentPassword = {
12 - ...user,
13 - password: bcrypt.hashSync('another_password', 10)
14 - };
15 - fakeUserModel.findOne.withArgs({ email: user.email }).resolves({
16 - ...userWithDifferentPassword
17 - });
18 - const fakeReq = {
19 - body: user
20 - };
21 - const fakeRes = {
22 - sendStatus: sinon.spy()
23 - };
24 - const usersController = new UsersController(fakeUserModel);
25 -
26 - await usersController.authenticate(fakeReq, fakeRes);
27 - sinon.assert.calledWith(fakeRes.sendStatus, 401);
28 - });
Precisamos alterar o método authenticate que vai utilizar o AuthService. Vamos começar alterando o teste should authenticate a user:
1 const userWithEncryptedPassword = {
2 ...user,
3 password: bcrypt.hashSync(user.password, 10)
4 };
5 + const jwtToken = jwt.sign(userWithEncryptedPassword,
6 + config.get('auth.key'),{
7 + expiresIn: config.get('auth.tokenExpiresIn')
8 + });
9
10 class FakeAuthService {
11 authenticate() {
12 return Promise.resolve(userWithEncryptedPassword)
13 }
14
15 + static generateToken() {
16 + return jwtToken;
17 + }
18 };
19
20 - const jwtToken = jwt.sign(
21 - Object.assign({}, user, { password: hashedPassword }),
22 - config.get('auth.key'),{
23 - expiresIn: config.get('auth.tokenExpiresIn')
24 - });
25 -
No código acima adicionamos um método fake para simular o generateToken do AuthService que retorna um token manualmente gerado. Se executarmos os testes de unidade agora eles vão estar quebrando, precisamos alterar o método authenticate para usar o generateToken no UsersController:
1 - const token = jwt.sign({
2 + const token = this.AuthService.generateToken({
3 name: user.name,
4 email: user.email,
5 password: user.password,
6 role: user.role
7 - }, config.get('auth.key'), {
8 - expiresIn: config.get('auth.tokenExpiresIn')
9 - });
10 + });
Vamos remover também as dependências que não serão mais necessárias:
1 -import jwt from 'jsonwebtoken';
2 -import config from 'config';
3 -import bcrypt from 'bcrypt';
Os testes de unidade devem estar passando.
Último passo é fixar os testes end 2 end da rota de products, vamos alterar o products_spec.js:
1 import Product from '../../../src/models/product';
2 + import AuthService from '../../../src/services/auth';
Agora vamos adicionar o expectedAdminUser para poder gerar o token, e a seguir o código para gerar o token
1 const expectedProduct = {
2 __v: 0,
3 _id: defaultId,
4 name: 'Default product',
5 description: 'product description',
6 price: 100
7 };
8 + const expectedAdminUser = {
9 + _id: defaultId,
10 + name: 'Jhon Doe',
11 + email: 'jhon@mail.com',
12 + role: 'admin'
13 + };
14 + const authToken = AuthService.generateToken(expectedAdminUser);
O próximo passo será adicionar o authToken nas requests:
1 request
2 .get('/products')
3 + .set({'x-access-token': authToken})
4 .end((err, res) => {
5 expect(res.body).to.eql([expectedProduct]);
6 done(err);
7
8
9 request
10 .get(`/products/${defaultId}`)
11 + .set({'x-access-token': authToken})
12 .end((err, res) => {
13 expect(res.statusCode).to.eql(200);
14 expect(res.body).to.eql([expectedProduct]);
15 request
16 .post('/products')
17 + .set({'x-access-token': authToken})
18 .send(newProduct)
19 .end((err, res) => {
20 expect(res.statusCode).to.eql(201);
21
22
23 request
24 .put(`/products/${defaultId}`)
25 + .set({'x-access-token': authToken})
26 .send(updatedProduct)
27 .end((err, res) => {
28 expect(res.status).to.eql(200);
29
30 request
31 .delete(`/products/${defaultId}`)
32 + .set({'x-access-token': authToken})
33 .end((err, res) => {
34 expect(res.status).to.eql(204);
35 done(err);
Pronto! Todos os testes devem estar passando
1 $ npm test
2
3 Controller: Products
4 get() products
5 ✓ should call send with a list of products
6 ✓ should return 400 when an error occurs
7 getById()
8 ✓ should call send with one product
9 create() product
10 ✓ should call send with a new product
11 when an error occurs
12 ✓ should return 422
13 update() product
14 ✓ should respond with 200 when the product has been updated
15 when an error occurs
16 ✓ should return 422
17 delete() product
18 ✓ should respond with 204 when the product has been deleted
19 when an error occurs
20 ✓ should return 400
21
22 Controller: Users
23 get() users
24 ✓ should call send with a list of users
25 ✓ should return 400 when an error occurs
26 getById()
27 ✓ should call send with one user
28 create() user
29 ✓ should call send with a new user
30 when an error occurs
31 ✓ should return 422
32 update() user
33 ✓ should respond with 200 when the user has been updated
34 when an error occurs
35 ✓ should return 422
36 delete() user
37 ✓ should respond with 204 when the user has been deleted
38 when an error occurs
39 ✓ should return 400
40 authenticate
41 ✓ should authenticate a user
42 ✓ should return 401 when theres no user
43
44 AuthMiddleware
45 ✓ should verify a JWT token and call the next middleware
46 ✓ should call the next middleware passing an error when the token validation fai\
47 ls
48 ✓ should call next middleware if theres no token
49
50 Service: Auth
51 authenticate
52 ✓ should authenticate a user
53 ✓ should return false when the password does not match
54 generateToken
55 ✓ should generate a JWT token from a payload
56
57
58 26 passing (259ms)
59
60
61 > node-book@1.0.0 test:integration /Users/wneto/Dev/building-testable-apis-with-node\
62 js-code
63 > NODE_ENV=test mocha --opts test/integration/mocha.opts test/integration/**/*_spec.\
64 js
65
66 Routes: Products
67 GET /products
68 ✓ should return a list of products
69 when an id is specified
70 ✓ should return 200 with one product
71 POST /products
72 when posting a product
73 ✓ should return a new product with status code 201
74 PUT /products/:id
75 when editing a product
76 ✓ should update the product and return 200 as status code
77 DELETE /products/:id
78 when deleting a product
79 ✓ should delete a product and return 204 as status code
80
81 Routes: Users
82 GET /users
83 ✓ should return a list of users
84 when an id is specified
85 ✓ should return 200 with one user
86 POST /users
87 when posting an user
88 ✓ should return a new user with status code 201
89 PUT /users/:id
90 when editing an user
91 ✓ should update the user and return 200 as status code
92 DELETE /users/:id
93 when deleting an user
94 ✓ should delete an user and return 204 as status code
95 when authenticating an user
96 ✓ should generate a valid token
97 ✓ should return unauthorized when the password does not match
98
99
100 12 passing (1s)
Código deste capitulo está aqui no github