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