Post on 22-Jan-2018
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