org.eclipse.hono.deviceregistry.FileBasedCredentialsService.java Source code

Java tutorial

Introduction

Here is the source code for org.eclipse.hono.deviceregistry.FileBasedCredentialsService.java

Source

/**
 * Copyright (c) 2017 Bosch Software Innovations GmbH.
 * <p>
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/epl-v10.html
 * <p>
 * Contributors:
 * Bosch Software Innovations GmbH - initial creation
 */

package org.eclipse.hono.deviceregistry;

import java.net.HttpURLConnection;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;

import org.eclipse.hono.service.credentials.BaseCredentialsService;
import org.eclipse.hono.util.CacheDirective;
import org.eclipse.hono.util.CredentialsConstants;
import org.eclipse.hono.util.CredentialsResult;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;

import io.vertx.core.AsyncResult;
import io.vertx.core.Future;
import io.vertx.core.Handler;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.json.DecodeException;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;

/**
 * A credentials service that keeps all data in memory but is backed by a file.
 * <p>
 * On startup this adapter tries to load credentials from a file (if configured).
 * On shutdown all credentials kept in memory are written to the file (if configured).
 */
@Repository
public final class FileBasedCredentialsService
        extends BaseCredentialsService<FileBasedCredentialsConfigProperties> {

    /**
     * The name of the JSON array within a tenant that contains the credentials.
     */
    public static final String ARRAY_CREDENTIALS = "credentials";
    /**
     * The name of the JSON property containing the tenant's ID.
     */
    public static final String FIELD_TENANT = "tenant";

    // <tenantId, <authId, credentialsData[]>>
    private final Map<String, Map<String, JsonArray>> credentials = new HashMap<>();
    private boolean running = false;
    private boolean dirty = false;

    @Autowired
    @Override
    public void setConfig(final FileBasedCredentialsConfigProperties configuration) {
        setSpecificConfig(configuration);
    }

    private Future<Void> checkFileExists(final boolean createIfMissing) {

        final Future<Void> result = Future.future();
        if (getConfig().getFilename() == null) {
            result.fail("no filename set");
        } else if (vertx.fileSystem().existsBlocking(getConfig().getFilename())) {
            result.complete();
        } else if (createIfMissing) {
            vertx.fileSystem().createFile(getConfig().getFilename(), result.completer());
        } else {
            log.debug("no such file [{}]", getConfig().getFilename());
            result.complete();
        }
        return result;
    }

    @Override
    protected void doStart(final Future<Void> startFuture) {

        if (running) {
            startFuture.complete();
        } else {

            if (!getConfig().isModificationEnabled()) {
                log.info("modification of credentials has been disabled");
            }

            if (getConfig().getFilename() == null) {
                log.debug("credentials filename is not set, no credentials will be loaded");
                running = true;
                startFuture.complete();
            } else {
                checkFileExists(getConfig().isSaveToFile()).compose(ok -> {
                    return loadCredentials();
                }).compose(s -> {
                    if (getConfig().isSaveToFile()) {
                        log.info("saving credentials to file every 3 seconds");
                        vertx.setPeriodic(3000, saveIdentities -> {
                            saveToFile();
                        });
                    } else {
                        log.info("persistence is disabled, will not save credentials to file");
                    }
                    running = true;
                    startFuture.complete();
                }, startFuture);
            }
        }
    }

    Future<Void> loadCredentials() {

        if (getConfig().getFilename() == null) {
            // no need to load anything
            return Future.succeededFuture();
        } else {
            final Future<Buffer> readResult = Future.future();
            log.debug("trying to load credentials from file {}", getConfig().getFilename());
            vertx.fileSystem().readFile(getConfig().getFilename(), readResult.completer());
            return readResult.compose(buffer -> {
                return addAll(buffer);
            }).recover(t -> {
                log.debug("cannot load credentials from file [{}]: {}", getConfig().getFilename(), t.getMessage());
                return Future.succeededFuture();
            });
        }
    }

    private Future<Void> addAll(final Buffer credentials) {
        final Future<Void> result = Future.future();
        try {
            int credentialsCount = 0;
            final JsonArray allObjects = credentials.toJsonArray();
            log.debug("trying to load credentials for {} tenants", allObjects.size());
            for (final Object obj : allObjects) {
                if (JsonObject.class.isInstance(obj)) {
                    credentialsCount += addCredentialsForTenant((JsonObject) obj);
                }
            }
            log.info("successfully loaded {} credentials from file [{}]", credentialsCount,
                    getConfig().getFilename());
            result.complete();
        } catch (final DecodeException e) {
            log.warn("cannot read malformed JSON from credentials file [{}]", getConfig().getFilename());
            result.fail(e);
        }
        return result;
    };

    int addCredentialsForTenant(final JsonObject tenant) {
        int count = 0;
        final String tenantId = tenant.getString(FIELD_TENANT);
        final Map<String, JsonArray> credentialsMap = new HashMap<>();
        for (final Object credentialsObj : tenant.getJsonArray(ARRAY_CREDENTIALS)) {
            final JsonObject credentials = (JsonObject) credentialsObj;
            final JsonArray authIdCredentials;
            if (credentialsMap.containsKey(credentials.getString(CredentialsConstants.FIELD_AUTH_ID))) {
                authIdCredentials = credentialsMap.get(credentials.getString(CredentialsConstants.FIELD_AUTH_ID));
            } else {
                authIdCredentials = new JsonArray();
            }
            authIdCredentials.add(credentials);
            credentialsMap.put(credentials.getString(CredentialsConstants.FIELD_AUTH_ID), authIdCredentials);
            count++;
        }
        credentials.put(tenantId, credentialsMap);
        return count;
    }

    @Override
    protected void doStop(final Future<Void> stopFuture) {

        if (running) {
            saveToFile().compose(s -> {
                running = false;
                stopFuture.complete();
            }, stopFuture);
        } else {
            stopFuture.complete();
        }
    }

    Future<Void> saveToFile() {

        if (!getConfig().isSaveToFile()) {
            return Future.succeededFuture();
        } else if (dirty) {
            return checkFileExists(true).compose(s -> {
                final AtomicInteger idCount = new AtomicInteger();
                final JsonArray tenants = new JsonArray();
                for (final Entry<String, Map<String, JsonArray>> entry : credentials.entrySet()) {
                    final JsonArray credentialsArray = new JsonArray();
                    for (final JsonArray singleAuthIdCredentials : entry.getValue().values()) {
                        credentialsArray.addAll(singleAuthIdCredentials.copy());
                        idCount.incrementAndGet();
                    }
                    tenants.add(new JsonObject().put(FIELD_TENANT, entry.getKey()).put(ARRAY_CREDENTIALS,
                            credentialsArray));
                }
                final Future<Void> writeHandler = Future.future();
                vertx.fileSystem().writeFile(getConfig().getFilename(),
                        Buffer.buffer(tenants.encodePrettily(), StandardCharsets.UTF_8.name()),
                        writeHandler.completer());
                return writeHandler.map(ok -> {
                    dirty = false;
                    log.trace("successfully wrote {} credentials to file {}", idCount.get(),
                            getConfig().getFilename());
                    return (Void) null;
                }).otherwise(t -> {
                    log.warn("could not write credentials to file {}", getConfig().getFilename(), t);
                    return (Void) null;
                });
            });
        } else {
            log.trace("credentials registry does not need to be persisted");
            return Future.succeededFuture();
        }
    }

    /**
     * {@inheritDoc}
     * <p>
     * The result object will include a <em>no-cache</em> directive.
     */
    @Override
    public void get(final String tenantId, final String type, final String authId,
            final Handler<AsyncResult<CredentialsResult<JsonObject>>> resultHandler) {
        get(tenantId, type, authId, null, resultHandler);
    }

    /**
     * {@inheritDoc}
     * <p>
     * The result object will include a <em>no-cache</em> directive.
     */
    @Override
    public void get(final String tenantId, final String type, final String authId, final JsonObject clientContext,
            final Handler<AsyncResult<CredentialsResult<JsonObject>>> resultHandler) {

        Objects.requireNonNull(tenantId);
        Objects.requireNonNull(type);
        Objects.requireNonNull(authId);
        Objects.requireNonNull(resultHandler);

        final JsonObject data = getSingleCredentials(tenantId, authId, type, clientContext);
        if (data == null) {
            resultHandler.handle(Future.succeededFuture(CredentialsResult.from(HttpURLConnection.HTTP_NOT_FOUND)));
        } else {
            resultHandler.handle(Future.succeededFuture(CredentialsResult.from(HttpURLConnection.HTTP_OK,
                    data.copy(), CacheDirective.noCacheDirective())));
        }
    }

    /**
     * {@inheritDoc}
     * <p>
     * The result object will include a <em>no-cache</em> directive.
     */
    @Override
    public void getAll(final String tenantId, final String deviceId,
            final Handler<AsyncResult<CredentialsResult<JsonObject>>> resultHandler) {

        Objects.requireNonNull(tenantId);
        Objects.requireNonNull(deviceId);
        Objects.requireNonNull(resultHandler);

        final Map<String, JsonArray> credentialsForTenant = credentials.get(tenantId);
        if (credentialsForTenant == null) {
            resultHandler.handle(Future.succeededFuture(CredentialsResult.from(HttpURLConnection.HTTP_NOT_FOUND)));
        } else {
            final JsonArray matchingCredentials = new JsonArray();
            // iterate over all credentials per auth-id in order to find credentials matching the given device
            for (final JsonArray credentialsForAuthId : credentialsForTenant.values()) {
                findCredentialsForDevice(credentialsForAuthId, deviceId, matchingCredentials);
            }
            if (matchingCredentials.isEmpty()) {
                resultHandler
                        .handle(Future.succeededFuture(CredentialsResult.from(HttpURLConnection.HTTP_NOT_FOUND)));
            } else {
                final JsonObject result = new JsonObject()
                        .put(CredentialsConstants.FIELD_CREDENTIALS_TOTAL, matchingCredentials.size())
                        .put(CredentialsConstants.CREDENTIALS_ENDPOINT, matchingCredentials);
                resultHandler.handle(Future.succeededFuture(CredentialsResult.from(HttpURLConnection.HTTP_OK,
                        result, CacheDirective.noCacheDirective())));
            }
        }
    }

    private void findCredentialsForDevice(final JsonArray credentials, final String deviceId,
            final JsonArray result) {

        for (final Object obj : credentials) {
            if (obj instanceof JsonObject) {
                final JsonObject currentCredentials = (JsonObject) obj;
                if (deviceId.equals(getTypesafeValueForField(currentCredentials,
                        CredentialsConstants.FIELD_PAYLOAD_DEVICE_ID))) {
                    // device ID matches, add a copy of credentials to result
                    result.add(currentCredentials.copy());
                }
            }
        }
    }

    /**
     * Get the credentials associated with the authId and the given type.
     * If type is null, all credentials associated with the authId are returned (as JsonArray inside the return value).
     *
     * @param tenantId The id of the tenant the credentials belong to.
     * @param authId The authentication identifier to look up credentials for.
     * @param type The type of credentials to look up.
     * @return The credentials object of the given type or {@code null} if no matching credentials exist.
     */
    private JsonObject getSingleCredentials(final String tenantId, final String authId, final String type,
            final JsonObject clientContext) {

        Objects.requireNonNull(tenantId);
        Objects.requireNonNull(authId);
        Objects.requireNonNull(type);

        final Map<String, JsonArray> credentialsForTenant = credentials.get(tenantId);
        if (credentialsForTenant != null) {
            final JsonArray authIdCredentials = credentialsForTenant.get(authId);
            if (authIdCredentials != null) {
                for (final Object authIdCredentialEntry : authIdCredentials) {
                    final JsonObject authIdCredential = (JsonObject) authIdCredentialEntry;
                    // return the first matching type entry for this authId
                    if (type.equals(authIdCredential.getString(CredentialsConstants.FIELD_TYPE))) {
                        if (clientContext != null) {
                            final AtomicBoolean match = new AtomicBoolean(true);
                            clientContext.forEach(field -> {
                                if (authIdCredential.containsKey(field.getKey())) {
                                    if (!authIdCredential.getString(field.getKey()).equals(field.getValue())) {
                                        match.set(false);
                                    }
                                } else {
                                    match.set(false);
                                }
                            });
                            if (!match.get()) {
                                continue;
                            }
                        }
                        return authIdCredential;
                    }
                }
            }
        }
        return null;
    }

    @Override
    public void add(final String tenantId, final JsonObject credentials,
            final Handler<AsyncResult<CredentialsResult<JsonObject>>> resultHandler) {

        Objects.requireNonNull(tenantId);
        Objects.requireNonNull(credentials);
        Objects.requireNonNull(resultHandler);
        final CredentialsResult<JsonObject> credentialsResult = addCredentialsResult(tenantId, credentials);
        resultHandler.handle(Future.succeededFuture(credentialsResult));
    }

    private CredentialsResult<JsonObject> addCredentialsResult(final String tenantId,
            final JsonObject credentialsToAdd) {

        final String authId = credentialsToAdd.getString(CredentialsConstants.FIELD_AUTH_ID);
        final String type = credentialsToAdd.getString(CredentialsConstants.FIELD_TYPE);
        log.debug("adding credentials for device [tenant-id: {}, auth-id: {}, type: {}]", tenantId, authId, type);

        final Map<String, JsonArray> credentialsForTenant = getCredentialsForTenant(tenantId);

        final JsonArray authIdCredentials = getAuthIdCredentials(authId, credentialsForTenant);

        // check if credentials already exist with the type and auth-id from the payload
        for (final Object credentialsObj : authIdCredentials) {
            final JsonObject credentials = (JsonObject) credentialsObj;
            if (credentials.getString(CredentialsConstants.FIELD_TYPE).equals(type)) {
                return CredentialsResult.from(HttpURLConnection.HTTP_CONFLICT);
            }
        }

        authIdCredentials.add(credentialsToAdd);
        dirty = true;
        return CredentialsResult.from(HttpURLConnection.HTTP_CREATED);
    }

    @Override
    public void update(final String tenantId, final JsonObject newCredentials,
            final Handler<AsyncResult<CredentialsResult<JsonObject>>> resultHandler) {

        Objects.requireNonNull(tenantId);
        Objects.requireNonNull(newCredentials);
        Objects.requireNonNull(resultHandler);

        if (getConfig().isModificationEnabled()) {
            final String authId = newCredentials.getString(CredentialsConstants.FIELD_AUTH_ID);
            final String type = newCredentials.getString(CredentialsConstants.FIELD_TYPE);
            log.debug("updating credentials for device [tenant-id: {}, auth-id: {}, type: {}]", tenantId, authId,
                    type);

            final Map<String, JsonArray> credentialsForTenant = getCredentialsForTenant(tenantId);
            if (credentialsForTenant == null) {
                resultHandler
                        .handle(Future.succeededFuture(CredentialsResult.from(HttpURLConnection.HTTP_NOT_FOUND)));
            } else {
                final JsonArray credentialsForAuthId = credentialsForTenant.get(authId);
                if (credentialsForAuthId == null) {
                    resultHandler.handle(
                            Future.succeededFuture(CredentialsResult.from(HttpURLConnection.HTTP_NOT_FOUND)));
                } else {
                    // find credentials of given type
                    boolean removed = false;
                    final Iterator<Object> credentialsIterator = credentialsForAuthId.iterator();
                    while (credentialsIterator.hasNext()) {
                        final JsonObject creds = (JsonObject) credentialsIterator.next();
                        if (creds.getString(CredentialsConstants.FIELD_TYPE).equals(type)) {
                            credentialsIterator.remove();
                            removed = true;
                            break;
                        }
                    }
                    if (removed) {
                        credentialsForAuthId.add(newCredentials);
                        dirty = true;
                        resultHandler.handle(
                                Future.succeededFuture(CredentialsResult.from(HttpURLConnection.HTTP_NO_CONTENT)));
                    } else {
                        resultHandler.handle(
                                Future.succeededFuture(CredentialsResult.from(HttpURLConnection.HTTP_NOT_FOUND)));
                    }
                }
            }
        } else {
            resultHandler.handle(Future.succeededFuture(CredentialsResult.from(HttpURLConnection.HTTP_FORBIDDEN)));
        }
    }

    @Override
    public void remove(final String tenantId, final String type, final String authId,
            final Handler<AsyncResult<CredentialsResult<JsonObject>>> resultHandler) {

        Objects.requireNonNull(tenantId);
        Objects.requireNonNull(type);
        Objects.requireNonNull(authId);
        Objects.requireNonNull(resultHandler);

        if (getConfig().isModificationEnabled()) {
            final Map<String, JsonArray> credentialsForTenant = credentials.get(tenantId);
            if (credentialsForTenant == null) {
                resultHandler
                        .handle(Future.succeededFuture(CredentialsResult.from(HttpURLConnection.HTTP_NOT_FOUND)));
            } else {
                final JsonArray credentialsForAuthId = credentialsForTenant.get(authId);
                if (credentialsForAuthId == null) {
                    resultHandler.handle(
                            Future.succeededFuture(CredentialsResult.from(HttpURLConnection.HTTP_NOT_FOUND)));
                } else if (removeCredentialsFromCredentialsArray(null, type, credentialsForAuthId)) {
                    if (credentialsForAuthId.isEmpty()) {
                        credentialsForTenant.remove(authId); // do not leave empty array as value
                    }
                    resultHandler.handle(
                            Future.succeededFuture(CredentialsResult.from(HttpURLConnection.HTTP_NO_CONTENT)));
                } else {
                    resultHandler.handle(
                            Future.succeededFuture(CredentialsResult.from(HttpURLConnection.HTTP_NOT_FOUND)));
                }
            }
        } else {
            resultHandler.handle(Future.succeededFuture(CredentialsResult.from(HttpURLConnection.HTTP_FORBIDDEN)));
        }
    }

    @Override
    public void removeAll(final String tenantId, final String deviceId,
            final Handler<AsyncResult<CredentialsResult<JsonObject>>> resultHandler) {

        Objects.requireNonNull(tenantId);
        Objects.requireNonNull(deviceId);
        Objects.requireNonNull(resultHandler);

        if (getConfig().isModificationEnabled()) {

            final Map<String, JsonArray> credentialsForTenant = credentials.get(tenantId);
            if (credentialsForTenant == null) {
                resultHandler
                        .handle(Future.succeededFuture(CredentialsResult.from(HttpURLConnection.HTTP_NOT_FOUND)));
            } else {

                boolean removedAnyElement = false;

                // delete based on type (no authId provided) - this might consume more time on large data sets and is thus
                // handled explicitly
                for (final JsonArray credentialsForAuthId : credentialsForTenant.values()) {
                    if (removeCredentialsFromCredentialsArray(deviceId, CredentialsConstants.SPECIFIER_WILDCARD,
                            credentialsForAuthId)) {
                        removedAnyElement = true;
                    }
                }

                // there might be empty credentials arrays left now, so remove them in a second run
                cleanupEmptyCredentialsArrays(credentialsForTenant);

                if (removedAnyElement) {
                    dirty = true;
                    resultHandler.handle(
                            Future.succeededFuture(CredentialsResult.from(HttpURLConnection.HTTP_NO_CONTENT)));
                } else {
                    resultHandler.handle(
                            Future.succeededFuture(CredentialsResult.from(HttpURLConnection.HTTP_NOT_FOUND)));
                }
            }
        } else {
            resultHandler.handle(Future.succeededFuture(CredentialsResult.from(HttpURLConnection.HTTP_FORBIDDEN)));
        }
    }

    private void cleanupEmptyCredentialsArrays(final Map<String, JsonArray> mapToCleanup) {

        // use an iterator here to allow removal during looping (streams currently do not allow this)
        final Iterator<Entry<String, JsonArray>> entries = mapToCleanup.entrySet().iterator();
        while (entries.hasNext()) {
            final Entry<String, JsonArray> entry = entries.next();
            if (entry.getValue().isEmpty()) {
                entries.remove();
            }
        }
    }

    private boolean removeCredentialsFromCredentialsArray(final String deviceId, final String type,
            final JsonArray credentialsForAuthId) {

        boolean removedElement = false;

        if (credentialsForAuthId != null) {
            // the credentials in the array always have the same authId, but possibly different types
            // use an iterator here to allow removal during looping (streams currently do not allow this)
            final Iterator<Object> credentialsIterator = credentialsForAuthId.iterator();
            while (credentialsIterator.hasNext()) {

                final JsonObject element = (JsonObject) credentialsIterator.next();
                final String credType = element.getString(CredentialsConstants.FIELD_TYPE);
                final String credDevice = element.getString(CredentialsConstants.FIELD_PAYLOAD_DEVICE_ID);

                if (!CredentialsConstants.SPECIFIER_WILDCARD.equals(type) && credType.equals(type)) {
                    // delete a single credentials instance
                    credentialsIterator.remove();
                    removedElement = true;
                    break; // there can only be one matching instance due to uniqueness guarantees
                } else if (CredentialsConstants.SPECIFIER_WILDCARD.equalsIgnoreCase(type)
                        && credDevice.equals(deviceId)) {
                    // delete all credentials for device
                    credentialsIterator.remove();
                    removedElement = true;
                } else if (credDevice.equals(deviceId) && credType.equals(type)) {
                    // delete all credentials for device of given type
                    credentialsIterator.remove();
                    removedElement = true;
                }
            }
        }

        return removedElement;
    }

    private Map<String, JsonArray> getCredentialsForTenant(final String tenantId) {
        return credentials.computeIfAbsent(tenantId, id -> new HashMap<>());
    }

    private JsonArray getAuthIdCredentials(final String authId, final Map<String, JsonArray> credentialsForTenant) {
        return credentialsForTenant.computeIfAbsent(authId, id -> new JsonArray());
    }

    /**
     * Removes all credentials from the registry.
     */
    public void clear() {
        dirty = true;
        credentials.clear();
    }

    @Override
    public String toString() {
        return String.format("%s[filename=%s]", FileBasedCredentialsService.class.getSimpleName(),
                getConfig().getFilename());
    }
}