001/*
002 * acme4j - Java ACME client
003 *
004 * Copyright (C) 2015 Richard "Shred" K├Ârber
005 *   http://acme4j.shredzone.org
006 *
007 * Licensed under the Apache License, Version 2.0 (the "License");
008 * you may not use this file except in compliance with the License.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
013 */
014package org.shredzone.acme4j.provider;
015
016import java.net.URI;
017import java.time.ZonedDateTime;
018import java.util.Collections;
019import java.util.HashMap;
020import java.util.Map;
021import java.util.Objects;
022import java.util.ServiceLoader;
023
024import org.shredzone.acme4j.Login;
025import org.shredzone.acme4j.Session;
026import org.shredzone.acme4j.challenge.Challenge;
027import org.shredzone.acme4j.challenge.Dns01Challenge;
028import org.shredzone.acme4j.challenge.Http01Challenge;
029import org.shredzone.acme4j.challenge.TlsAlpn01Challenge;
030import org.shredzone.acme4j.challenge.TokenChallenge;
031import org.shredzone.acme4j.connector.Connection;
032import org.shredzone.acme4j.connector.DefaultConnection;
033import org.shredzone.acme4j.connector.HttpConnector;
034import org.shredzone.acme4j.connector.NetworkSettings;
035import org.shredzone.acme4j.exception.AcmeException;
036import org.shredzone.acme4j.toolbox.JSON;
037
038/**
039 * Abstract implementation of {@link AcmeProvider}. It consists of a challenge
040 * registry and a standard {@link HttpConnector}.
041 * <p>
042 * Implementing classes must implement at least {@link AcmeProvider#accepts(URI)}
043 * and {@link AbstractAcmeProvider#resolve(URI)}.
044 */
045public abstract class AbstractAcmeProvider implements AcmeProvider {
046    private static final int HTTP_NOT_MODIFIED = 304;
047
048    private static final Map<String, ChallengeProvider> CHALLENGES = challengeMap();
049
050    @Override
051    public Connection connect(URI serverUri, NetworkSettings networkSettings) {
052        return new DefaultConnection(createHttpConnector(networkSettings));
053    }
054
055    @Override
056    public JSON directory(Session session, URI serverUri) throws AcmeException {
057        var expires = session.getDirectoryExpires();
058        if (expires != null && expires.isAfter(ZonedDateTime.now())) {
059            // The cached directory is still valid
060            return null;
061        }
062
063        try (var conn = connect(serverUri, session.networkSettings())) {
064            var lastModified = session.getDirectoryLastModified();
065            var rc = conn.sendRequest(resolve(serverUri), session, lastModified);
066            if (lastModified != null && rc == HTTP_NOT_MODIFIED) {
067                // The server has not been modified since
068                return null;
069            }
070
071            // evaluate caching headers
072            session.setDirectoryLastModified(conn.getLastModified().orElse(null));
073            session.setDirectoryExpires(conn.getExpiration().orElse(null));
074
075            // use nonce header if there is one, saves a HEAD request...
076            conn.getNonce().ifPresent(session::setNonce);
077
078            return conn.readJsonResponse();
079        }
080    }
081
082    private static Map<String, ChallengeProvider> challengeMap() {
083        var map = new HashMap<String, ChallengeProvider>();
084
085        map.put(Dns01Challenge.TYPE, Dns01Challenge::new);
086        map.put(Http01Challenge.TYPE, Http01Challenge::new);
087        map.put(TlsAlpn01Challenge.TYPE, TlsAlpn01Challenge::new);
088
089        for (var provider : ServiceLoader.load(ChallengeProvider.class)) {
090            var typeAnno = provider.getClass().getAnnotation(ChallengeType.class);
091            if (typeAnno == null) {
092                throw new IllegalStateException("ChallengeProvider "
093                        + provider.getClass().getName()
094                        + " has no @ChallengeType annotation");
095            }
096            var type = typeAnno.value();
097            if (type == null || type.trim().isEmpty()) {
098                throw new IllegalStateException("ChallengeProvider "
099                        + provider.getClass().getName()
100                        + ": type must not be null or empty");
101            }
102            if (map.containsKey(type)) {
103                throw new IllegalStateException("ChallengeProvider "
104                        + provider.getClass().getName()
105                        + ": there is already a provider for challenge type "
106                        + type);
107            }
108            map.put(type, provider);
109        }
110
111        return Collections.unmodifiableMap(map);
112    }
113
114    /**
115     * {@inheritDoc}
116     * <p>
117     * This implementation handles the standard challenge types. For unknown types,
118     * generic {@link Challenge} or {@link TokenChallenge} instances are created.
119     * <p>
120     * Custom provider implementations may override this method to provide challenges that
121     * are proprietary to the provider.
122     */
123    @Override
124    public Challenge createChallenge(Login login, JSON data) {
125        Objects.requireNonNull(login, "login");
126        Objects.requireNonNull(data, "data");
127
128        var type = data.get("type").asString();
129
130        var constructor = CHALLENGES.get(type);
131        if (constructor != null) {
132            return constructor.create(login, data);
133        }
134
135        if (data.contains("token")) {
136            return new TokenChallenge(login, data);
137        } else {
138            return new Challenge(login, data);
139        }
140    }
141
142    /**
143     * Creates a {@link HttpConnector}.
144     * <p>
145     * Subclasses may override this method to configure the {@link HttpConnector}.
146     */
147    protected HttpConnector createHttpConnector(NetworkSettings settings) {
148        return new HttpConnector(settings);
149    }
150
151}