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; 015 016import static java.util.stream.Collectors.toList; 017import static org.shredzone.acme4j.toolbox.AcmeUtils.parseTimestamp; 018 019import java.net.HttpURLConnection; 020import java.net.URL; 021import java.time.Instant; 022import java.util.ArrayList; 023import java.util.Arrays; 024import java.util.Collection; 025import java.util.Collections; 026import java.util.List; 027 028import org.shredzone.acme4j.challenge.Challenge; 029import org.shredzone.acme4j.connector.Connection; 030import org.shredzone.acme4j.exception.AcmeException; 031import org.shredzone.acme4j.exception.AcmeProtocolException; 032import org.shredzone.acme4j.exception.AcmeRetryAfterException; 033import org.shredzone.acme4j.toolbox.JSON; 034import org.shredzone.acme4j.toolbox.JSONBuilder; 035import org.slf4j.Logger; 036import org.slf4j.LoggerFactory; 037 038/** 039 * Represents an authorization request at the ACME server. 040 */ 041public class Authorization extends AcmeResource { 042 private static final long serialVersionUID = -3116928998379417741L; 043 private static final Logger LOG = LoggerFactory.getLogger(Authorization.class); 044 045 private String domain; 046 private Status status; 047 private Instant expires; 048 private List<Challenge> challenges; 049 private List<List<Challenge>> combinations; 050 private boolean loaded = false; 051 052 protected Authorization(Session session, URL location) { 053 super(session); 054 setLocation(location); 055 } 056 057 /** 058 * Creates a new instance of {@link Authorization} and binds it to the 059 * {@link Session}. 060 * 061 * @param session 062 * {@link Session} to be used 063 * @param location 064 * Location of the Authorization 065 * @return {@link Authorization} bound to the session and location 066 */ 067 public static Authorization bind(Session session, URL location) { 068 return new Authorization(session, location); 069 } 070 071 /** 072 * Gets the domain name to be authorized. 073 */ 074 public String getDomain() { 075 load(); 076 return domain; 077 } 078 079 /** 080 * Gets the authorization status. 081 */ 082 public Status getStatus() { 083 load(); 084 return status; 085 } 086 087 /** 088 * Gets the expiry date of the authorization, if set by the server. 089 */ 090 public Instant getExpires() { 091 load(); 092 return expires; 093 } 094 095 /** 096 * Gets a list of all challenges offered by the server. 097 */ 098 public List<Challenge> getChallenges() { 099 load(); 100 return challenges; 101 } 102 103 /** 104 * Gets all combinations of challenges supported by the server. 105 */ 106 public List<List<Challenge>> getCombinations() { 107 load(); 108 return combinations; 109 } 110 111 /** 112 * Finds a single {@link Challenge} of the given type. Responding to this 113 * {@link Challenge} is sufficient for authorization. This is a convenience call to 114 * {@link #findCombination(String...)}. 115 * 116 * @param type 117 * Challenge name (e.g. "http-01") 118 * @return {@link Challenge} matching that name, or {@code null} if there is no such 119 * challenge, or if the challenge alone is not sufficient for authorization. 120 * @throws ClassCastException 121 * if the type does not match the expected Challenge class type 122 */ 123 @SuppressWarnings("unchecked") 124 public <T extends Challenge> T findChallenge(String type) { 125 return (T) findCombination(type).stream().findFirst().orElse(null); 126 } 127 128 /** 129 * Finds a combination of {@link Challenge} types that the client supports. The client 130 * has to respond to <em>all</em> of the {@link Challenge}s returned. However, this 131 * method attempts to find the combination with the smallest number of 132 * {@link Challenge}s. 133 * 134 * @param types 135 * Challenge name or names (e.g. "http-01"), in no particular order. 136 * Basically this is a collection of all challenge types supported by your 137 * implementation. 138 * @return Matching {@link Challenge} combination, or an empty collection if the ACME 139 * server does not support any of your challenges. The challenges are returned 140 * in no particular order. The result may be a subset of the types you have 141 * provided, if fewer challenges are actually required for a successful 142 * validation. 143 */ 144 public Collection<Challenge> findCombination(String... types) { 145 Collection<String> available = Arrays.asList(types); 146 Collection<String> combinationTypes = new ArrayList<>(); 147 148 Collection<Challenge> result = Collections.emptyList(); 149 150 for (List<Challenge> combination : getCombinations()) { 151 combinationTypes.clear(); 152 for (Challenge c : combination) { 153 combinationTypes.add(c.getType()); 154 } 155 156 if (available.containsAll(combinationTypes) && 157 (result.isEmpty() || result.size() > combination.size())) { 158 result = combination; 159 } 160 } 161 162 return Collections.unmodifiableCollection(result); 163 } 164 165 /** 166 * Updates the {@link Authorization}. After invocation, the {@link Authorization} 167 * reflects the current state at the ACME server. 168 * 169 * @throws AcmeRetryAfterException 170 * the auhtorization is still being validated, and the server returned an 171 * estimated date when the validation will be completed. If you are 172 * polling for the authorization to complete, you should wait for the date 173 * given in {@link AcmeRetryAfterException#getRetryAfter()}. Note that the 174 * authorization status is updated even if this exception was thrown. 175 */ 176 public void update() throws AcmeException { 177 LOG.debug("update"); 178 try (Connection conn = getSession().provider().connect()) { 179 conn.sendRequest(getLocation(), getSession()); 180 conn.accept(HttpURLConnection.HTTP_OK, HttpURLConnection.HTTP_ACCEPTED); 181 182 unmarshalAuthorization(conn.readJsonResponse()); 183 184 conn.handleRetryAfter("authorization is not completed yet"); 185 } 186 } 187 188 /** 189 * Permanently deactivates the {@link Authorization}. 190 */ 191 public void deactivate() throws AcmeException { 192 LOG.debug("deactivate"); 193 try (Connection conn = getSession().provider().connect()) { 194 JSONBuilder claims = new JSONBuilder(); 195 claims.putResource("authz"); 196 claims.put("status", "deactivated"); 197 198 conn.sendSignedRequest(getLocation(), claims, getSession()); 199 conn.accept(HttpURLConnection.HTTP_OK, HttpURLConnection.HTTP_ACCEPTED); 200 } 201 } 202 203 /** 204 * Lazily updates the object's state when one of the getters is invoked. 205 */ 206 protected void load() { 207 if (!loaded) { 208 try { 209 update(); 210 } catch (AcmeRetryAfterException ex) { 211 // ignore... The object was still updated. 212 LOG.debug("Retry-After", ex); 213 } catch (AcmeException ex) { 214 throw new AcmeProtocolException("Could not load lazily", ex); 215 } 216 } 217 } 218 219 /** 220 * Sets the properties according to the given JSON data. 221 * 222 * @param json 223 * JSON data 224 */ 225 protected void unmarshalAuthorization(JSON json) { 226 this.status = Status.parse(json.get("status").asString(), Status.PENDING); 227 228 String jsonExpires = json.get("expires").asString(); 229 if (jsonExpires != null) { 230 expires = parseTimestamp(jsonExpires); 231 } 232 233 JSON jsonIdentifier = json.get("identifier").asObject(); 234 if (jsonIdentifier != null) { 235 String type = jsonIdentifier.get("type").asString(); 236 if (type != null && !"dns".equals(type)) { 237 throw new AcmeProtocolException("Unknown authorization type: " + type); 238 } 239 domain = jsonIdentifier.get("value").asString(); 240 } 241 242 challenges = fetchChallenges(json); 243 combinations = fetchCombinations(json, challenges); 244 245 loaded = true; 246 } 247 248 /** 249 * Fetches all {@link Challenge} that are defined in the JSON. 250 * 251 * @param json 252 * {@link JSON} to read 253 * @return List of {@link Challenge} 254 */ 255 private List<Challenge> fetchChallenges(JSON json) { 256 Session session = getSession(); 257 258 return Collections.unmodifiableList(json.get("challenges").asArray().stream() 259 .map(JSON.Value::asObject) 260 .map(session::createChallenge) 261 .collect(toList())); 262 } 263 264 /** 265 * Fetches all possible combination of {@link Challenge} that are defined in the JSON. 266 * 267 * @param json 268 * {@link JSON} to read 269 * @param challenges 270 * List of available {@link Challenge} 271 * @return List of {@link Challenge} combinations 272 */ 273 private List<List<Challenge>> fetchCombinations(JSON json, List<Challenge> challenges) { 274 JSON.Array jsonCombinations = json.get("combinations").asArray(); 275 if (jsonCombinations == null) { 276 return Arrays.asList(challenges); 277 } 278 279 return Collections.unmodifiableList(jsonCombinations.stream() 280 .map(JSON.Value::asArray) 281 .map(this::findChallenges) 282 .collect(toList())); 283 } 284 285 /** 286 * Converts an array of challenge indexes to a list of matching {@link Challenge}. 287 * 288 * @param combination 289 * {@link Array} of the challenge indexes 290 * @return List of matching {@link Challenge} 291 */ 292 private List<Challenge> findChallenges(JSON.Array combination) { 293 return combination.stream() 294 .mapToInt(JSON.Value::asInt) 295 .mapToObj(challenges::get) 296 .collect(toList()); 297 } 298 299}