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.HttpURLConnection;
017import java.net.URI;
018import java.time.ZonedDateTime;
019import java.util.Collections;
020import java.util.HashMap;
021import java.util.Map;
022import java.util.Objects;
023import java.util.ServiceLoader;
024
025import org.shredzone.acme4j.Login;
026import org.shredzone.acme4j.Session;
027import org.shredzone.acme4j.challenge.Challenge;
028import org.shredzone.acme4j.challenge.Dns01Challenge;
029import org.shredzone.acme4j.challenge.Http01Challenge;
030import org.shredzone.acme4j.challenge.TlsAlpn01Challenge;
031import org.shredzone.acme4j.challenge.TokenChallenge;
032import org.shredzone.acme4j.connector.Connection;
033import org.shredzone.acme4j.connector.DefaultConnection;
034import org.shredzone.acme4j.connector.HttpConnector;
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
047    private static final Map<String, ChallengeProvider> CHALLENGES = challengeMap();
048
049    @Override
050    public Connection connect(URI serverUri) {
051        return new DefaultConnection(createHttpConnector());
052    }
053
054    @Override
055    public JSON directory(Session session, URI serverUri) throws AcmeException {
056        ZonedDateTime expires = session.getDirectoryExpires();
057        if (expires != null && expires.isAfter(ZonedDateTime.now())) {
058            // The cached directory is still valid
059            return null;
060        }
061
062        try (Connection conn = connect(serverUri)) {
063            ZonedDateTime lastModified = session.getDirectoryLastModified();
064            int rc = conn.sendRequest(resolve(serverUri), session, lastModified);
065            if (lastModified != null && rc == HttpURLConnection.HTTP_NOT_MODIFIED) {
066                // The server has not been modified since
067                return null;
068            }
069
070            // evaluate caching headers
071            session.setDirectoryLastModified(conn.getLastModified().orElse(null));
072            session.setDirectoryExpires(conn.getExpiration().orElse(null));
073
074            // use nonce header if there is one, saves a HEAD request...
075            String nonce = conn.getNonce();
076            if (nonce != null) {
077                session.setNonce(nonce);
078            }
079
080            return conn.readJsonResponse();
081        }
082    }
083
084    private static Map<String, ChallengeProvider> challengeMap() {
085        Map<String, ChallengeProvider> map = new HashMap<>();
086
087        map.put(Dns01Challenge.TYPE, Dns01Challenge::new);
088        map.put(Http01Challenge.TYPE, Http01Challenge::new);
089        map.put(TlsAlpn01Challenge.TYPE, TlsAlpn01Challenge::new);
090
091        for (ChallengeProvider provider : ServiceLoader.load(ChallengeProvider.class)) {
092            ChallengeType typeAnno = provider.getClass().getAnnotation(ChallengeType.class);
093            if (typeAnno == null) {
094                throw new IllegalStateException("ChallengeProvider "
095                        + provider.getClass().getName()
096                        + " has no @ChallengeType annotation");
097            }
098            String type = typeAnno.value();
099            if (type == null || type.trim().isEmpty()) {
100                throw new IllegalStateException("ChallengeProvider "
101                        + provider.getClass().getName()
102                        + ": type must not be null or empty");
103            }
104            if (map.containsKey(type)) {
105                throw new IllegalStateException("ChallengeProvider "
106                        + provider.getClass().getName()
107                        + ": there is already a provider for challenge type "
108                        + type);
109            }
110            map.put(type, provider);
111        }
112
113        return Collections.unmodifiableMap(map);
114    }
115
116    /**
117     * {@inheritDoc}
118     * <p>
119     * This implementation handles the standard challenge types. For unknown types,
120     * generic {@link Challenge} or {@link TokenChallenge} instances are created.
121     * <p>
122     * Custom provider implementations may override this method to provide challenges that
123     * are unique to the provider.
124     */
125    @Override
126    public Challenge createChallenge(Login login, JSON data) {
127        Objects.requireNonNull(login, "login");
128        Objects.requireNonNull(data, "data");
129
130        String type = data.get("type").asString();
131
132        ChallengeProvider constructor = CHALLENGES.get(type);
133        if (constructor != null) {
134            return constructor.create(login, data);
135        }
136
137        if (data.contains("token")) {
138            return new TokenChallenge(login, data);
139        } else {
140            return new Challenge(login, data);
141        }
142    }
143
144    /**
145     * Creates a {@link HttpConnector}.
146     * <p>
147     * Subclasses may override this method to configure the {@link HttpConnector}.
148     */
149    protected HttpConnector createHttpConnector() {
150        return new HttpConnector();
151    }
152
153}