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.challenge; 015 016import java.net.HttpURLConnection; 017import java.net.URL; 018import java.time.Instant; 019import java.util.Objects; 020 021import org.shredzone.acme4j.AcmeResource; 022import org.shredzone.acme4j.Problem; 023import org.shredzone.acme4j.Session; 024import org.shredzone.acme4j.Status; 025import org.shredzone.acme4j.connector.Connection; 026import org.shredzone.acme4j.exception.AcmeException; 027import org.shredzone.acme4j.exception.AcmeProtocolException; 028import org.shredzone.acme4j.exception.AcmeRetryAfterException; 029import org.shredzone.acme4j.toolbox.JSON; 030import org.shredzone.acme4j.toolbox.JSONBuilder; 031import org.slf4j.Logger; 032import org.slf4j.LoggerFactory; 033 034/** 035 * A generic challenge. It can be used as a base class for actual challenge 036 * implementations, but it is also used if the ACME server offers a proprietary challenge 037 * that is unknown to acme4j. 038 * <p> 039 * Subclasses must override {@link Challenge#acceptable(String)} so it only accepts the 040 * own type. {@link Challenge#respond(JSONBuilder)} should be overridden to put all 041 * required data to the response. 042 */ 043public class Challenge extends AcmeResource { 044 private static final long serialVersionUID = 2338794776848388099L; 045 private static final Logger LOG = LoggerFactory.getLogger(Challenge.class); 046 047 protected static final String KEY_TYPE = "type"; 048 protected static final String KEY_STATUS = "status"; 049 protected static final String KEY_URI = "uri"; 050 protected static final String KEY_VALIDATED = "validated"; 051 protected static final String KEY_ERROR = "error"; 052 053 private JSON data = JSON.empty(); 054 055 /** 056 * Creates a new generic {@link Challenge} object. 057 * 058 * @param session 059 * {@link Session} to bind to. 060 */ 061 public Challenge(Session session) { 062 super(session); 063 } 064 065 /** 066 * Returns a {@link Challenge} object of an existing challenge. 067 * 068 * @param session 069 * {@link Session} to be used 070 * @param location 071 * Challenge location 072 * @return {@link Challenge} bound to this session and location 073 */ 074 @SuppressWarnings("unchecked") 075 public static <T extends Challenge> T bind(Session session, URL location) throws AcmeException { 076 Objects.requireNonNull(session, "session"); 077 Objects.requireNonNull(location, "location"); 078 079 LOG.debug("bind"); 080 try (Connection conn = session.provider().connect()) { 081 conn.sendRequest(location, session); 082 conn.accept(HttpURLConnection.HTTP_OK, HttpURLConnection.HTTP_ACCEPTED); 083 084 JSON json = conn.readJsonResponse(); 085 if (!(json.contains("type"))) { 086 throw new IllegalArgumentException("Provided URI is not a challenge URI"); 087 } 088 089 return (T) session.createChallenge(json); 090 } 091 } 092 093 /** 094 * Returns the challenge type by name (e.g. "http-01"). 095 */ 096 public String getType() { 097 return data.get(KEY_TYPE).asString(); 098 } 099 100 /** 101 * Returns the current status of the challenge. 102 */ 103 public Status getStatus() { 104 return Status.parse(data.get(KEY_STATUS).asString(), Status.PENDING); 105 } 106 107 /** 108 * Returns the location {@link URL} of the challenge. 109 */ 110 @Override 111 public URL getLocation() { 112 return data.get(KEY_URI).asURL(); 113 } 114 115 /** 116 * Returns the validation date, if returned by the server. 117 */ 118 public Instant getValidated() { 119 return data.get(KEY_VALIDATED).asInstant(); 120 } 121 122 /** 123 * Returns the reason why the challenge failed, if returned by the server. 124 */ 125 public Problem getError() { 126 return data.get(KEY_ERROR).asProblem(); 127 } 128 129 /** 130 * Returns the JSON representation of the challenge data. 131 */ 132 protected JSON getJSON() { 133 return data; 134 } 135 136 /** 137 * Exports the response state, as preparation for triggering the challenge. 138 * 139 * @param cb 140 * {@link JSONBuilder} to copy the response to 141 */ 142 protected void respond(JSONBuilder cb) { 143 cb.put(KEY_TYPE, getType()); 144 } 145 146 /** 147 * Checks if the type is acceptable to this challenge. 148 * 149 * @param type 150 * Type to check 151 * @return {@code true} if acceptable, {@code false} if not 152 */ 153 protected boolean acceptable(String type) { 154 return type != null && !type.trim().isEmpty(); 155 } 156 157 /** 158 * Sets the challenge state to the given JSON map. 159 * 160 * @param json 161 * JSON containing the challenge data 162 */ 163 public void unmarshall(JSON json) { 164 String type = json.get(KEY_TYPE).asString(); 165 if (type == null) { 166 throw new IllegalArgumentException("map does not contain a type"); 167 } 168 if (!acceptable(type)) { 169 throw new AcmeProtocolException("wrong type: " + type); 170 } 171 172 data = json; 173 authorize(); 174 } 175 176 /** 177 * Callback that is invoked when the challenge is supposed to compute its 178 * authorization data. 179 */ 180 protected void authorize() { 181 // Does nothing here... 182 } 183 184 /** 185 * Triggers this {@link Challenge}. The ACME server is requested to validate the 186 * response. Note that the validation is performed asynchronously by the ACME server. 187 */ 188 public void trigger() throws AcmeException { 189 LOG.debug("trigger"); 190 try (Connection conn = getSession().provider().connect()) { 191 JSONBuilder claims = new JSONBuilder(); 192 claims.putResource("challenge"); 193 respond(claims); 194 195 conn.sendSignedRequest(getLocation(), claims, getSession()); 196 conn.accept(HttpURLConnection.HTTP_OK, HttpURLConnection.HTTP_ACCEPTED); 197 198 unmarshall(conn.readJsonResponse()); 199 } 200 } 201 202 /** 203 * Updates the state of this challenge. 204 * 205 * @throws AcmeRetryAfterException 206 * the challenge is still being validated, and the server returned an 207 * estimated date when the challenge will be completed. If you are polling 208 * for the challenge to complete, you should wait for the date given in 209 * {@link AcmeRetryAfterException#getRetryAfter()}. Note that the 210 * challenge status is updated even if this exception was thrown. 211 */ 212 public void update() throws AcmeException { 213 LOG.debug("update"); 214 try (Connection conn = getSession().provider().connect()) { 215 conn.sendRequest(getLocation(), getSession()); 216 conn.accept(HttpURLConnection.HTTP_OK, HttpURLConnection.HTTP_ACCEPTED); 217 218 unmarshall(conn.readJsonResponse()); 219 220 conn.handleRetryAfter("challenge is not completed yet"); 221 } 222 } 223 224}