Cómo autentificar un dispositivo en el servidor - JWT - OpenSSL


Última revisión en enero del 2022.
Cuando contruimos un servidor REST o MQTT que nos hace de puente entre los diferentes elementos que forman nuestra red de IoT, tenemos que buscar una manera de autentificar los dispositivos en el servidor para evitar que cualquiera pueda usar sus recursos. Este post explica cómo hacerlo con tokens JWT, cómo crear las claves RSA, cómo inicialializar los dispositivos y el servidor, y cómo usar los tokens para autentificar y autorizar las solicitudes.

JWT - JSON Web Token

JWT se usa para enviar información entre el cliente y el servidor de forma que permita su mutua autentificación. Además esta autentificación tiene una validez acotada en el tiempo.

La información viaja en abierto pero con una firma que protege su manipulación. Esta firma nos permite autentificar tanto al cliente como al servidor.

En nuestro caso utilizaremos clave pública RSA para firmar los JWTs. Cada dispositivo tiene empotrada su clave privada y el servidor ha sido inicializado para cada dispositivo con su correspondiente clave pública.

Los JWT se añaden en el header de autentificación del protocolo HTTP sobre el que los dispositivos realizan sus solicitudes a los recursos del servidor.

Aprovisionamiento de credenciales

Comenzamos generando unas claves RSA para cada dispositivo. Iniciando cada dispositivo con la clave rsa privada y un identificador único. Y iniciando el servidor con el identificador de cada dispositivo y su clave rsa pública.

Generando un par de claves RSA

Podemos usar OpenSSL para generar un par de claves RSA de 2048 bits para cada dispositivo.

La clave privada


openssl genpkey 
        -algorithm RSA 
        -out rsa_private.pem 
        -pkeyopt rsa_keygen_bits:2048

y a partir de la clave privada creamos la clave pública


openssl rsa 
        -in rsa_private.pem 
        -pubout 
        -out rsa_public.pem

Las claves generadas están en PEM que es un formato base64-ASCII , como este proyecto esta desarrollado integramente en Java, convertimos las claves a DER que es un formato binario que usa el JDK.

Para la clave privada


openssl pkcs8 
        -topk8 
        -inform PEM 
        -outform DER 
        -in rsa_private.pem 
        -nocrypt 
        > rsa_private_pkcs8.der

Para la clave pública


 openssl pkey 
         -in rsa_public.pem 
         -pubin 
         -outform der 
         -out rsa_public.der

Inicializando cada dispositivo con su clave privada

Simplemente se empotra la clave privada rsa en formato DER en el dispositivo y se le asigna un identificador único con el que el servidor pueda identificarle.

Inicializando cada dispositivo en el servidor con su clave pública

Para inicializar los dispositivo en el servidor primero se autentifica la persona encargada de hacer el aprovisionamiento. En nuestro caso delegamos en Google Identity (OAuth 2.0) que nos devuelve un token JWT que nos permite autentificar y autorizar al aprovisionador en el servidor.

Después, se envía al servidor una solicitud de crear cada dispositivo con su identificador y su clave pública asociada, además de incluye el token JWT devuelto por Google Identity. El servidor verifica el token con Google Identity y verifica si el usuario está autorizado para crear dispositivos, en tal caso crea el dispositivo con el identificador y la clave pública asociada.

Código de entrada de datos

En el HTML de entrada de datos podemos ver el div con identificador "g_id_onload" que permite autentificarnos a través de Google Identity, el token resultante es guardado por el callback handleToken.

  
<html>
    <head>
        <title>device initialization</title>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <script src="https://accounts.google.com/gsi/client" async defer></script>
        <script src="signin.js"></script>
        <style>
            .bit
            {
                margin-top: 1em;
                display:flex;
            }
        </style>
    </head>        
    <body>
        <div id="g_id_onload"
             data-client_id="691482276368-97dvng0nomkmu7sp9dbceshn6qfltc9v.apps.googleusercontent.com"            
             data-callback="handleToken"
             >
        </div>
       
        <div class="bit">
            <label for="id">Device ID (Without spaces):</label>
            <input type="text" id="id" name="id">
        </div>        
        <div class="bit">
            <label for="description">Device description:</label>
            <textarea  id="description" name="description"></textarea>
        </div>
        <div class="bit">
            <label for="public">Device Public Key:</label>
            <input type="file"  id="public" name="public">
        </div>
        <div class="bit">
            <button onclick="onSubmit()">Submit</button>
        </div>
        <div class="bit" id="status">          
        </div>

    </body>
</html>

Debajo tenemos el Vanilla JavaScript de la página HTML anterior. La clave pública la enviamos en formato Base64 dentro de un paquete JSON con un POST al recurso device.


let token = null;

function handleToken(info)
{
    token = info.credential;
}

function getBase64(file)
{
    return new Promise((resolve, reject) =>
    {
        const reader = new FileReader();
        reader.readAsDataURL(file);
        reader.onload = () => resolve(reader.result.split(',')[1]);
        reader.onerror = error => reject(error);
    });
}

function messageToUser(message)
{
    document.getElementById('status').innerText = message;
}

function onSubmit()
{
    const selectedFile = document.getElementById('public').files[0];
    const id = document.getElementById('id').value;
    const description = document.getElementById('description').value;
    const publicKey = null;
    if (token === null)
    {
        messageToUser("User is not authenticated");
        return;
    }
    getBase64(selectedFile).then(base64PublicKey =>
    {
        fetch("/resource/device", {
            method: 'POST',
            headers:
                    {
                        'Content-Type': 'application/json',
                        'Authorization': 'Bearer ' + token
                    },
            body: JSON.stringify({id, description, base64PublicKey, publicKey})
        }).then((response) =>
        {
            messageToUser("Status: " + response.status);
        });
    }).catch((error) =>
    {
         messageToUser(error);
    });
}

Código de verificación del token y autorización

Usamos Jersey RESTful Web Services framework para crear el recurso device. Sólo comentar el método POST va acompañado de la anotación @SecuredUser que vincula un filtro que crea el Contexto de Seguridad que nos permite autentificar el usuario.


package com.elmoli.security.resources;

import com.elmoli.security.controller.DeviceController;
import com.elmoli.security.domain.DeviceDomain;
import java.net.URI;
import java.util.Base64;
import javax.ws.rs.BadRequestException;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.NotAuthorizedException;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.SecurityContext;

@Path("device")
public class DeviceREST
{

    DeviceController dc = null;

    public DeviceREST()
    {
        dc = new DeviceController();
    }

    @SecuredUser
    @POST
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.TEXT_PLAIN)
    public Response create(@Context SecurityContext sc, DeviceDomain device)
    {

        String base64PublicKey = device.getBase64PublicKey();
        String id = device.getId();

        if ((base64PublicKey == null) || (base64PublicKey.isEmpty())
                || (id == null) || (id.isEmpty()) || id.matches(".*\\s.*"))
        {
            throw new BadRequestException("Mandatory fields are empty or id contains spaces.");
        }

        // Authorization security control ----------------------------------
        if (sc.isUserInRole("admin") == false)

        {
            throw new NotAuthorizedException("Authenticated user must be the admin");
        }

        device.setPublicKey(Base64.getDecoder().decode(base64PublicKey));
        dc.create(device);

        return Response.created(URI.create("/device/" + id)).build();

    }
}

Debajo tenemos el filtro que crea el Contexto de Seguridad y verifica la validez del token creado con Google Identity.


package com.elmoli.security.resources;

import com.elmoli.security.GPKM;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken.Payload;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
import com.google.api.client.googleapis.auth.oauth2.GooglePublicKeysManager;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.Principal;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.annotation.Priority;
import javax.ws.rs.NotAuthorizedException;
import javax.ws.rs.Priorities;
import javax.ws.rs.container.ContainerRequestFilter;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.SecurityContext;
import javax.ws.rs.core.UriInfo;
import javax.ws.rs.ext.Provider;


@SecuredUser
@Provider
@Priority(Priorities.AUTHENTICATION)
public class AuthenticationUserFilter implements ContainerRequestFilter
{

    @Context
    UriInfo uriInfo;

    private static final String ADMIN_EMAIL = "admin@kk.com";
    private static final String AUTHENTICATION_SCHEME = "Bearer";

    @Override
    public void filter(ContainerRequestContext requestContext) throws IOException
    {
               
        String authorizationHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);
        String token = authorizationHeader
                .substring(AUTHENTICATION_SCHEME.length()).trim();

        // Check if the token is present
        if (token == null || token.isEmpty())
        {
            throw new NotAuthorizedException("Token must be provided");
        }

        try
        {
            // Validate the token
            requestContext.setSecurityContext(getSecurityContext(token));
        } catch (GeneralSecurityException ex)
        {
            Logger.getLogger(AuthenticationUserFilter.class.getName()).log(Level.SEVERE, null, ex);
        }
    }

    private SecurityContext getSecurityContext(String token) throws GeneralSecurityException, IOException
    {
        final SecurityContext sc;

        GooglePublicKeysManager manager = GPKM.getInstance();
        GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(manager).build();

        GoogleIdToken idToken = verifier.verify(token);
        if (idToken != null)
        {
            Payload payload = idToken.getPayload();
            String subject = payload.getEmail();
            sc = new SecurityContext()
            {
                @Override
                public Principal getUserPrincipal()
                {
                    return () -> subject;
                }

                @Override
                public boolean isUserInRole(String role)
                {
                    if ((role.equals("admin") == true) && (subject.equals(ADMIN_EMAIL) == false))
                    {
                        return false;
                    }
                    return (true);
                }

                @Override
                public boolean isSecure()
                {
                    return uriInfo.getAbsolutePath().toString().startsWith("https");
                }

                @Override
                public String getAuthenticationScheme()
                {
                    return AUTHENTICATION_SCHEME;
                }
            };
        } else
        {
            throw new NotAuthorizedException("Invalid token.");
        }
        return (sc);
    }
}

Cómo autentificamos un dispositivo

Como ejemplo usamos un dispositivo que envía una notificación de alarma a un teléfono móvil a través método notification del recurso alarm. Para crea el recurso hemos usado un servidor Jetty al que hemos añadido el Jersey RESTful Web Services framework.

Dispositivo cliente

El dispositivo utiliza Jersey JAX-RS Client para acceder al recurso. Para autentificar al dispositivo creamos un JWT (createJwtRsa), firmado con su clave rsa privada, que acompaña cada solicitud HTTP como atributo en su cabecera (Bearer Authentication). En este caso damos al token una validez de 24 horas.


package com.elmoli.restalarmclient;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import java.io.IOException;

import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

import java.nio.file.Files;
import java.nio.file.Paths;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.ClientBuilder;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;


public class ClientTest
{

    private static String createJwtRsa(String projectId,
            String deviceId,
            String privateKeyFile)
            throws NoSuchAlgorithmException, IOException, InvalidKeySpecException
    {
        LocalDateTime now = LocalDateTime.now();
        Date date = Date.from(now.atZone(ZoneId.systemDefault()).toInstant());
        LocalDateTime after = now.plusHours(24);
        Date afterDate = Date.from(after.atZone(ZoneId.systemDefault()).toInstant());

        JwtBuilder jwtBuilder
                = Jwts.builder()
                        .setIssuedAt(date)
                        .setExpiration(afterDate)
                        .setAudience(projectId)
                        .claim("deviceId", deviceId);

        byte[] keyBytes = Files.readAllBytes(Paths.get(privateKeyFile));

        PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
        KeyFactory kf = KeyFactory.getInstance("RSA");
        PrivateKey pk = kf.generatePrivate(spec);

        return jwtBuilder.signWith(SignatureAlgorithm.RS256, pk).compact();
    }

    public static void main(String[] args)
    {
               
        try
        {
            Client client = ClientBuilder.newClient();
           
            // authentification token
            String jwt = createJwtRsa("Project Id", "MyFistDeviceID", "rsa_private_pkcs8");

            // entity
            NotificationDomain nd = new NotificationDomain("Title Notification", "Body Notification");
            Entity e = Entity.entity(nd, MediaType.APPLICATION_JSON);

            // Resource call
            Response response = client.target("http://192.168.1.123:8080")
                    .path("resource/alarm/notification")                    
                    .request(MediaType.TEXT_PLAIN)
                    .header(HttpHeaders.AUTHORIZATION ,"Bearer " + jwt)
                    .post(e);

            // Returned status
            System.out.println("status: " + response.getStatus());
           
        } catch (NoSuchAlgorithmException | IOException | InvalidKeySpecException ex)
        {
            Logger.getLogger(ClientTest.class.getName()).log(Level.SEVERE, null, ex);
        }
    }

}

Servidor Pub/Sub - Jetty - Jersey

En el servidor tenemos el recurso alarm con el método subscriber que permite a las aplicaciones de móvil subscribirse a las notificaciones de alarma y el método notification que es utilizado por los dispositivos para enviar alarmas a los móviles subscritos.

El método notification está securizado con la anotación @SecuredDevice que filtra los dispositivos no inicializados.


package com.elmoli.security.resources;

import com.elmoli.security.FirebaseMessage;
import com.elmoli.security.controller.SubscriberController;
import com.elmoli.security.domain.NotificationDomain;
import com.elmoli.security.domain.SubscriberDomain;
import java.io.IOException;
import java.net.URI;
import javax.ws.rs.BadRequestException;
import javax.ws.rs.Consumes;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.SecurityContext;

@Path("alarm")
public class AlarmREST
{
    @POST
    @SecuredUser
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.TEXT_PLAIN)
    @Path("/subscriber")
    public Response addSubscriber(@Context SecurityContext securityContext, SubscriberDomain sd)
    {
        SubscriberController sc = new SubscriberController();
        sc.create(sd);
        return Response.created(URI.create("/subscriber/")).build();
    }

    @POST
    @SecuredDevice
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.TEXT_PLAIN)
    @Path("/notification")
    public Response sendNotification(@Context SecurityContext sc, NotificationDomain nd)
    {
        FirebaseMessage fb = new FirebaseMessage();
        String str;
        try
        {
            str = fb.sendNotification(nd);
        } catch (IOException ex)
        {
            throw new BadRequestException(ex);
        }
        return Response.accepted().build();
    }

}

En el filtro AuthenticationDeviceFilter que implementa la anotación @SecuredDevice tiene el método validateToken que extrae la identificación del dispositivo del JWT, busca en el Google Cloud Datastore si el dispositivo ha estado inicializado, recupera su clave pública, y verifica la firma del token. Si alguna cosa falla se genera una excepción de autorización.


package com.elmoli.security.resources;

import com.elmoli.security.controller.DeviceController;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.JwsHeader;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SigningKeyResolverAdapter;
import java.io.IOException;
import java.security.Key;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.annotation.Priority;
import javax.ws.rs.NotAuthorizedException;
import javax.ws.rs.Priorities;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.container.ContainerRequestFilter;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.ext.Provider;

@SecuredDevice
@Provider
@Priority(Priorities.AUTHENTICATION)
public class AuthenticationDeviceFilter implements ContainerRequestFilter
{

    private static final String AUTHENTICATION_SCHEME = "Bearer";

    @Override
    public void filter(ContainerRequestContext requestContext) throws IOException
    {

        // Get the Authorization header from the request
        String authorizationHeader
                = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);

        // Validate the Authorization header
        if (!isTokenBasedAuthentication(authorizationHeader))
        {
            throw new NotAuthorizedException("Token must be provided");
        }

        // Extract the token from the Authorization header
        String token = authorizationHeader
                .substring(AUTHENTICATION_SCHEME.length()).trim();

        try
        {
            validateToken(token);
        } catch (Exception e)
        {
            throw new NotAuthorizedException("Device not validated");
        }

    }

    private boolean isTokenBasedAuthentication(String authorizationHeader)
    {
        return authorizationHeader != null && authorizationHeader.toLowerCase()
                .startsWith(AUTHENTICATION_SCHEME.toLowerCase() + " ");
    }

    private void validateToken(String token) throws Exception
    {
        Jws<Claims> parseClaimsJws = Jwts.parser().setSigningKeyResolver(new SigningKeyResolverAdapter()
        {
            @Override
            public Key resolveSigningKey(JwsHeader header, Claims claims)
            {
                KeyFactory kf = null;
                PublicKey pk = null;
                // Get device Public Key
                String deviceId = claims.get("deviceId").toString();;
                DeviceController dc = new DeviceController();
                byte[] keyBytes = dc.getDevice(deviceId).getPublicKey();
                X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes);
                try
                {
                    kf = KeyFactory.getInstance("RSA");
                    pk = kf.generatePublic(spec);
                } catch (NoSuchAlgorithmException | InvalidKeySpecException ex)
                {
                    Logger.getLogger(AuthenticationDeviceFilter.class.getName()).log(Level.SEVERE, null, ex);
                }
                return pk;
            }

        }
        ).parseClaimsJws(token);
    }
}

Para finalizar

La seguridad es una preocupación crítica a la hora de crear una red de dispositivos en Internet. Los tokens JWT nos ofrecen una forma fácil de autentificar y autorizar nuestros dispositivos en el servidor.

Recordar que las claves privadas se han de mantener privadas; y que los tokens JWT llevan la información (payload) codificada pero no encriptada y que temporalmente autentifican los dispositivos en el servidor, o sea que también se han de mantener privados.

Más información

Qué más

Comentarios