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}