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