All Downloads are FREE. Search and download functionalities are using the official Maven repository.

nl.vpro.media.odi.security.OdiAuthentication Maven / Gradle / Ivy

/*
 * Copyright (C) 2012 Licensed under the Apache License, Version 2.0
 * VPRO The Netherlands
 */
package nl.vpro.media.odi.security;

import lombok.extern.slf4j.Slf4j;

import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;

import org.apache.commons.codec.binary.Base64;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import nl.vpro.domain.media.Location;
import nl.vpro.domain.media.MediaObject;
import nl.vpro.util.ThreadPools;

/**
 * @author Roelof Jan Koekoek
 * @since 2.1
 */
@Aspect
@Slf4j
public class OdiAuthentication {
    public static final String X_NPO_DATE = "x-npo-date";

    public static final String X_NPO_MID = "x-npo-mid";

    public static final String X_NPO_URL = "x-npo-url";

    public static final String X_ORIGIN = "x-origin";

    public static final String AUTHORIZATION = "authorization";

    //per line format "apiKey:privateKey:allowXOrigin:origins , separated with * support"
    private static final Pattern CONFIG_PATTERN = Pattern.compile("^(\\w+):([^:]+):(false|true):(.+)$");

    private static final Pattern HEADER_PATTERN = Pattern.compile("\\s*NPO\\s+(\\w+):(.+)\\s*$");

    private static final String CONFIG_FILE = "odi.clients";

    private String configFolder;

    private long expiresInMinutes = 10;

    private  Set clients = null;

    private final ExecutorService executorService =
        Executors.newSingleThreadExecutor(ThreadPools.createThreadFactory("OdiConfigMonitor", true, Thread.NORM_PRIORITY));

    private boolean running = true;

    @PostConstruct
    public void init() {
        loadAuthorizedClients();
        appendConfigWatcher();
    }
    @PreDestroy
    public void shutdown() {
        running = false;
        executorService.shutdownNow();
    }

    @Before("target(nl.vpro.media.odi.OdiService) && execution(* *(..)) && args(media, request, ..)")
    public void handleMedia(MediaObject media, HttpServletRequest request) {
        request = patchIE89(request);

        hasRecentDateHeader(request);
        hasMatchingMid(media, request);
        isAuthorized(request);
    }

    @Before("target(nl.vpro.media.odi.OdiService) && execution(* *(..)) && args(location, request, ..)")
    public void handleLocation(Location location, HttpServletRequest request) {
        request = patchIE89(request);

        hasRecentDateHeader(request);
        hasMatchingLocation(location, request);
        isAuthorized(request);

    }

    @Before("target(nl.vpro.media.odi.OdiService) && execution(* *(..)) && args(url, request, ..)")
    public void handleUrl(String url, HttpServletRequest request) {
        request = patchIE89(request);

        hasRecentDateHeader(request);
        hasMatchingUrl(url, request);
        isAuthorized(request);
    }


    public void setExpiresInMinutes(int expiresInMinutes) {
        this.expiresInMinutes = expiresInMinutes;
    }

    public void setConfigFolder(String configFolder) {
        this.configFolder = configFolder;
    }

    private void loadAuthorizedClients() {

        Set newClients = new LinkedHashSet<>();



        final Scanner scanner;
        if (StringUtils.isEmpty(configFolder)) {
            log.warn("No odi.clients folder configured, taking default odi.clients configuration");
            scanner = new Scanner(getClass().getResourceAsStream("/" + CONFIG_FILE));
        } else {
            final File configFile = new File(configFolder, CONFIG_FILE);
            log.info("Loading odi clients from {}", configFile);
            try {
                scanner = new Scanner(configFile);
            } catch (FileNotFoundException e) {
                throw new RuntimeException("Missing ODI config " + configFile, e);
            }

        }
        try {
            while (scanner.hasNextLine()) {
                String line = scanner.nextLine();
                if (!line.startsWith("#") && !line.isEmpty()) {
                    OdiClient client = extractClientConfig(line);
                    newClients.add(client);
                }
            }
            clients = Collections.unmodifiableSet(newClients);
            log.info("Loaded {} odi clients", clients.size());
        } finally {
            scanner.close();
        }
    }

    private void appendConfigWatcher() {
        executorService.execute(new Runnable() {
            @Override
            public void run() {
                Path path = Paths.get(configFolder);

                WatchService watcher;
                try {
                    watcher = path.getFileSystem().newWatchService();
                    WatchEvent.Kind[] kinds = {StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_MODIFY};

                    path.register(watcher, kinds);
                } catch (IOException e) {
                    log.error("Error starting the odi config watcher on {}", CONFIG_FILE, e);
                    throw new RuntimeException(e);
                }

                while(running) {
                    try {
                        WatchKey key = watcher.take();
                        log.info("Reloading odi clients from {}", configFolder);
                        for (WatchEvent event : key.pollEvents()) {
                            final Path changed = (Path) event.context();
                            if (changed.endsWith(CONFIG_FILE)) {
                                loadAuthorizedClients();
                                break;
                            }
                        }
                        if (!key.reset()) {
                            log.info("Stopped watching as key " + key + " not valid");
                            break;
                        }

                    } catch (Exception e) {
                        log.error("Error reloading odi config from {}. ODI clients might not function properly. Retry after file update", CONFIG_FILE, e);
                    }
                }
            }
        });
    }

    protected OdiClient extractClientConfig(String line) {
        Matcher matcher = CONFIG_PATTERN.matcher(line);
        if(!matcher.find()) {
            throw new RuntimeException("Can't parse \"" + line + "\"");
        }

        String publicKey = matcher.group(1);
        String originsString = matcher.group(4);
        List origins = new ArrayList<>();
        for(String origin : originsString.split(",")) {
            if(!StringUtils.isEmpty(origin)) {
                origins.add(origin.trim());
            }
        }

        String secret = matcher.group(2);
        boolean allowXOrigin = matcher.group(3) != null && "true".equals(matcher.group(3));
        return new OdiClient(
            publicKey,
            origins,
            secret,
            allowXOrigin
        );
    }

    private void hasRecentDateHeader(HttpServletRequest request) {
        String value = request.getHeader(X_NPO_DATE.toLowerCase());
        Date date;
        try {
            date = Util.rfc822(value);
        } catch(Exception e) {
            return;
        }

        boolean expired = Math.abs(date.getTime() - System.currentTimeMillis()) > expiresInMinutes * 60 * 1000;
        if(expired) {
            log.debug("Expired authentication client: {} server: {}", date.getTime(), System.currentTimeMillis());
            throw new NoAccessException("not recent");
        }
    }

    private void hasMatchingMid(MediaObject media, HttpServletRequest request) {
        String header = request.getHeader(X_NPO_MID.toLowerCase());
        if (header == null) {
            throw new NoAccessException("no mid header");
        }
        if (media == null) {
            throw new NoAccessException("no media");
        }
        if (!media.getMid().equals(header)) {
            throw new NoAccessException("mid");
        }
    }

    private void hasMatchingLocation(Location location, HttpServletRequest request) {
        throw new NoAccessException("not implemented");
    }

    private void hasMatchingUrl(String url, HttpServletRequest request) {
        String header = request.getHeader(X_NPO_URL.toLowerCase());
        if (url != null && ! url.equals(header)) {
            throw new NoAccessException("url");
        }
    }

    private void isAuthorized(HttpServletRequest request) {
        String authorization = request.getHeader(AUTHORIZATION);
        if(authorization == null) {
            log.debug("Missing authorization");
            throw new NoAccessException("missing");
        }

        Matcher matcher = HEADER_PATTERN.matcher(authorization);
        if(!matcher.find()) {
            log.debug("Invalid authorization");
            throw new NoAccessException("invalid");
        }

        String publicKey = matcher.group(1);

        boolean hasXOrigin = false;
        String origin = request.getHeader("origin");
        if(origin == null) {
            origin = request.getHeader("X-Origin");
            hasXOrigin = true;
        }

        if(origin == null || origin.length() == 0) {
            log.debug("Missing origin");
            throw new NoAccessException("origin");
        }

        OdiClient client = findMatchingClient(publicKey, origin);
        if(client == null) {
            log.debug("Unauthorised client");
            throw new NoAccessException("client");
        }

        if(hasXOrigin && !client.isAllowXOrigin()) {
            log.debug("X-Origin not allowed for {}", client);
            throw new NoAccessException("xorigin");

        }

        String hmac = matcher.group(2);

        String expectedSecurityHeaders = Util.concatSecurityHeaders(request);
        String expectedHmap = Util.hmacSHA256(client.getSecret(), expectedSecurityHeaders);
        if(! expectedHmap.equals(hmac)) {
            log.debug("Invalid signature " + expectedSecurityHeaders);
            throw new NoAccessException("signature");

        }
    }

    private OdiClient findMatchingClient(String publicKey, String origin) {
        for(OdiClient client : clients) {
            if(client.getPublicKey().equals(publicKey)) {
               if (client.matchesOrigin(origin)) {
                   return client;
               }
            }
        }
        return null;
    }

    private HttpServletRequest patchIE89(HttpServletRequest request) {
        if(!isIE89(request)) {
            return request;
        }

        String ieHeader = request.getParameter("iecomp");
        if (ieHeader == null) {
            return request;
        }
        return new WrappedIE89HttpServletRequest(ieHeader, request);
    }

    private boolean isIE89(HttpServletRequest request) {
        String userAgent = request.getHeader("user-agent");
        return userAgent != null && (userAgent.contains("MSIE 9.0")
            || userAgent.contains("MSIE 8.0")
            || userAgent.contains("MSIE 7.0") // Compatibility mode
        );
    }

    public static class NoAccessException extends RuntimeException {
        private final String reason;
        NoAccessException(String reason) {
            super("No access");
            this.reason = reason;
        }
        public String getReason() {
            return reason;
        }
    }

    private static class WrappedIE89HttpServletRequest extends HttpServletRequestWrapper {

        private static final ObjectMapper mapper = new ObjectMapper();

        private final JsonNode param;

        public WrappedIE89HttpServletRequest(String ieParam, HttpServletRequest request) {
            super(request);

            try {
                param = mapper.readTree(Base64.decodeBase64(ieParam));
            } catch(IOException e) {
                throw new RuntimeException(e);
            }
        }

        @Override
        public String getHeader(String name) {
            switch(name) {
                case X_NPO_DATE:
                    return fromJson(X_NPO_DATE);
                case X_NPO_MID:
                    return fromJson(X_NPO_MID);
                case X_NPO_URL:
                    return fromJson(X_NPO_URL);
                case AUTHORIZATION:
                    return fromJson(AUTHORIZATION);
                default:
                    return super.getHeader(name);
            }
        }

        private String fromJson(String property) {
            JsonNode node = param.get(property);
            return node != null ? node.asText() : null;
        }
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy