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.net.http.HttpClient;
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.DnsAccount01Challenge;
030import org.shredzone.acme4j.challenge.DnsPersist01Challenge;
031import org.shredzone.acme4j.challenge.Http01Challenge;
032import org.shredzone.acme4j.challenge.TlsAlpn01Challenge;
033import org.shredzone.acme4j.challenge.TokenChallenge;
034import org.shredzone.acme4j.connector.Connection;
035import org.shredzone.acme4j.connector.DefaultConnection;
036import org.shredzone.acme4j.connector.HttpConnector;
037import org.shredzone.acme4j.connector.NetworkSettings;
038import org.shredzone.acme4j.exception.AcmeException;
039import org.shredzone.acme4j.toolbox.JSON;
040
041/**
042 * Abstract implementation of {@link AcmeProvider}. It consists of a challenge
043 * registry and a standard {@link HttpConnector}.
044 * <p>
045 * Implementing classes must implement at least {@link AcmeProvider#accepts(URI)}
046 * and {@link AbstractAcmeProvider#resolve(URI)}.
047 */
048public abstract class AbstractAcmeProvider implements AcmeProvider {
049    private static final int HTTP_NOT_MODIFIED = 304;
050
051    private static final Map<String, ChallengeProvider> CHALLENGES = challengeMap();
052
053    @Override
054    public Connection connect(URI serverUri, NetworkSettings networkSettings, HttpClient httpClient) {
055        return new DefaultConnection(createHttpConnector(networkSettings, httpClient));
056    }
057
058    @Override
059    public JSON directory(Session session, URI serverUri) throws AcmeException {
060        var expires = session.getDirectoryExpires();
061        if (expires != null && expires.isAfter(ZonedDateTime.now())) {
062            // The cached directory is still valid
063            return null;
064        }
065
066        try (var nonceHolder = session.lockNonce();
067             var conn = connect(serverUri, session.networkSettings(), session.getHttpClient())) {
068            var lastModified = session.getDirectoryLastModified();
069            var rc = conn.sendRequest(resolve(serverUri), session, lastModified);
070            if (lastModified != null && rc == HTTP_NOT_MODIFIED) {
071                // The server has not been modified since
072                return null;
073            }
074
075            // evaluate caching headers
076            session.setDirectoryLastModified(conn.getLastModified().orElse(null));
077            session.setDirectoryExpires(conn.getExpiration().orElse(null));
078
079            // use nonce header if there is one, saves a HEAD request...
080            conn.getNonce().ifPresent(nonceHolder::setNonce);
081
082            return conn.readJsonResponse();
083        }
084    }
085
086    private static Map<String, ChallengeProvider> challengeMap() {
087        var map = new HashMap<String, ChallengeProvider>();
088
089        map.put(Dns01Challenge.TYPE, Dns01Challenge::new);
090        map.put(DnsAccount01Challenge.TYPE, DnsAccount01Challenge::new);
091        map.put(DnsPersist01Challenge.TYPE, DnsPersist01Challenge::new);
092        map.put(Http01Challenge.TYPE, Http01Challenge::new);
093        map.put(TlsAlpn01Challenge.TYPE, TlsAlpn01Challenge::new);
094
095        for (var provider : ServiceLoader.load(ChallengeProvider.class)) {
096            var typeAnno = provider.getClass().getAnnotation(ChallengeType.class);
097            if (typeAnno == null) {
098                throw new IllegalStateException("ChallengeProvider "
099                        + provider.getClass().getName()
100                        + " has no @ChallengeType annotation");
101            }
102            var type = typeAnno.value();
103            if (type == null || type.trim().isEmpty()) {
104                throw new IllegalStateException("ChallengeProvider "
105                        + provider.getClass().getName()
106                        + ": type must not be null or empty");
107            }
108            if (map.containsKey(type)) {
109                throw new IllegalStateException("ChallengeProvider "
110                        + provider.getClass().getName()
111                        + ": there is already a provider for challenge type "
112                        + type);
113            }
114            map.put(type, provider);
115        }
116
117        return Collections.unmodifiableMap(map);
118    }
119
120    /**
121     * {@inheritDoc}
122     * <p>
123     * This implementation handles the standard challenge types. For unknown types,
124     * generic {@link Challenge} or {@link TokenChallenge} instances are created.
125     * <p>
126     * Custom provider implementations may override this method to provide challenges that
127     * are proprietary to the provider.
128     */
129    @Override
130    public Challenge createChallenge(Login login, JSON data) {
131        Objects.requireNonNull(login, "login");
132        Objects.requireNonNull(data, "data");
133
134        var type = data.get("type").asString();
135
136        var constructor = CHALLENGES.get(type);
137        if (constructor != null) {
138            return constructor.create(login, data);
139        }
140
141        if (data.contains("token")) {
142            return new TokenChallenge(login, data);
143        } else {
144            return new Challenge(login, data);
145        }
146    }
147
148    /**
149     * Creates a {@link HttpConnector} with the given {@link NetworkSettings} and
150     * {@link HttpClient}.
151     * <p>
152     * Subclasses may override this method to configure the {@link HttpConnector}.
153     *
154     * @param settings The network settings to use
155     * @param httpClient The HTTP client to use
156     * @return A new {@link HttpConnector} instance
157     * @since 4.0.0
158     */
159    protected HttpConnector createHttpConnector(NetworkSettings settings, HttpClient httpClient) {
160        return new HttpConnector(settings, httpClient);
161    }
162
163}