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}