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.Objects.requireNonNull;
017
018import java.net.URI;
019import java.net.URL;
020import java.security.KeyPair;
021import java.time.ZonedDateTime;
022import java.util.EnumMap;
023import java.util.Locale;
024import java.util.Map;
025import java.util.Optional;
026import java.util.ServiceLoader;
027import java.util.concurrent.atomic.AtomicReference;
028import java.util.stream.StreamSupport;
029
030import edu.umd.cs.findbugs.annotations.Nullable;
031import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
032import org.shredzone.acme4j.connector.Connection;
033import org.shredzone.acme4j.connector.NetworkSettings;
034import org.shredzone.acme4j.connector.Resource;
035import org.shredzone.acme4j.exception.AcmeException;
036import org.shredzone.acme4j.exception.AcmeNotSupportedException;
037import org.shredzone.acme4j.provider.AcmeProvider;
038import org.shredzone.acme4j.provider.GenericAcmeProvider;
039import org.shredzone.acme4j.toolbox.AcmeUtils;
040import org.shredzone.acme4j.toolbox.JSON;
041import org.shredzone.acme4j.toolbox.JSON.Value;
042
043/**
044 * A {@link Session} tracks the entire communication with a CA.
045 * <p>
046 * To create a session instance, use its constructor. It requires the URI of the ACME
047 * server to connect to. This can be the location of the CA's directory (via {@code http}
048 * or {@code https} protocol), or a special URI (via {@code acme} protocol). See the
049 * documentation about valid URIs.
050 */
051public class Session {
052
053    private static final GenericAcmeProvider GENERIC_PROVIDER = new GenericAcmeProvider();
054
055    private final AtomicReference<Map<Resource, URL>> resourceMap = new AtomicReference<>();
056    private final AtomicReference<Metadata> metadata = new AtomicReference<>();
057    private final NetworkSettings networkSettings = new NetworkSettings();
058    private final URI serverUri;
059    private final AcmeProvider provider;
060
061    private @Nullable String nonce;
062    private @Nullable Locale locale = Locale.getDefault();
063    private String languageHeader = AcmeUtils.localeToLanguageHeader(Locale.getDefault());
064    protected @Nullable ZonedDateTime directoryLastModified;
065    protected @Nullable ZonedDateTime directoryExpires;
066
067    /**
068     * Creates a new {@link Session}.
069     *
070     * @param serverUri
071     *         URI string of the ACME server to connect to. This is either the location of
072     *         the CA's ACME directory (using {@code http} or {@code https} protocol), or
073     *         a special URI (using the {@code acme} protocol).
074     * @throws IllegalArgumentException
075     *         if no ACME provider was found for the server URI.
076     */
077    public Session(String serverUri) {
078        this(URI.create(serverUri));
079    }
080
081    /**
082     * Creates a new {@link Session}.
083     *
084     * @param serverUri
085     *         {@link URI} of the ACME server to connect to. This is either the location
086     *         of the CA's ACME directory (using {@code http} or {@code https} protocol),
087     *         or a special URI (using the {@code acme} protocol).
088     * @throws IllegalArgumentException
089     *         if no ACME provider was found for the server URI.
090     */
091    public Session(URI serverUri) {
092        this.serverUri = requireNonNull(serverUri, "serverUri");
093
094        if (GENERIC_PROVIDER.accepts(serverUri)) {
095            provider = GENERIC_PROVIDER;
096            return;
097        }
098
099        var providers = ServiceLoader.load(AcmeProvider.class);
100        provider = StreamSupport.stream(providers.spliterator(), false)
101            .filter(p -> p.accepts(serverUri))
102            .reduce((a, b) -> {
103                    throw new IllegalArgumentException("Both ACME providers "
104                        + a.getClass().getSimpleName() + " and "
105                        + b.getClass().getSimpleName() + " accept "
106                        + serverUri + ". Please check your classpath.");
107                })
108            .orElseThrow(() -> new IllegalArgumentException("No ACME provider found for " + serverUri));
109    }
110
111    /**
112     * Creates a new {@link Session} using the given {@link AcmeProvider}.
113     * <p>
114     * This constructor is only to be used for testing purposes.
115     *
116     * @param serverUri
117     *         {@link URI} of the ACME server
118     * @param provider
119     *         {@link AcmeProvider} to be used
120     * @since 2.8
121     */
122    public Session(URI serverUri, AcmeProvider provider) {
123        this.serverUri = requireNonNull(serverUri, "serverUri");
124        this.provider = requireNonNull(provider, "provider");
125
126        if (!provider.accepts(serverUri)) {
127            throw new IllegalArgumentException("Provider does not accept " + serverUri);
128        }
129    }
130
131    /**
132     * Logs into an existing account.
133     *
134     * @param accountLocation
135     *            Location {@link URL} of the account
136     * @param accountKeyPair
137     *            Account {@link KeyPair}
138     * @return {@link Login} to this account
139     */
140    public Login login(URL accountLocation, KeyPair accountKeyPair) {
141        return new Login(accountLocation, accountKeyPair, this);
142    }
143
144    /**
145     * Gets the ACME server {@link URI} of this session.
146     */
147    public URI getServerUri() {
148        return serverUri;
149    }
150
151    /**
152     * Gets the last base64 encoded nonce, or {@code null} if the session is new. This
153     * method is mainly for internal use.
154     */
155    @Nullable
156    public String getNonce() {
157        return nonce;
158    }
159
160    /**
161     * Sets the base64 encoded nonce received by the server. This method is mainly for
162     * internal use.
163     */
164    public void setNonce(@Nullable String nonce) {
165        this.nonce = nonce;
166    }
167
168    /**
169     * Gets the current locale of this session, or {@code null} if no special language is
170     * selected.
171     */
172    @Nullable
173    public Locale getLocale() {
174        return locale;
175    }
176
177    /**
178     * Sets the locale used in this session. The locale is passed to the server as
179     * Accept-Language header. The server <em>may</em> respond with localized messages.
180     * The default is the system's language. If set to {@code null}, any language will be
181     * accepted.
182     */
183    public void setLocale(@Nullable Locale locale) {
184        this.locale = locale;
185        this.languageHeader = AcmeUtils.localeToLanguageHeader(locale);
186    }
187
188    /**
189     * Gets an Accept-Language header value that matches the current locale. This method
190     * is mainly for internal use.
191     *
192     * @since 3.0.0
193     */
194    public String getLanguageHeader() {
195        return languageHeader;
196    }
197
198    /**
199     * Returns the current {@link NetworkSettings}.
200     *
201     * @return {@link NetworkSettings}
202     * @since 2.8
203     */
204    @SuppressFBWarnings("EI_EXPOSE_REP")    // behavior is intended
205    public NetworkSettings networkSettings() {
206        return networkSettings;
207    }
208
209    /**
210     * Returns the {@link AcmeProvider} that is used for this session.
211     *
212     * @return {@link AcmeProvider}
213     */
214    public AcmeProvider provider() {
215        return provider;
216    }
217
218    /**
219     * Returns a new {@link Connection} to the ACME server.
220     *
221     * @return {@link Connection}
222     */
223    public Connection connect() {
224        return provider.connect(getServerUri(), networkSettings);
225    }
226
227    /**
228     * Gets the {@link URL} of the given {@link Resource}. This may involve connecting to
229     * the server and fetching the directory. The result is cached.
230     *
231     * @param resource
232     *            {@link Resource} to get the {@link URL} of
233     * @return {@link URL} of the resource
234     * @throws AcmeException
235     *             if the server does not offer the {@link Resource}
236     */
237    public URL resourceUrl(Resource resource) throws AcmeException {
238        return resourceUrlOptional(resource)
239                .orElseThrow(() -> new AcmeNotSupportedException(resource.path()));
240    }
241
242    /**
243     * Gets the {@link URL} of the given {@link Resource}. This may involve connecting to
244     * the server and fetching the directory. The result is cached.
245     *
246     * @param resource
247     *            {@link Resource} to get the {@link URL} of
248     * @return {@link URL} of the resource, or empty if the resource is not available.
249     * @since 3.0.0
250     */
251    public Optional<URL> resourceUrlOptional(Resource resource) throws AcmeException {
252        readDirectory();
253        return Optional.ofNullable(resourceMap.get()
254                .get(requireNonNull(resource, "resource")));
255    }
256
257    /**
258     * Gets the metadata of the provider's directory. This may involve connecting to the
259     * server and fetching the directory. The result is cached.
260     *
261     * @return {@link Metadata}. May contain no data, but is never {@code null}.
262     */
263    public Metadata getMetadata() throws AcmeException {
264        readDirectory();
265        return metadata.get();
266    }
267
268    /**
269     * Returns the date when the directory has been modified the last time.
270     *
271     * @return The last modification date of the directory, or {@code null} if unknown
272     * (directory has not been read yet or did not provide this information).
273     * @since 2.10
274     */
275    @Nullable
276    public ZonedDateTime getDirectoryLastModified() {
277        return directoryLastModified;
278    }
279
280    /**
281     * Sets the date when the directory has been modified the last time. Should only be
282     * invoked by {@link AcmeProvider} implementations.
283     *
284     * @param directoryLastModified
285     *         The last modification date of the directory, or {@code null} if unknown
286     *         (directory has not been read yet or did not provide this information).
287     * @since 2.10
288     */
289    public void setDirectoryLastModified(@Nullable ZonedDateTime directoryLastModified) {
290        this.directoryLastModified = directoryLastModified;
291    }
292
293    /**
294     * Returns the date when the current directory records will expire. A fresh copy of
295     * the directory will be fetched automatically after that instant.
296     *
297     * @return The expiration date, or {@code null} if the server did not provide this
298     * information.
299     * @since 2.10
300     */
301    @Nullable
302    public ZonedDateTime getDirectoryExpires() {
303        return directoryExpires;
304    }
305
306    /**
307     * Sets the date when the current directory will expire. Should only be invoked by
308     * {@link AcmeProvider} implementations.
309     *
310     * @param directoryExpires
311     *         Expiration date, or {@code null} if the server did not provide this
312     *         information.
313     * @since 2.10
314     */
315    public void setDirectoryExpires(@Nullable ZonedDateTime directoryExpires) {
316        this.directoryExpires = directoryExpires;
317    }
318
319    /**
320     * Returns {@code true} if a copy of the directory is present in a local cache. It is
321     * not evaluated if the cached copy has expired though.
322     *
323     * @return {@code true} if a directory is available.
324     * @since 2.10
325     */
326    public boolean hasDirectory() {
327        return resourceMap.get() != null;
328    }
329
330    /**
331     * Purges the directory cache. Makes sure that a fresh copy of the directory will be
332     * read from the CA on the next time the directory is accessed.
333     *
334     * @since 3.0.0
335     */
336    public void purgeDirectoryCache() {
337        setDirectoryLastModified(null);
338        setDirectoryExpires(null);
339        resourceMap.set(null);
340    }
341
342    /**
343     * Reads the provider's directory, then rebuild the resource map. The resource map
344     * is unchanged if the {@link AcmeProvider} returns that the directory has not been
345     * changed on the remote side.
346     */
347    private void readDirectory() throws AcmeException {
348        var directoryJson = provider().directory(this, getServerUri());
349        if (directoryJson == null) {
350            if (!hasDirectory()) {
351                throw new AcmeException("AcmeProvider did not provide a directory");
352            }
353            return;
354        }
355
356        var meta = directoryJson.get("meta");
357        if (meta.isPresent()) {
358            metadata.set(new Metadata(meta.asObject()));
359        } else {
360            metadata.set(new Metadata(JSON.empty()));
361        }
362
363        var map = new EnumMap<Resource, URL>(Resource.class);
364        for (var res : Resource.values()) {
365            directoryJson.get(res.path())
366                    .map(Value::asURL)
367                    .ifPresent(url -> map.put(res, url));
368        }
369
370        resourceMap.set(map);
371    }
372
373    @Override
374    protected final void finalize() {
375        // CT_CONSTRUCTOR_THROW: Prevents finalizer attack
376    }
377
378}