
org.shredzone.acme4j.Account Maven / Gradle / Ivy
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j;
import static java.util.stream.Collectors.toUnmodifiableList;
import java.net.URI;
import java.security.KeyPair;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.shredzone.acme4j.connector.Resource;
import org.shredzone.acme4j.connector.ResourceIterator;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeNotSupportedException;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.exception.AcmeServerException;
import org.shredzone.acme4j.toolbox.AcmeUtils;
import org.shredzone.acme4j.toolbox.JSON.Value;
import org.shredzone.acme4j.toolbox.JSONBuilder;
import org.shredzone.acme4j.toolbox.JoseUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* A representation of an account at the ACME server.
*/
public class Account extends AcmeJsonResource {
private static final long serialVersionUID = 7042863483428051319L;
private static final Logger LOG = LoggerFactory.getLogger(Account.class);
private static final String KEY_TOS_AGREED = "termsOfServiceAgreed";
private static final String KEY_ORDERS = "orders";
private static final String KEY_CONTACT = "contact";
private static final String KEY_STATUS = "status";
private static final String KEY_EXTERNAL_ACCOUNT_BINDING = "externalAccountBinding";
protected Account(Login login) {
super(login, login.getAccountLocation());
}
/**
* Returns if the user agreed to the terms of service.
*
* @return {@code true} if the user agreed to the terms of service. May be
* empty if the server did not provide such an information.
*/
public Optional getTermsOfServiceAgreed() {
return getJSON().get(KEY_TOS_AGREED).map(Value::asBoolean);
}
/**
* List of registered contact addresses (emails, phone numbers etc).
*
* This list is unmodifiable. Use {@link #modify()} to change the contacts. May be
* empty, but is never {@code null}.
*/
public List getContacts() {
return getJSON().get(KEY_CONTACT)
.asArray()
.stream()
.map(Value::asURI)
.collect(toUnmodifiableList());
}
/**
* Returns the current status of the account.
*
* Possible values are: {@link Status#VALID}, {@link Status#DEACTIVATED},
* {@link Status#REVOKED}.
*/
public Status getStatus() {
return getJSON().get(KEY_STATUS).asStatus();
}
/**
* Returns {@code true} if the account is bound to an external non-ACME account.
*
* @since 2.8
*/
public boolean hasExternalAccountBinding() {
return getJSON().contains(KEY_EXTERNAL_ACCOUNT_BINDING);
}
/**
* Returns the key identifier of the external non-ACME account. If this account is
* not bound to an external account, the result is empty.
*
* @since 2.8
*/
public Optional getKeyIdentifier() {
return getJSON().get(KEY_EXTERNAL_ACCOUNT_BINDING)
.optional().map(Value::asObject)
.map(j -> j.get("protected")).map(Value::asEncodedObject)
.map(j -> j.get("kid")).map(Value::asString);
}
/**
* Returns an {@link Iterator} of all {@link Order} belonging to this
* {@link Account}.
*
* Using the iterator will initiate one or more requests to the ACME server.
*
* @return {@link Iterator} instance that returns {@link Order} objects in no specific
* sorting order. {@link Iterator#hasNext()} and {@link Iterator#next()} may throw
* {@link AcmeProtocolException} if a batch of authorization URIs could not be fetched
* from the server.
*/
public Iterator getOrders() {
var ordersUrl = getJSON().get(KEY_ORDERS).optional().map(Value::asURL);
if (ordersUrl.isEmpty()) {
// Let's Encrypt does not provide this field at the moment, although it's required.
// See https://github.com/letsencrypt/boulder/issues/3335
throw new AcmeNotSupportedException("getOrders()");
}
return new ResourceIterator<>(getLogin(), KEY_ORDERS, ordersUrl.get(), Login::bindOrder);
}
/**
* Creates a builder for a new {@link Order}.
*
* @return {@link OrderBuilder} object
*/
public OrderBuilder newOrder() {
return getLogin().newOrder();
}
/**
* Pre-authorizes a domain. The CA will check if it accepts the domain for
* certification, and returns the necessary challenges.
*
* Some servers may not allow pre-authorization.
*
* It is not possible to pre-authorize wildcard domains.
*
* @param domain
* Domain name to be pre-authorized. IDN names are accepted and will be ACE
* encoded automatically.
* @return {@link Authorization} object for this domain
* @throws AcmeException
* if the server does not allow pre-authorization
* @throws AcmeServerException
* if the server allows pre-authorization, but will refuse to issue a
* certificate for this domain
*/
public Authorization preAuthorizeDomain(String domain) throws AcmeException {
Objects.requireNonNull(domain, "domain");
if (domain.isEmpty()) {
throw new IllegalArgumentException("domain must not be empty");
}
return preAuthorize(Identifier.dns(domain));
}
/**
* Pre-authorizes an {@link Identifier}. The CA will check if it accepts the
* identifier for certification, and returns the necessary challenges.
*
* Some servers may not allow pre-authorization.
*
* It is not possible to pre-authorize wildcard domains.
*
* @param identifier
* {@link Identifier} to be pre-authorized.
* @return {@link Authorization} object for this identifier
* @throws AcmeException
* if the server does not allow pre-authorization
* @throws AcmeServerException
* if the server allows pre-authorization, but will refuse to issue a
* certificate for this identifier
* @since 2.3
*/
public Authorization preAuthorize(Identifier identifier) throws AcmeException {
Objects.requireNonNull(identifier, "identifier");
var newAuthzUrl = getSession().resourceUrl(Resource.NEW_AUTHZ);
if (identifier.toMap().containsKey(Identifier.KEY_SUBDOMAIN_AUTH_ALLOWED)
&& !getSession().getMetadata().isSubdomainAuthAllowed()) {
throw new AcmeNotSupportedException("subdomain-auth");
}
LOG.debug("preAuthorize {}", identifier);
try (var conn = getSession().connect()) {
var claims = new JSONBuilder();
claims.put("identifier", identifier.toMap());
conn.sendSignedRequest(newAuthzUrl, claims, getLogin());
var auth = getLogin().bindAuthorization(conn.getLocation());
auth.setJSON(conn.readJsonResponse());
return auth;
}
}
/**
* Changes the {@link KeyPair} associated with the account.
*
* After a successful call, the new key pair is already set in the associated
* {@link Login}. The old key pair can be discarded.
*
* @param newKeyPair
* new {@link KeyPair} to be used for identifying this account
*/
public void changeKey(KeyPair newKeyPair) throws AcmeException {
Objects.requireNonNull(newKeyPair, "newKeyPair");
if (Arrays.equals(getLogin().getKeyPair().getPrivate().getEncoded(),
newKeyPair.getPrivate().getEncoded())) {
throw new IllegalArgumentException("newKeyPair must actually be a new key pair");
}
LOG.debug("key-change");
try (var conn = getSession().connect()) {
var keyChangeUrl = getSession().resourceUrl(Resource.KEY_CHANGE);
var payloadClaim = new JSONBuilder();
payloadClaim.put("account", getLocation());
payloadClaim.putKey("oldKey", getLogin().getKeyPair().getPublic());
var jose = JoseUtils.createJoseRequest(keyChangeUrl, newKeyPair,
payloadClaim, null, null);
conn.sendSignedRequest(keyChangeUrl, jose, getLogin());
getLogin().setKeyPair(newKeyPair);
}
}
/**
* Permanently deactivates an account. Related certificates may still be valid after
* account deactivation, and need to be revoked separately if neccessary.
*
* A deactivated account cannot be reactivated!
*/
public void deactivate() throws AcmeException {
LOG.debug("deactivate");
try (var conn = getSession().connect()) {
var claims = new JSONBuilder();
claims.put(KEY_STATUS, "deactivated");
conn.sendSignedRequest(getLocation(), claims, getLogin());
setJSON(conn.readJsonResponse());
}
}
/**
* Modifies the account data of the account.
*
* @return {@link EditableAccount} where the account can be modified
*/
public EditableAccount modify() {
return new EditableAccount();
}
/**
* Provides editable properties of an {@link Account}.
*/
public class EditableAccount {
private final List editContacts = new ArrayList<>();
private EditableAccount() {
editContacts.addAll(Account.this.getContacts());
}
/**
* Returns the list of all contact URIs for modification. Use the {@link List}
* methods to modify the contact list.
*
* The modified list is not validated. If you change entries, you have to make
* sure that they are valid according to the RFC. It is recommended to use
* the {@code addContact()} methods below to add new contacts to the list.
*/
@SuppressFBWarnings("EI_EXPOSE_REP") // behavior is intended
public List getContacts() {
return editContacts;
}
/**
* Adds a new Contact to the account.
*
* @param contact
* Contact URI
* @return itself
*/
public EditableAccount addContact(URI contact) {
AcmeUtils.validateContact(contact);
editContacts.add(contact);
return this;
}
/**
* Adds a new Contact to the account.
*
* This is a convenience call for {@link #addContact(URI)}.
*
* @param contact
* Contact URI as string
* @return itself
*/
public EditableAccount addContact(String contact) {
addContact(URI.create(contact));
return this;
}
/**
* Adds a new Contact email to the account.
*
* This is a convenience call for {@link #addContact(String)} that doesn't
* require to prepend the email address with the "mailto" scheme.
*
* @param email
* Contact email without "mailto" scheme (e.g. [email protected])
* @return itself
*/
public EditableAccount addEmail(String email) {
addContact("mailto:" + email);
return this;
}
/**
* Commits the changes and updates the account.
*/
public void commit() throws AcmeException {
LOG.debug("modify/commit");
try (var conn = getSession().connect()) {
var claims = new JSONBuilder();
if (!editContacts.isEmpty()) {
claims.put(KEY_CONTACT, editContacts);
}
conn.sendSignedRequest(getLocation(), claims, getLogin());
setJSON(conn.readJsonResponse());
}
}
}
}