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}