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;
017
018import java.net.URI;
019import java.net.URL;
020import java.security.KeyPair;
021import java.util.ArrayList;
022import java.util.Arrays;
023import java.util.Collections;
024import java.util.Iterator;
025import java.util.List;
026import java.util.Objects;
027import java.util.Optional;
028
029import edu.umd.cs.findbugs.annotations.Nullable;
030import org.shredzone.acme4j.connector.Connection;
031import org.shredzone.acme4j.connector.Resource;
032import org.shredzone.acme4j.connector.ResourceIterator;
033import org.shredzone.acme4j.exception.AcmeException;
034import org.shredzone.acme4j.exception.AcmeProtocolException;
035import org.shredzone.acme4j.exception.AcmeServerException;
036import org.shredzone.acme4j.toolbox.AcmeUtils;
037import org.shredzone.acme4j.toolbox.JSON;
038import org.shredzone.acme4j.toolbox.JSON.Value;
039import org.shredzone.acme4j.toolbox.JSONBuilder;
040import org.shredzone.acme4j.toolbox.JoseUtils;
041import org.slf4j.Logger;
042import org.slf4j.LoggerFactory;
043
044/**
045 * Represents an account at the ACME server.
046 */
047public class Account extends AcmeJsonResource {
048    private static final long serialVersionUID = 7042863483428051319L;
049    private static final Logger LOG = LoggerFactory.getLogger(Account.class);
050
051    private static final String KEY_TOS_AGREED = "termsOfServiceAgreed";
052    private static final String KEY_ORDERS = "orders";
053    private static final String KEY_CONTACT = "contact";
054    private static final String KEY_STATUS = "status";
055    private static final String KEY_EXTERNAL_ACCOUNT_BINDING = "externalAccountBinding";
056
057    protected Account(Login login) {
058        super(login, login.getAccountLocation());
059    }
060
061    /**
062     * Returns if the user agreed to the terms of service.
063     *
064     * @return {@code true} if the user agreed to the terms of service. May be
065     *         {@code null} if the server did not provide such an information.
066     */
067    @Nullable
068    public Boolean getTermsOfServiceAgreed() {
069        return getJSON().get(KEY_TOS_AGREED).map(Value::asBoolean).orElse(null);
070    }
071
072    /**
073     * List of contact addresses (emails, phone numbers etc).
074     */
075    public List<URI> getContacts() {
076        return Collections.unmodifiableList(getJSON().get(KEY_CONTACT)
077                    .asArray()
078                    .stream()
079                    .map(JSON.Value::asURI)
080                    .collect(toList()));
081    }
082
083    /**
084     * Returns the current status of the account.
085     * <p>
086     * Possible values are: {@link Status#VALID}, {@link Status#DEACTIVATED},
087     * {@link Status#REVOKED}.
088     */
089    public Status getStatus() {
090        return getJSON().get(KEY_STATUS).asStatus();
091    }
092
093    /**
094     * Returns {@code true} if the account is bound to an external non-ACME account.
095     *
096     * @since 2.8
097     */
098    public boolean hasExternalAccountBinding() {
099        return getJSON().contains(KEY_EXTERNAL_ACCOUNT_BINDING);
100    }
101
102    /**
103     * Returns the key identifier of the external non-ACME account. If this account is
104     * not bound to an external account, {@code null} is returned instead.
105     *
106     * @since 2.8
107     */
108    @Nullable
109    public String getKeyIdentifier() {
110        return getJSON().get(KEY_EXTERNAL_ACCOUNT_BINDING)
111                .optional().map(Value::asObject)
112                .map(j -> j.get("protected")).map(Value::asEncodedObject)
113                .map(j -> j.get("kid")).map(Value::asString)
114                .orElse(null);
115    }
116
117    /**
118     * Returns an {@link Iterator} of all {@link Order} belonging to this {@link Account}.
119     * <p>
120     * Using the iterator will initiate one or more requests to the ACME server.
121     *
122     * @return {@link Iterator} instance that returns {@link Order} objects in no specific
123     *         order. {@link Iterator#hasNext()} and {@link Iterator#next()} may throw
124     *         {@link AcmeProtocolException} if a batch of authorization URIs could not be
125     *         fetched from the server. Each {@link Iterator} instance may provide the
126     *         {@link Order} objects in a different order.
127     */
128    public Iterator<Order> getOrders() {
129        Optional<URL> ordersUrl = getJSON().get(KEY_ORDERS).optional().map(Value::asURL);
130        if (!ordersUrl.isPresent()) {
131            // Let's Encrypt does not provide this field at the moment although it's required.
132            // See https://github.com/letsencrypt/boulder/issues/3335
133            throw new AcmeProtocolException("This ACME server does not support getOrders()");
134        }
135        return new ResourceIterator<>(getLogin(), KEY_ORDERS, ordersUrl.get(), Login::bindOrder);
136    }
137
138    /**
139     * Creates a builder for a new {@link Order}.
140     *
141     * @return {@link OrderBuilder} object
142     */
143    public OrderBuilder newOrder() {
144        return new OrderBuilder(getLogin());
145    }
146
147    /**
148     * Pre-authorizes a domain. The CA will check if it accepts the domain for
149     * certification, and returns the necessary challenges.
150     * <p>
151     * Some servers may not allow pre-authorization.
152     * <p>
153     * It is not possible to pre-authorize wildcard domains.
154     *
155     * @param domain
156     *            Domain name to be pre-authorized. IDN names are accepted and will be ACE
157     *            encoded automatically.
158     * @return {@link Authorization} object for this domain
159     * @throws AcmeException
160     *             if the server does not allow pre-authorization
161     * @throws AcmeServerException
162     *             if the server allows pre-authorization, but will refuse to issue a
163     *             certificate for this domain
164     */
165    public Authorization preAuthorizeDomain(String domain) throws AcmeException {
166        Objects.requireNonNull(domain, "domain");
167        if (domain.isEmpty()) {
168            throw new IllegalArgumentException("domain must not be empty");
169        }
170        return preAuthorize(Identifier.dns(domain));
171    }
172
173    /**
174     * Pre-authorizes an {@link Identifier}. The CA will check if it accepts the
175     * identifier for certification, and returns the necessary challenges.
176     * <p>
177     * Some servers may not allow pre-authorization.
178     * <p>
179     * It is not possible to pre-authorize wildcard domains.
180     *
181     * @param identifier
182     *            {@link Identifier} to be pre-authorized.
183     * @return {@link Authorization} object for this identifier
184     * @throws AcmeException
185     *             if the server does not allow pre-authorization
186     * @throws AcmeServerException
187     *             if the server allows pre-authorization, but will refuse to issue a
188     *             certificate for this identifier
189     * @since 2.3
190     */
191    public Authorization preAuthorize(Identifier identifier) throws AcmeException {
192        Objects.requireNonNull(identifier, "identifier");
193
194        URL newAuthzUrl = getSession().resourceUrl(Resource.NEW_AUTHZ);
195
196        LOG.debug("preAuthorize {}", identifier);
197        try (Connection conn = getSession().connect()) {
198            JSONBuilder claims = new JSONBuilder();
199            claims.put("identifier", identifier.toMap());
200
201            conn.sendSignedRequest(newAuthzUrl, claims, getLogin());
202
203            URL authLocation = conn.getLocation();
204            if (authLocation == null) {
205                throw new AcmeProtocolException("Server did not provide an authorization location");
206            }
207
208            Authorization auth = getLogin().bindAuthorization(authLocation);
209            auth.setJSON(conn.readJsonResponse());
210            return auth;
211        }
212    }
213
214    /**
215     * Changes the {@link KeyPair} associated with the account.
216     * <p>
217     * After a successful call, the new key pair is used in the bound {@link Session},
218     * and the old key pair can be disposed of.
219     *
220     * @param newKeyPair
221     *            new {@link KeyPair} to be used for identifying this account
222     */
223    public void changeKey(KeyPair newKeyPair) throws AcmeException {
224        Objects.requireNonNull(newKeyPair, "newKeyPair");
225        if (Arrays.equals(getLogin().getKeyPair().getPrivate().getEncoded(),
226                        newKeyPair.getPrivate().getEncoded())) {
227            throw new IllegalArgumentException("newKeyPair must actually be a new key pair");
228        }
229
230        LOG.debug("key-change");
231
232        try (Connection conn = getSession().connect()) {
233            URL keyChangeUrl = getSession().resourceUrl(Resource.KEY_CHANGE);
234
235            JSONBuilder payloadClaim = new JSONBuilder();
236            payloadClaim.put("account", getLocation());
237            payloadClaim.putKey("oldKey", getLogin().getKeyPair().getPublic());
238
239            JSONBuilder jose = JoseUtils.createJoseRequest(keyChangeUrl, newKeyPair,
240                    payloadClaim, null, null);
241
242            conn.sendSignedRequest(keyChangeUrl, jose, getLogin());
243
244            getLogin().setKeyPair(newKeyPair);
245        }
246    }
247
248    /**
249     * Permanently deactivates an account. Related certificates may still be valid after
250     * account deactivation, and need to be revoked separately if neccessary.
251     * <p>
252     * A deactivated account cannot be reactivated!
253     */
254    public void deactivate() throws AcmeException {
255        LOG.debug("deactivate");
256        try (Connection conn = getSession().connect()) {
257            JSONBuilder claims = new JSONBuilder();
258            claims.put(KEY_STATUS, "deactivated");
259
260            conn.sendSignedRequest(getLocation(), claims, getLogin());
261            setJSON(conn.readJsonResponse());
262        }
263    }
264
265    /**
266     * Modifies the account data of the account.
267     *
268     * @return {@link EditableAccount} where the account can be modified
269     */
270    public EditableAccount modify() {
271        return new EditableAccount();
272    }
273
274    /**
275     * Editable {@link Account}.
276     */
277    public class EditableAccount {
278        private final List<URI> editContacts = new ArrayList<>();
279
280        private EditableAccount() {
281            editContacts.addAll(Account.this.getContacts());
282        }
283
284        /**
285         * Returns the list of all contact URIs for modification. Use the {@link List}
286         * methods to modify the contact list.
287         */
288        public List<URI> getContacts() {
289            return editContacts;
290        }
291
292        /**
293         * Adds a new Contact to the account.
294         *
295         * @param contact
296         *            Contact URI
297         * @return itself
298         */
299        public EditableAccount addContact(URI contact) {
300            AcmeUtils.validateContact(contact);
301            editContacts.add(contact);
302            return this;
303        }
304
305        /**
306         * Adds a new Contact to the account.
307         * <p>
308         * This is a convenience call for {@link #addContact(URI)}.
309         *
310         * @param contact
311         *            Contact URI as string
312         * @return itself
313         */
314        public EditableAccount addContact(String contact) {
315            addContact(URI.create(contact));
316            return this;
317        }
318
319        /**
320         * Adds a new Contact email to the account.
321         * <p>
322         * This is a convenience call for {@link #addContact(String)} hat doesn't
323         * require from you attach "mailto" scheme before email address.
324         *
325         * @param email
326         *            Contact email without "mailto" scheme (e.g. test@gmail.com)
327         * @return itself
328         */
329        public EditableAccount addEmail(String email) {
330            addContact("mailto:" + email);
331            return this;
332        }
333
334        /**
335         * Commits the changes and updates the account.
336         */
337        public void commit() throws AcmeException {
338            LOG.debug("modify/commit");
339            try (Connection conn = getSession().connect()) {
340                JSONBuilder claims = new JSONBuilder();
341                if (!editContacts.isEmpty()) {
342                    claims.put(KEY_CONTACT, editContacts);
343                }
344
345                conn.sendSignedRequest(getLocation(), claims, getLogin());
346                setJSON(conn.readJsonResponse());
347            }
348        }
349    }
350
351}