Asegurando APIs en Symfony con JWT

Post on 22-Jan-2018

1.016 views 13 download

Transcript of Asegurando APIs en Symfony con JWT

deSymfony 30 junio - 1 julio 2017 Castellón

ASEGURANDO APIS EN SYMFONY CON JWTNacho Martín

deSymfony

¡Muchas gracias a nuestros patrocinadores!

Programo en Limenius

Casi todos los proyectos necesitan una API

Hacemos aplicaciones a medida con Symfony y React

JWT es una buena herramienta para asegurarlas

Por qué es esto necesario

Por qué es esto necesario

¿Por qué no está todo inventado?

Esta charla va de alivios

Esta charla va de alivios

Autenticación con Cookies

Cliente (navegador) Servidor

BD

POST /login_check

username: "nacho", password: "patata"

Autenticación con Cookies

Cliente (navegador) Servidor

BD

Obtiene usuario Verifica credenciales

Guarda sesión

Autenticación con Cookies

Cliente (navegador) Servidor

BD

Envía cookie al cliente

Set-Cookie: PHPSESSIONID=HOLA…

Autenticación con Cookies

Cliente (navegador) Servidor

BD

Usa cookie para identificarse

Cookie: PHPSESSIONID=HOLA

Problemas con Cookies

Problemas con CORS

Implementación no natural en algunos clientes

Hay que protegerse contra CSRF

Requiere una gestión de sesión y pensar en cómo escalar

Mantienen un estado (sesión)

Estado en REST

[…] communication must be stateless in nature, […], such that each request from client to server must contain all of the information necessary to understand the request, and cannot take advantage of any stored context on the

server. Session state is therefore kept entirely on the client.

Cómo siempre, hay razones

Requiere una gestión de sesión, y pensar en cómo escalar

¿Qué hacer con un balanceador de carga?

¿Dónde guardar las sesiones?

Alternativas para APIs

Junio 2012

JSON Web Tokens (JWT)

JOSEJSON Object Signing and Encryption{ JWT

JWAJWSJWEJWK

JOSEJSON Object Signing and Encryption{ JWT

JWAJWSJWEJWK

JWT solo es un formato de tokens

Pero muchas veces decimos “usar JWT” Para referirnos a una forma de trabajar con ellos

https://www.flickr.com/photos/tokencompany/8073379662

Autenticación con JWT

Cliente (navegador) Servidor

BD

POST /login_check

username: "nacho", password: "patata"

Autenticación con JWT

Cliente (navegador) Servidor

BD

Obtiene usuario Verifica credenciales

Guarda sesión?

Autenticación con JWT

Cliente (navegador) Servidor

BD

Envía token al cliente

{“token”:"tomaUnToken"}

Autenticación con JWT

Cliente (navegador) Servidor

BD

Usa token para identificarse

Authorization: Bearer tomaUnToken

Uso en JavaScript

fetch(baseUrl + '/admin/api/recipes', { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', 'Authorization': 'Bearer '+token, }, body: JSON.stringify( data )})

Almacenamiento en JavaScript

window.localStorage.setItem('access_token', token)

window.localStorage.getItem('access_token')

Almacenamiento en JavaScript

window.localStorage.setItem('access_token', token)

window.localStorage.getItem('access_token')

Ya no tenemos CSRF, ahora ojo con ataques XSS

El famoso token

eyJhbGciOiJSUzI1NiJ9.eyJyb2xlcyI6WyJST0xFX1VTRVIiXSwidXNlcm5hbWUiOiJuYWNobyIsImlhdCI6MTQ5ODE0MjA3Niw

iZXhwIjoxNDk4MTQ1Njc2fQ

eyJhbGciOiJSUzI1NiJ9.eyJyb2xlcyI6WyJST0xFX1VTRVIiXSwidXNlcm5hbWUiOiJuYWNobyIsImlhdCI6MTQ5ODE0MjA3Niw

iZXhwIjoxNDk4MTQ1Njc2fQ

El famoso token

eyJhbGciOiJSUzI1NiJ9.eyJyb2xlcyI6WyJST0xFX1VTRVIiXSwidXNlcm5hbWUiOiJuYWNobyIsImlhdCI6MTQ5ODE0MjA3Niw

iZXhwIjoxNDk4MTQ1Njc2fQ

Hay un puntoEl famoso token

El famoso token

eyJhbGciOiJSUzI1NiJ9.eyJyb2xlcyI6WyJST0xFX1VTRVIiXSwidXNlcm5hbWUiOiJuYWNobyIsImlhdCI6MTQ5ODE0MjA3Niw

iZXhwIjoxNDk4MTQ1Njc2fQ

El famoso token

eyJhbGciOiJSUzI1NiJ9.eyJyb2xlcyI6WyJST0xFX1VTRVIiXSwidXNlcm5hbWUiOiJuYWNobyIsImlhdCI6MTQ5ODE0MjA3Niw

iZXhwIjoxNDk4MTQ1Njc2fQ

{“alg":"none"}

{ “username":"nacho", “iat":1498142076, “exp”:1498145676 }

Base64Url

eyJhbGciOiJSUzI1NiJ9{“alg":"none"}

No está cifrado (todos lo pueden leer) Solo está codificado

Esto solo sirve para que pueda viajar en URLs Y en cualquier sitio donde puedan viajar strings

Base64Url{ “username":"nacho", “iat":1498142076, “exp”:1498145676 }

¿Me puedo fiar de esto?

Base64Url{ “username":"nacho", “iat":1498142076, “exp”:1498145676 }

¿Me puedo fiar de esto?

El token completo

eyJhbGciOiJSUzI1NiJ9.eyJyb2xlcyI6WyJST0xFX1VTRVIiXSwidXNlcm5hbWUiOiJuYWNobyIsImlhdCI6MTQ5ODE0MjA3NiwiZXhwIjoxNDk4MTQ1Njc2fQ.jomtnO16Tik6jxU_0HmsXFxbUSyBoNTu8RVSbQ9jEKUfYEFTG8QZrsSpNl5uzlXf-

hP2zx1YmPTow1jXGyoFjV6Nk1e7pFlw2s8fSrIZpT2ZFBuVPefKhSWXYoSUHGZWtMFMU-yghnWA6tlFD5UcJiDQ3ZlUCbLxOlDdygUoC841aR9R87otefdQUEKY_faGq1Tl-

KxJfjndG4HENgC7M52JaX5xFKmOlI1mKXqDvVOrCTil3yOcqxQv94SZjqhG5V7NLaaslMDXVl4fzJC-WWE_Eo0xzfOSxMAZ7NBEvha207pjl8FAszQDZ0uuqxfPLqb4QnpALnFAGip4hlu28wRccAsWJQ6uSYtClrE9Kwt7Vlo4PrPX3zqMb_YaRI1QUco6qjj2AsCf18-0f5XvgqrwSoU_73w4pgsj7rUyft9mwe3tiUYCoUP_dKFJfcz_ofHScpsWfFJ4lD4TIzpKf1LfLFwRUcpQuJdR

K8-1C_x5dJILrO2fSKZbxFCq_-zB2UHmbH8eFQQYxIpS4eDjFDZTeFLOzruapM10taDQ8buGOyVUx9vwTJoWq9dFuqVAdhFc9h6iXNy0QzI46uvN-

en1n6KVsKTfaLecvCYhIIt32Z5mYD3YgDEeRnLZ5TIgykiVNL9SZCGphzv6h5MEs_xQyDo6XOsu92tPtbqyvI4

El token completo

eyJhbGciOiJSUzI1NiJ9.eyJyb2xlcyI6WyJST0xFX1VTRVIiXSwidXNlcm5hbWUiOiJuYWNobyIsImlhdCI6MTQ5ODE0MjA3NiwiZXhwIjoxNDk4MTQ1Njc2fQ.jomtnO16Tik6jxU_0HmsXFxbUSyBoNTu8RVSbQ9jEKUfYEFTG8QZrsSpNl5uzlXf-

hP2zx1YmPTow1jXGyoFjV6Nk1e7pFlw2s8fSrIZpT2ZFBuVPefKhSWXYoSUHGZWtMFMU-yghnWA6tlFD5UcJiDQ3ZlUCbLxOlDdygUoC841aR9R87otefdQUEKY_faGq1Tl-

KxJfjndG4HENgC7M52JaX5xFKmOlI1mKXqDvVOrCTil3yOcqxQv94SZjqhG5V7NLaaslMDXVl4fzJC-WWE_Eo0xzfOSxMAZ7NBEvha207pjl8FAszQDZ0uuqxfPLqb4QnpALnFAGip4hlu28wRccAsWJQ6uSYtClrE9Kwt7Vlo4PrPX3zqMb_YaRI1QUco6qjj2AsCf18-0f5XvgqrwSoU_73w4pgsj7rUyft9mwe3tiUYCoUP_dKFJfcz_ofHScpsWfFJ4lD4TIzpKf1LfLFwRUcpQuJdR

K8-1C_x5dJILrO2fSKZbxFCq_-zB2UHmbH8eFQQYxIpS4eDjFDZTeFLOzruapM10taDQ8buGOyVUx9vwTJoWq9dFuqVAdhFc9h6iXNy0QzI46uvN-

en1n6KVsKTfaLecvCYhIIt32Z5mYD3YgDEeRnLZ5TIgykiVNL9SZCGphzv6h5MEs_xQyDo6XOsu92tPtbqyvI4

header.claims.signature

Pero sí está firmado (los intermediarios no lo pueden modificar sin que nos enteremos)

No está cifrado

Header

{ “typ”:”JWT”, “alg”:”RS256” }

Header

{ “typ”:”JWT”, “alg”:”RS256” }

Tipo de token

Header

{ “typ”:”JWT”, “alg”:”RS256” }

Tipo de token

Algoritmo de hashing

¿Qué algoritmo usar?

JWA (rfc7518)

Diferencia simétrico/asimétrico

Simétrico: usamos la misma clave para firmar y validar.

Asimétrico: usamos distintas claves para firmar y validar.

RS256 RS384 RS512

Algoritmos

HS256 HS384 HS512

ES256 ES384 ES512

HMAC ECDSA RSA

PS256 PS384 PS348

RSASSA-PSS

none

RS256 RS384 RS512

Algoritmos

HS256 HS384 HS512

ES256 ES384 ES512

HMAC ECDSA RSA

PS256 PS384 PS348

RSASSA-PSS

none

Simétrico

RS256 RS384 RS512

Algoritmos

HS256 HS384 HS512

ES256 ES384 ES512

HMAC ECDSA RSA

PS256 PS384 PS348

RSASSA-PSS

none

Simétrico Asimétricos

RS256 RS384 RS512

Algoritmos

HS256 HS384 HS512

ES256 ES384 ES512

HMAC ECDSA

RS256 RS384 RS512

RSA

PS256 PS384 PS348

RSASSA-PSS

none

Simétrico Asimétricos

Claims

{ “username":"nacho", “iat":1498142076, “exp”:1498145676 }

Registered claimsjti

iss

aud

sub

iat

exp

nbf

Id del token: String

Issuer (emisor): StringOrUri

Audiencia: StringOrUri

Subject (tema): StringOrUri

Cuándo se creó: NumericDate

Cuándo expira: NumericDate

Tiempo hasta válidez: NumericDate

Custom claims

{ “username":"nacho", “iat":1498142076, “exp”:1498145676 }

Podemos añadir lo que queramos.

Solo hay que tener en cuenta: Ser conciso.

Los datos no van cifrados (el cliente los ve).

Firma

Signature = algoritmo(payload, key)

Payload = Base64URL(headers)+ ”.”+ Base64URL(claims)

JWS (rfc7515)

JWT.IO

Importante: Usar TLS (“SSL”)

Ventajas de JWT

Caso: Mi app en JavaScript se ve distinta dependiendo del rol del usuario

Con Cookies

Cliente Servidor

BD

Cookie: PHPSESSIONID=HOLA

Con Cookies

Cliente Servidor

BD

Cookie: PHPSESSIONID=HOLA

Tengo una cookie pero no sé qué usuario soy ni qué permisos tengo

Con Cookies

Cliente Servidor

BD

Cookie: PHPSESSIONID=HOLA

Con Cookies

Cliente Servidor

BD

Cookie: PHPSESSIONID=HOLA

Obtener usuario

Con Cookies

Cliente Servidor

BD

Cookie: PHPSESSIONID=HOLA

Obtener usuario

Con Cookies

Cliente Servidor

BD

Cookie: PHPSESSIONID=HOLA

Obtener usuario

Con Cookies

Cliente Servidor

BD

Cookie: PHPSESSIONID=HOLA

Obtener usuario

Con Cookies

Cliente Servidor

BD

Cookie: PHPSESSIONID=HOLA

Obtener usuario

Y ahora mostramos

Con JWT

Cliente Servidor

BD

Token: ejh9…

Con JWT

Cliente Servidor

BD

Token: ejh9…

{ “username":"nacho", “iat":1498142076, “exp”:1498145676 }

Con JWT

Cliente Servidor

BD

Token: ejh9…

{ “username":"nacho", “iat":1498142076, “exp”:1498145676 }

Tenemos todos los datos

Con JWT

Cliente

Token: ejh9…

{ “username":"nacho", “iat":1498142076, “exp”:1498145676 }

Tenemos todos los datos

MicroserviciosAuth

Clave privada y pública

ConsultaClave pública

ClienteClave pública

PedidosClave pública

MicroserviciosAuth

Clave privada y pública

ConsultaClave pública

ClienteClave pública

PedidosClave pública

{ “aud”:”consulta", }

MicroserviciosAuth

Clave privada y pública

ConsultaClave pública

ClienteClave pública

PedidosClave pública

{ “aud”:”consulta", }

MicroserviciosAuth

Clave privada

Consulta

ClienteClave pública

Clave pública

Pedidos

Gateway

Soporte en PHP

lcobucci/jwt

Crear tokensuse Lcobucci\JWT\Builder;use Lcobucci\JWT\Signer\Keychain;use Lcobucci\JWT\Signer\Rsa\Sha256;

$signer = new Sha256();$keychain = new Keychain();$privateKey = $keychain->getPrivateKey('file://path');

$token = (new Builder())->setIssuer('http://example.com') ->setAudience('http://example.org') ->setId('4f1g23a12aa', true) ->setIssuedAt(time()) ->setNotBefore(time() + 60) ->setExpiration(time() + 3600) ->set('uid', 1) ->sign($signer, $privateKey) ->getToken();

Validar tokensuse Lcobucci\JWT\Signer\Keychain;use Lcobucci\JWT\Signer\Rsa\Sha256;use Lcobucci\JWT\ValidationData;

$signer = new Sha256();$keychain = new Keychain();$publicKey = $keychain->getPublicKey('file://path');

$data = new ValidationData();$data->setIssuer('http://example.com');$data->setAudience('http://example.org');$data->setId('4f1g23a12aa');

$token->validate($data);$token->verify($signer, $publicKey);

LexikJWTAuthenticationBundle

Instalación

composer require lexik/jwt-authentication-bundle

$bundles = array( // ... new Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle(), // ...);

app/config/AppKernel.php

Crear claves

$ openssl genrsa -out var/jwt/private.pem -aes256 4096 $ openssl rsa -pubout -in app/jwt/private.pem -out var/jwt/public.pem

Crear claves

$ openssl genrsa -out var/jwt/private.pem -aes256 4096 $ openssl rsa -pubout -in app/jwt/private.pem -out var/jwt/public.pem

Clave privada

Crear claves

$ openssl genrsa -out var/jwt/private.pem -aes256 4096 $ openssl rsa -pubout -in app/jwt/private.pem -out var/jwt/public.pem

Clave privada

Clave pública

Configuraciónapp/config/parameters.yml

app/config/config.yml

lexik_jwt_authentication: private_key_path: %jwt_private_key_path% public_key_path: %jwt_public_key_path% pass_phrase: %jwt_key_pass_phrase% token_ttl: %jwt_token_ttl%

parameters: jwt_private_key_path: '%kernel.root_dir%/../var/jwt/private.pem' jwt_public_key_path: '%kernel.root_dir%/../var/jwt/public.pem' jwt_key_pass_phrase: patata jwt_token_ttl: 3600

Configuraciónapp/config/parameters.yml

app/config/config.yml

lexik_jwt_authentication: private_key_path: %jwt_private_key_path% public_key_path: %jwt_public_key_path% pass_phrase: %jwt_key_pass_phrase% token_ttl: %jwt_token_ttl% encoder: service: lexik_jwt_authentication.encoder.lcobucci

parameters: jwt_private_key_path: '%kernel.root_dir%/../var/jwt/private.pem' jwt_public_key_path: '%kernel.root_dir%/../var/jwt/public.pem' jwt_key_pass_phrase: patata jwt_token_ttl: 3600

Configuraciónapp/config/parameters.yml

app/config/config.yml

lexik_jwt_authentication: private_key_path: %jwt_private_key_path% public_key_path: %jwt_public_key_path% pass_phrase: %jwt_key_pass_phrase% token_ttl: %jwt_token_ttl% encoder: service: lexik_jwt_authentication.encoder.lcobucci

parameters: jwt_private_key_path: '%kernel.root_dir%/../var/jwt/private.pem' jwt_public_key_path: '%kernel.root_dir%/../var/jwt/public.pem' jwt_key_pass_phrase: patata jwt_token_ttl: 3600

Probablemente será default en algún punto

firewalls: login: pattern: ^/api/login stateless: true anonymous: true form_login: check_path: /api/login_check success_handler: lexik_jwt_authentication.handler.authentication_success failure_handler: lexik_jwt_authentication.handler.authentication_failure require_previous_session: false

api: pattern: ^/api stateless: true guard: authenticators: - lexik_jwt_authentication.jwt_token_authenticator

Security configapp/config/security.yml

firewalls: login: pattern: ^/api/login stateless: true anonymous: true form_login: check_path: /api/login_check success_handler: lexik_jwt_authentication.handler.authentication_success failure_handler: lexik_jwt_authentication.handler.authentication_failure require_previous_session: false

api: pattern: ^/api stateless: true guard: authenticators: - lexik_jwt_authentication.jwt_token_authenticator

Security configapp/config/security.yml

No asigna cookies

Routing config

app/config/routing.ymlapi_login_check: path: /api/login_check

Extender el bundle

EventosJWT_CREATED: Añadir/quitar datos a claims.

JWT_DECODED: Validaciones extra.

JWT_AUTHENTICATED: Añadir datos al token de Symfony.

AUTHENTICATION_FAILURE

JWT_INVALID

JWT_NOT_FOUND

JWT_EXPIRED} Cambiar respuestas.

AUTHENTICATION_SUCCESS

Añadir/quitar datos a payloadclass JWTCreatedListener{ public function onJWTCreated(JWTCreatedEvent $event) { $payload = $event->getData(); $user = $event->getUser(); if ($user->canOrder()) { $payload['aud'] = ‘pedidos'; }

$event->setData($payload); }}

Comprobaciones extraclass JWTDecodedListener{ public function onJWTDecoded(JWTDecodedEvent $event) { $payload = $event->getPayload();

if (!isset($payload['aud']) || $payload['aud'] !== 'pedidos') { $event->markAsInvalid(); } }}

Añadir atributo API a Token Sfclass JWTAuthenticatedListener{ public function onJWTAuthenticated(JWTAuthenticatedEvent $event) { $token = $event->getToken(); $token->setAttribute('api', true); }}

public function pagesAction(Request $request){ if ($this->get(‘security.token_storage') ->getToken() ->getAttribute(‘api')) { return new JsonResponse('hola usuario de api'); }}

¿Qué pasa si quiero…?

Tener diferentes estrategias en distintos firewalls Hacer algo muy particular

Security

AuthenticationProviderInterface

Security

AuthenticationProviderInterface

UserProvider

Security

AuthenticationEntryPointInterface

AuthenticationProviderInterface

UserProvider

Security

AuthenticationListener

AuthenticationEntryPointInterface

AuthenticationProviderInterface

UserProvider

Security

AuthenticationListener

AuthenticationEntryPointInterface

AuthenticationProviderInterface

UserProvider

Security

Events

AuthenticationListener

AuthenticationEntryPointInterface

AuthenticationProviderInterface

UserProvider

Security

EventsUserInterface

AuthenticationListener

AuthenticationEntryPointInterface

AuthenticationProviderInterface

AbstractToken

UserProvider

Security

EventsUserInterface

AuthenticationListener

AuthenticationEntryPointInterface

AuthenticationProviderInterface

AbstractToken

UserProvider

Security

VotersEvents

UserInterface

UserProvider

UserProvider

👌

AuthenticationListener

AuthenticationEntryPointInterface

AuthenticationProviderInterface

AbstractToken

UserProvider

Security

VotersEvents

UserInterface

Guard💂

👌

La interfaz GuardAuthenticator

interface GuardAuthenticatorInterface{ public function getCredentials(Request $request); public function getUser($credentials, UserProviderInterface $userProvider); public function checkCredentials($credentials, UserInterface $user); public function createAuthenticatedToken(UserInterface $user, $providerKey); public function onAuthenticationFailure(Request $request, AuthenticationException $exception); public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey); public function supportsRememberMe();}

La interfaz GuardAuthenticatorgetCredentials

getUser

checkCredentials

createAuthenticatedToken

onAuthenticationFailure

onAuthenticationSuccess

supportsRememberMe

La interfaz GuardAuthenticatorgetCredentials

getUser

checkCredentials

createAuthenticatedToken

onAuthenticationFailure

onAuthenticationSuccess

supportsRememberMe

Validar token

La interfaz GuardAuthenticatorgetCredentials

getUser

checkCredentials

createAuthenticatedToken

onAuthenticationFailure

onAuthenticationSuccess

supportsRememberMe

Validar token

Obtener usuario a partir de JWT

La interfaz GuardAuthenticatorgetCredentials

getUser

checkCredentials

createAuthenticatedToken

onAuthenticationFailure

onAuthenticationSuccess

supportsRememberMe

Validar token

Obtener usuario a partir de JWT

true

La interfaz GuardAuthenticatorgetCredentials

getUser

checkCredentials

createAuthenticatedToken

onAuthenticationFailure

onAuthenticationSuccess

supportsRememberMe

Validar token

Obtener usuario a partir de JWT

true

Crear token de Symfony (no JWT)

La interfaz GuardAuthenticatorgetCredentials

getUser

checkCredentials

createAuthenticatedToken

onAuthenticationFailure

onAuthenticationSuccess

supportsRememberMe

Devolver respuesta adecuada

Validar token

Obtener usuario a partir de JWT

true

Crear token de Symfony (no JWT)

La interfaz GuardAuthenticatorgetCredentials

getUser

checkCredentials

createAuthenticatedToken

onAuthenticationFailure

onAuthenticationSuccess

supportsRememberMe

No hacer nada

Devolver respuesta adecuada

Validar token

Obtener usuario a partir de JWT

true

Crear token de Symfony (no JWT)

La interfaz GuardAuthenticatorgetCredentials

getUser

checkCredentials

createAuthenticatedToken

onAuthenticationFailure

onAuthenticationSuccess

supportsRememberMe false

No hacer nada

Devolver respuesta adecuada

Validar token

Obtener usuario a partir de JWT

true

Crear token de Symfony (no JWT)

La interfaz GuardAuthenticatorgetCredentials

getUser

checkCredentials

createAuthenticatedToken

onAuthenticationFailure

onAuthenticationSuccess

supportsRememberMe false

No hacer nada

Devolver respuesta adecuada

Validar token

Obtener usuario a partir de JWT

true

Crear token de Symfony (no JWT)

JWTTokenAuthenticator

use Lexik\Bundle\JWTAuthenticationBundle\Security\Guard\JWTTokenAuthenticator as BaseAuthenticator;

class MiTokenAuthenticator extends BaseAuthenticator{}

security: # ... firewalls: # ... otra_api: pattern: ^/otraapi stateless: true guard: authenticators: - app.mi_token_authenticator

FAQ

Oauth vs JWT? Oauth + JWT?

Usos fuera de headers

token_extractors: authorization_header: enabled: true prefix: Bearer name: Authorization cookie: enabled: false name: BEARER query_parameter: enabled: false name: bearer

Cuántos datos caben en un token?

https://www.flickr.com/photos/highwaysagency/6008275527

Invalidar tokens

Enlaces con caducidad

$user = $this->getUser();$jwtManager = $this->get('lexik_jwt_authentication.jwt_manager');$token2 = $jwtManager->create($user);

<a href="{{ path('reset_password', {'bearer':authToken.credentials}) }}”> Reset password</a>

Controlador

Vista

Renovar tokens

👌

Impersonar usuarios en APIs

👌

Impersonar usuarios en APIs

HEADER:X-Switch-User: johndoe

Requests a otros dominios

👌

Ejemplo de uso con React

¡Gracias!@nacmartin

nacho@limenius.com

http://limenius.com Formación, consultoría

y desarrollo de proyectos