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