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.time.Duration; 017import java.time.Instant; 018import java.util.EnumSet; 019import java.util.Optional; 020 021import org.shredzone.acme4j.AcmeJsonResource; 022import org.shredzone.acme4j.Login; 023import org.shredzone.acme4j.PollableResource; 024import org.shredzone.acme4j.Problem; 025import org.shredzone.acme4j.Status; 026import org.shredzone.acme4j.exception.AcmeException; 027import org.shredzone.acme4j.exception.AcmeProtocolException; 028import org.shredzone.acme4j.toolbox.JSON; 029import org.shredzone.acme4j.toolbox.JSON.Value; 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 its 040 * own type. {@link Challenge#prepareResponse(JSONBuilder)} can be overridden to put all 041 * required data to the challenge response. 042 */ 043public class Challenge extends AcmeJsonResource implements PollableResource { 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_URL = "url"; 049 protected static final String KEY_STATUS = "status"; 050 protected static final String KEY_VALIDATED = "validated"; 051 protected static final String KEY_ERROR = "error"; 052 053 /** 054 * Creates a new generic {@link Challenge} object. 055 * 056 * @param login 057 * {@link Login} the resource is bound with 058 * @param data 059 * {@link JSON} challenge data 060 */ 061 public Challenge(Login login, JSON data) { 062 super(login, data.get(KEY_URL).asURL()); 063 setJSON(data); 064 } 065 066 /** 067 * Returns the challenge type by name (e.g. "http-01"). 068 */ 069 public String getType() { 070 return getJSON().get(KEY_TYPE).asString(); 071 } 072 073 /** 074 * Returns the current status of the challenge. 075 * <p> 076 * Possible values are: {@link Status#PENDING}, {@link Status#PROCESSING}, 077 * {@link Status#VALID}, {@link Status#INVALID}. 078 * <p> 079 * A challenge is only completed when it reaches either status {@link Status#VALID} or 080 * {@link Status#INVALID}. 081 */ 082 @Override 083 public Status getStatus() { 084 return getJSON().get(KEY_STATUS).asStatus(); 085 } 086 087 /** 088 * Returns the validation date, if returned by the server. 089 */ 090 public Optional<Instant> getValidated() { 091 return getJSON().get(KEY_VALIDATED).map(Value::asInstant); 092 } 093 094 /** 095 * Returns a reason why the challenge has failed in the past, if returned by the 096 * server. If there are multiple errors, they can be found in 097 * {@link Problem#getSubProblems()}. 098 */ 099 public Optional<Problem> getError() { 100 return getJSON().get(KEY_ERROR).map(it -> it.asProblem(getLocation())); 101 } 102 103 /** 104 * Prepares the response message for triggering the challenge. Subclasses can add 105 * fields to the {@link JSONBuilder} as required by the challenge. Implementations of 106 * subclasses should make sure that {@link #prepareResponse(JSONBuilder)} of the 107 * superclass is invoked. 108 * 109 * @param response 110 * {@link JSONBuilder} to write the response to 111 */ 112 protected void prepareResponse(JSONBuilder response) { 113 // Do nothing here... 114 } 115 116 /** 117 * Checks if the type is acceptable to this challenge. This generic class only checks 118 * if the type is not blank. Subclasses should instead check if the given type matches 119 * expected challenge type. 120 * 121 * @param type 122 * Type to check 123 * @return {@code true} if acceptable, {@code false} if not 124 */ 125 protected boolean acceptable(String type) { 126 return type != null && !type.trim().isEmpty(); 127 } 128 129 @Override 130 protected void setJSON(JSON json) { 131 var type = json.get(KEY_TYPE).asString(); 132 133 if (!acceptable(type)) { 134 throw new AcmeProtocolException("incompatible type " + type + " for this challenge"); 135 } 136 137 var loc = json.get(KEY_URL).asString(); 138 if (!loc.equals(getLocation().toString())) { 139 throw new AcmeProtocolException("challenge has changed its location"); 140 } 141 142 super.setJSON(json); 143 } 144 145 /** 146 * Triggers this {@link Challenge}. The ACME server is requested to validate the 147 * response. Note that the validation is performed asynchronously by the ACME server. 148 * <p> 149 * After a challenge is triggered, it changes to {@link Status#PENDING}. As soon as 150 * validation takes place, it changes to {@link Status#PROCESSING}. After validation 151 * the status changes to {@link Status#VALID} or {@link Status#INVALID}, depending on 152 * the outcome of the validation. 153 * <p> 154 * If the challenge requires a resource to be set on your side (e.g. a DNS record or 155 * an HTTP file), it <em>must</em> be reachable from public before {@link #trigger()} 156 * is invoked, and <em>must not</em> be taken down until the challenge has reached 157 * {@link Status#VALID} or {@link Status#INVALID}. 158 * <p> 159 * If this method is invoked a second time, the ACME server is requested to retry the 160 * validation. This can be useful if the client state has changed, for example after a 161 * firewall rule has been updated. 162 * 163 * @see #waitForCompletion(Duration) 164 */ 165 public void trigger() throws AcmeException { 166 LOG.debug("trigger"); 167 try (var conn = getSession().connect()) { 168 var claims = new JSONBuilder(); 169 prepareResponse(claims); 170 171 conn.sendSignedRequest(getLocation(), claims, getLogin()); 172 setJSON(conn.readJsonResponse()); 173 } 174 } 175 176 /** 177 * Waits until the challenge is completed. 178 * <p> 179 * Is is completed if it reaches either {@link Status#VALID} or 180 * {@link Status#INVALID}. 181 * <p> 182 * This method is synchronous and blocks the current thread. 183 * 184 * @param timeout 185 * Timeout until a terminal status must have been reached 186 * @return Status that was reached 187 * @since 3.4.0 188 */ 189 public Status waitForCompletion(Duration timeout) 190 throws AcmeException, InterruptedException { 191 return waitForStatus(EnumSet.of(Status.VALID, Status.INVALID), timeout); 192 } 193 194}