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}