diff --git a/.gitignore b/.gitignore index 4e868ec..0c7dbc7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ build/ .gradle/ src/main/resources/config.json -src/main/resources/clientmails/* *.der *.pem node_modules @@ -12,7 +11,9 @@ webapp/build .settings/ bin/ artifacts/ +.DS_Store -!src/main/webapp/WEB-INF src/main/webapp/* +!src/main/webapp/WEB-INF/ + .idea/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d53ecaf --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "java.compile.nullAnalysis.mode": "automatic", + "java.configuration.updateBuildConfiguration": "automatic" +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..acbcce3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ + +FROM yarnpkg/node-yarn:latest as webappbuild + +ARG LANGUAGE=en + +# Build the webapp +COPY ./webapp/ /webapp/ +WORKDIR /webapp +RUN yarn install && ./build.sh ${LANGUAGE} + +FROM gradle:7.6-jdk11 as javabuild + +# Build the java app +COPY ./ /app/ +WORKDIR /app +RUN gradle build + +FROM tomee:9.0-jre11 + +# Copy the webapp to the webapps directory +COPY --from=webappbuild /webapp/build/ /usr/local/tomee/webapps/ROOT/ + +# Copy the war file to the webapps directory +COPY --from=javabuild /app/build/libs/irma_email_issuer-1.1.0.war /usr/local/tomee/webapps/ + +EXPOSE 8080 \ No newline at end of file diff --git a/README.md b/README.md index 158f650..1ecaddd 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,61 @@ -# Email server +# irma_email_issuer -Add an email address for use in your [Yivi app](https://github.com/privacybydesign/irma_mobile). +Add an e-mail address for use in your [Yivi app](https://github.com/privacybydesign/irmamobile). +## Running (development) +The easiest way to run the irma_email_issuer for development purposes is via Docker. -## Setting up the server +### Configuration +Various configuration files, keys and settings need to be in place to be able to build and run the apps. + +1. To generate the required keys for the issuer, run: +```bash +$ utils/keygen.sh ./src/main/resources/sk ./src/main/resources/pk +``` + +2. Create the Java app configuration: +Copy the file `src/main/resources/config.sample.json` to `src/main/resources/config.json`. + +### Run +Use docker-compose up combined with your localhost IP address as environment variable to spin up the containers: +```bash +$ IP=192.168.1.105 docker-compose up +``` +Note: do not use `127.0.0.1` or `0.0.0.0` as IP addresses as this will result in the app not being able to find the issuer. + +By default, docker-compose caches docker images, so on a second run the previous built images will be used. A fresh build can be enforced using the --build flag. +```bash +$ IP=192.168.1.105 docker-compose up --build +``` + +## Manual +The Java api and JavaScript frontend can be built and run manually using the following commands: 1. Generate JWT keys for the issuer ```bash -./utils/keygen.sh ./src/main/resources/sk ./src/main/resources/pk +$ utils/keygen.sh ./src/main/resources/sk ./src/main/resources/pk +``` + +2. Copy the file `src/main/resources/config.sample.json` to `src/main/resources/config.json` and modify it. + +3. Build the webapp: +```bash +$ cd webapp && yarn install && yarn build en && cd ../ +``` +If you want to build another language, for example Dutch, change `build en` to `build nl`. + +4. Copy the file `webapp/config.example.js` to `webapp/build/assets/config.js` and modify it + +5. Run the following command in the root directory of this project: +```bash +$ gradle appRun +``` + +To open the webapp navigate to http://localhost:8080. The API is accessible via http://localhost:8080/irma_email_issuer/api + +## Test +You can run the tests, defined in `src/test/java/foundation/privacybydesign/email`, using the following command: +```bash +$ gradle test ``` -2. Copy the file `src/main/resources/config.sample.json` to - `build/resources/main/config.json` and modify it. -3. Run `gradle appRun` in the root directory of this project. -4. Navigate to `http://localhost:8080/irma_email_issuer/api/hello` diff --git a/build.gradle b/build.gradle index 48b6048..394f267 100644 --- a/build.gradle +++ b/build.gradle @@ -2,9 +2,9 @@ group 'foundation.privacybydesign.email' version '1.1.0' apply plugin: 'war' -apply plugin: 'org.akhikhl.gretty' +apply plugin: 'org.gretty' -sourceCompatibility = 1.7 +sourceCompatibility = 11 buildscript { repositories { @@ -13,12 +13,11 @@ buildscript { } } dependencies { - classpath "gradle.plugin.org.akhikhl.gretty:gretty:1.4.2" + classpath "org.gretty:gretty:4.0.3" } } repositories { - mavenLocal() maven { url "https://credentials.github.io/repos/maven2/" } @@ -26,19 +25,26 @@ repositories { } dependencies { - compile 'org.glassfish.jersey.core:jersey-server:2.25' - compile 'org.glassfish.jersey.containers:jersey-container-servlet:2.25' - compile 'ch.qos.logback:logback-classic:1.1.7' - compile 'com.sun.mail:javax.mail:1.5.6' - - compile 'org.irmacard.api:irma_api_common:1.2.2' - compile 'foundation.privacybydesign.common:irma_server_common:0.3.2' - - testCompile group: 'junit', name: 'junit', version: '4.12' + implementation 'org.glassfish.jersey.core:jersey-server:3.0.0' + implementation 'org.glassfish.jersey.containers:jersey-container-servlet:3.0.0' + implementation 'org.glassfish.jersey.inject:jersey-hk2:3.0.0' + implementation 'ch.qos.logback:logback-classic:1.1.7' + implementation 'com.sun.mail:jakarta.mail:2.0.1' + implementation 'jakarta.ws.rs:jakarta.ws.rs-api:3.1.0' + + implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation 'com.google.code.gson:gson:2.8.9' + implementation 'org.apache.commons:commons-lang3:3.7' + implementation 'org.bouncycastle:bcpkix-jdk15on:1.70' + implementation 'org.bouncycastle:bcprov-jdk15on:1.67' + implementation 'jakarta.xml.bind:jakarta.xml.bind-api:3.0.1' + + implementation 'org.irmacard.api:irma_api_common:2.0.0' + + testImplementation group: 'junit', name: 'junit', version: '4.13.1' } gretty { contextConfigFile = file('src/main/resources/jetty-env.xml') - scanInterval = 10 - inplaceMode = "hard" + extraResourceBase 'webapp/build' } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f0cc7db --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,61 @@ +version: "3.8" +name: irma_email_issuer + +services: + # Irma issuer service + irmaserver: + image: ghcr.io/privacybydesign/irma:v0.13.2 + working_dir: /irmago + ports: + - 8088:8088 + expose: + - 8088 + networks: + - irma-net + entrypoint: + - "irma" + - "server" + - "--no-auth=false" + - "--requestors={\"irma_email_issuer\":{\"auth_method\":\"publickey\",\"key_file\": \"/config/pk.pem\"} }" + - "--port=8088" + - "--jwt-privkey-file=/config/sk.pem" + - "--url=http://${IP}:8088" + volumes: + - ./src/main/resources/:/config/ + + # Mailhog service + mailhog: + image: mailhog/mailhog + networks: + # We use a localhost alias such that the test configuration also works for users who run it without Docker. + irma-net: + aliases: + - mailhog.localhost + ports: + - 1025:1025 + - 8025:8025 # Port of the web interface + + # Service that runs the SMS issuer webapp and api + irma_email_issuer: + platform: linux/x86_64 + build: + context: . + dockerfile: Dockerfile + volumes: + # Make keys and config files available for Java app + - ./src/main/resources/:/config/ + # Make config.js available for webapp + - ./webapp/config.example.js:/usr/local/tomee/webapps/ROOT/assets/config.js:ro" + ports: + - 8080:8080 + expose: + - 8080 + networks: + - irma-net + +# Docker Desktop for MacOS does not support exposing ports when using host networking. Therefore, +# we have to use bridge networking and expose the ports manually. +# https://github.com/docker/for-mac/issues/1031 +networks: + irma-net: + driver: bridge \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 53b9e38..4e86b92 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.9.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.2-bin.zip +networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/main/java/foundation/privacybydesign/email/CleanupBackgroundJob.java b/src/main/java/foundation/privacybydesign/email/CleanupBackgroundJob.java new file mode 100644 index 0000000..d9db4eb --- /dev/null +++ b/src/main/java/foundation/privacybydesign/email/CleanupBackgroundJob.java @@ -0,0 +1,43 @@ +package foundation.privacybydesign.email; + +import foundation.privacybydesign.email.ratelimit.MemoryRateLimit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.servlet.ServletContextEvent; +import jakarta.servlet.ServletContextListener; +import jakarta.servlet.annotation.WebListener; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Clean up in-memory data structures once in a while. + */ +@WebListener +public class CleanupBackgroundJob implements ServletContextListener { + private static Logger logger = LoggerFactory.getLogger(CleanupBackgroundJob.class); + private ScheduledExecutorService scheduler; + + @Override + public void contextInitialized(ServletContextEvent event) { + logger.info("Setting up background cleanup task"); + scheduler = Executors.newSingleThreadScheduledExecutor(); + + scheduler.scheduleAtFixedRate(new Runnable() { + @Override public void run() { + try { + MemoryRateLimit.getInstance().periodicCleanup(); + } catch (Exception e) { + logger.error("Failed to run periodic cleanup:"); + e.printStackTrace(); + } + } + }, 5, 5, TimeUnit.MINUTES); + } + + @Override + public void contextDestroyed(ServletContextEvent event) { + scheduler.shutdownNow(); + } +} diff --git a/src/main/java/foundation/privacybydesign/email/Client.java b/src/main/java/foundation/privacybydesign/email/Client.java index 4050174..4f805b2 100644 --- a/src/main/java/foundation/privacybydesign/email/Client.java +++ b/src/main/java/foundation/privacybydesign/email/Client.java @@ -2,8 +2,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; - -import java.io.IOException; import java.util.HashMap; public class Client { diff --git a/src/main/java/foundation/privacybydesign/email/EmailConfiguration.java b/src/main/java/foundation/privacybydesign/email/EmailConfiguration.java index 6384327..4b17703 100644 --- a/src/main/java/foundation/privacybydesign/email/EmailConfiguration.java +++ b/src/main/java/foundation/privacybydesign/email/EmailConfiguration.java @@ -1,6 +1,6 @@ package foundation.privacybydesign.email; -import foundation.privacybydesign.common.BaseConfiguration; +import foundation.privacybydesign.email.common.BaseConfiguration; import io.jsonwebtoken.SignatureAlgorithm; import org.irmacard.api.common.util.GsonUtil; import org.slf4j.Logger; @@ -12,7 +12,7 @@ import java.util.HashMap; import java.util.Map; -public class EmailConfiguration extends BaseConfiguration { +public class EmailConfiguration extends BaseConfiguration { private static Logger logger = LoggerFactory.getLogger(Client.class); static EmailConfiguration instance; @@ -109,6 +109,10 @@ public PrivateKey getPrivateKey() throws KeyManagementException { public SignatureAlgorithm getJwtAlgorithm() { return SignatureAlgorithm.RS256; } public Client getClient(String token) { + if (clients == null || !clients.containsKey(token)) { + return null; + } + return clients.get(token); } diff --git a/src/main/java/foundation/privacybydesign/email/EmailProvider.java b/src/main/java/foundation/privacybydesign/email/EmailProvider.java deleted file mode 100644 index 447ddfd..0000000 --- a/src/main/java/foundation/privacybydesign/email/EmailProvider.java +++ /dev/null @@ -1,26 +0,0 @@ -package foundation.privacybydesign.email; - -import foundation.privacybydesign.common.email.EmailTokens; - -import javax.mail.internet.AddressException; - -/** - * Test console application. Quite useless now, but was useful while writing - * this application. - */ -public class EmailProvider { - public static void main(String[] args) throws AddressException { - EmailConfiguration conf = EmailConfiguration.getInstance(); - - // Test email with signature - EmailTokens signer = new EmailTokens(conf.getSecretKey(), conf.getEmailTokenValidity()); - String token = signer.createToken(conf.getMailFrom()); - - String mailBody = String.format(conf.getVerifyEmailBody("en"), - "#verify-email/" + token); - - System.out.println("Sending test email..."); - EmailSender.send(conf.getMailFrom(), "mail verification", mailBody); - System.out.println("Done."); - } -} \ No newline at end of file diff --git a/src/main/java/foundation/privacybydesign/email/EmailRestApi.java b/src/main/java/foundation/privacybydesign/email/EmailRestApi.java index 2876bc1..3bacbe8 100644 --- a/src/main/java/foundation/privacybydesign/email/EmailRestApi.java +++ b/src/main/java/foundation/privacybydesign/email/EmailRestApi.java @@ -1,7 +1,5 @@ package foundation.privacybydesign.email; -import foundation.privacybydesign.common.email.EmailTokens; -import foundation.privacybydesign.common.filters.RateLimit; import org.irmacard.api.common.ApiClient; import org.irmacard.api.common.CredentialRequest; import org.irmacard.api.common.issuing.IdentityProviderRequest; @@ -10,10 +8,12 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import javax.mail.internet.AddressException; -import javax.ws.rs.*; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; +import foundation.privacybydesign.email.ratelimit.MemoryRateLimit; +import foundation.privacybydesign.email.ratelimit.RateLimit; +import jakarta.mail.internet.AddressException; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; @@ -28,11 +28,13 @@ @Path("") public class EmailRestApi { private static Logger logger = LoggerFactory.getLogger(EmailRestApi.class); + private static RateLimit rateLimiter = MemoryRateLimit.getInstance(); private static final String ERR_ADDRESS_MALFORMED = "error:email-address-malformed"; private static final String ERR_INVALID_TOKEN = "error:invalid-token"; private static final String ERR_INVALID_LANG = "error:invalid-language"; private static final String OK_RESPONSE = "OK"; // value doesn't really matter + private static final String ERR_RATE_LIMITED = "error:ratelimit"; private EmailTokens signer; @@ -52,17 +54,28 @@ public Response sendEmail(@FormParam("email") String email, if (client == null) return Response.status(Response.Status.UNAUTHORIZED).build(); - if (lang == null || lang.length() == 0) - lang = EmailConfiguration.getInstance().getDefaultLanguage(); - + lang = parseLanguage(lang); + // We only accept lowercase email addresses. if (!email.equals(email.toLowerCase())) { logger.error("Address contains uppercase characters"); return Response.status(Response.Status.BAD_REQUEST).entity(ERR_ADDRESS_MALFORMED).build(); } - String token = signer.createToken(email); try { + + long retryAfter = rateLimiter.rateLimited(email); + if (retryAfter > 0) { + // 429 Too Many Requests + // https://tools.ietf.org/html/rfc6585#section-4 + return Response.status(429) + .entity(ERR_RATE_LIMITED) + .header("Retry-After", (int) Math.ceil(retryAfter / 1000.0)) + .build(); + } + + String token = signer.createToken(email); + String url = conf.getServerURL(lang) + "#verify-email/" + token + "/" + URLEncoder.encode(client.getReturnURL(), StandardCharsets.UTF_8.toString()); EmailSender.send( @@ -79,6 +92,9 @@ public Response sendEmail(@FormParam("email") String email, } catch (UnsupportedEncodingException e) { logger.error("Invalid return URL: {}: {}", client.getReturnURL(), e.getMessage()); return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build(); + } catch (Exception e) { + logger.error("Sending mail failed:\n{}", e.getMessage()); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build(); } return Response.status(Response.Status.OK).entity(OK_RESPONSE).build(); } @@ -93,7 +109,6 @@ public Response sendEmail(@FormParam("email") String email, @POST @Path("/send-email-token") @Produces(MediaType.TEXT_PLAIN) - @RateLimit public Response sendEmailToken(@FormParam("email") String emailAddress, @FormParam("language") String language) { EmailConfiguration conf = EmailConfiguration.getInstance(); @@ -104,11 +119,21 @@ public Response sendEmailToken(@FormParam("email") String emailAddress, return Response.status(Response.Status.BAD_REQUEST).entity(ERR_ADDRESS_MALFORMED).build(); } + long retryAfter = rateLimiter.rateLimited(emailAddress); + if (retryAfter > 0) { + // 429 Too Many Requests + // https://tools.ietf.org/html/rfc6585#section-4 + return Response.status(429) + .entity(ERR_RATE_LIMITED) + .header("Retry-After", (int) Math.ceil(retryAfter / 1000.0)) + .build(); + } + // Test email with signature String token = signer.createToken(emailAddress); - if (language == null || language.length() == 0) - language = EmailConfiguration.getInstance().getDefaultLanguage(); + language = parseLanguage(language); + String mailBodyTemplate = conf.getVerifyEmailBody(language); if (mailBodyTemplate == null) { return Response.status(Response.Status.BAD_REQUEST).entity @@ -128,6 +153,9 @@ public Response sendEmailToken(@FormParam("email") String emailAddress, logger.error("Invalid address: {}", e.getMessage()); return Response.status(Response.Status.BAD_REQUEST).entity (ERR_ADDRESS_MALFORMED).build(); + } catch (Exception e) { + logger.error("Sending mail failed:\n{}", e.getMessage()); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build(); } return Response.status(Response.Status.OK).entity (OK_RESPONSE).build(); @@ -189,4 +217,21 @@ public Response verifyEmailToken(@FormParam("token") String token) throws KeyMan return Response.status(Response.Status.OK) .entity(jwt).build(); } + + /** + * Return a sanitized language code or the default language based on the given input + * + * @param language the language code to sanitize + * @return String with language code + */ + private String parseLanguage(String language){ + if (language == null || language.length() == 0){ + language = EmailConfiguration.getInstance().getDefaultLanguage(); + } + else { + // Only allow letters in the language code to prevent path and other injection attacts + language = language.replaceAll("[^a-zA-Z]", ""); + } + return language; + } } diff --git a/src/main/java/foundation/privacybydesign/email/EmailSender.java b/src/main/java/foundation/privacybydesign/email/EmailSender.java index 27449f2..42e9e73 100644 --- a/src/main/java/foundation/privacybydesign/email/EmailSender.java +++ b/src/main/java/foundation/privacybydesign/email/EmailSender.java @@ -1,12 +1,8 @@ package foundation.privacybydesign.email; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.util.Properties; -import javax.mail.*; -import javax.mail.internet.*; - +import jakarta.mail.*; +import jakarta.mail.internet.*; /** * Simple class to send emails. Mail host/port/auth is configured in @@ -15,7 +11,6 @@ * TODO: join this with org.irmacard.keyshare.web.email.EmailSender */ public class EmailSender { - private static Logger logger = LoggerFactory.getLogger(EmailSender.class); /** * Send an email using a configured SMTP server. @@ -25,11 +20,11 @@ public class EmailSender { * @param body Email text body * @throws AddressException */ - public static void send(String toAddresses, String subject, String body) throws AddressException { + public static void send(String toAddresses, String subject, String body) throws AddressException, MessagingException { send(toAddresses, subject, body, null, false); } - public static void send(String toAddresses, String subject, String body, String replyto, boolean html, Object... o) throws AddressException{ + public static void send(String toAddresses, String subject, String body, String replyto, boolean html, Object... o) throws AddressException, MessagingException{ InternetAddress[] addresses = InternetAddress.parse(toAddresses); if (addresses.length != 1) throw new AddressException("Invalid amount of (comma-separated) addresses given (should be 1)"); @@ -57,29 +52,22 @@ protected PasswordAuthentication getPasswordAuthentication() { session = Session.getInstance(props); } - try { - Message message = new MimeMessage(session); - message.setFrom(new InternetAddress(EmailConfiguration.getInstance().getMailFrom())); - message.setRecipients(Message.RecipientType.TO, addresses); - message.setSubject(subject); - if (replyto != null && replyto.length() > 0) - message.setReplyTo(new Address[]{new InternetAddress(replyto)}); + Message message = new MimeMessage(session); + message.setFrom(new InternetAddress(EmailConfiguration.getInstance().getMailFrom())); + message.setRecipients(Message.RecipientType.TO, addresses); + message.setSubject(subject); + if (replyto != null && replyto.length() > 0) + message.setReplyTo(new Address[]{new InternetAddress(replyto)}); - if (o != null && o.length > 0) - body = String.format(body, o); + if (o != null && o.length > 0) + body = String.format(body, o); - if (html) { - MimeBodyPart messageBodyPart = new MimeBodyPart(); - messageBodyPart.setContent(body, "text/html"); - Multipart multipart = new MimeMultipart(); - multipart.addBodyPart(messageBodyPart); - message.setContent(multipart); - } else { - message.setText(body); - } - Transport.send(message); - } catch (MessagingException e) { - logger.error("Sending mail failed:\n{}", e.getMessage()); + if (html) { + message.setContent(body, "text/html"); + } else { + message.setText(body); } + + Transport.send(message); } } diff --git a/src/main/java/foundation/privacybydesign/email/EmailService.java b/src/main/java/foundation/privacybydesign/email/EmailService.java index af9ccf3..95c628a 100644 --- a/src/main/java/foundation/privacybydesign/email/EmailService.java +++ b/src/main/java/foundation/privacybydesign/email/EmailService.java @@ -6,7 +6,7 @@ import org.glassfish.jersey.server.ResourceConfig; -import javax.ws.rs.*; +import jakarta.ws.rs.ApplicationPath; @ApplicationPath("/") public class EmailService extends ResourceConfig { diff --git a/src/main/java/foundation/privacybydesign/email/EmailTokens.java b/src/main/java/foundation/privacybydesign/email/EmailTokens.java new file mode 100644 index 0000000..5a19516 --- /dev/null +++ b/src/main/java/foundation/privacybydesign/email/EmailTokens.java @@ -0,0 +1,144 @@ +package foundation.privacybydesign.email; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.crypto.Mac; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import javax.crypto.spec.SecretKeySpec; +import jakarta.xml.bind.DatatypeConverter; +/** + * Create and verify tokens that can be used in email verification messages. + * + * Based on this idea: + * https://aykevl.nl/2015/01/south-stateless-authenticated-sessions-http-golang + * In short, a token has the format payload:timestamp:signature + * where the payload can be any email address (email addresses may not + * contain colons according to the HTML5 field so it + * should be fine). The timestamp is the creation timestamp. The signature is + * over the payload:timestamp part. This way it is relatively easy to expire + * tokens (set a different validity) or revoke (change the key). + * + * The tokens aren't too long, just 55 bytes + the payload. And by using the + * URL version of base64 the potentially problematic '/' and '+' characters + * are avoided so tokens can be easily put in a URL without escaping. + */ +public class EmailTokens { + private static Logger logger = LoggerFactory.getLogger(EmailTokens.class); + private static String SIGNING_ALGORITHM = "HmacSHA256"; + + private Mac mac; + private long tokenValidity; + + public EmailTokens(String signingKey, long tokenValidity) { + this.tokenValidity = tokenValidity; + + // HMAC calculated using this sample: + // https://gist.github.com/ishikawa/88599/3195bdeecabeb38aa62872ab61877aefa6aef89e + SecretKeySpec key = new SecretKeySpec(signingKey.getBytes(), SIGNING_ALGORITHM); + try { + mac = Mac.getInstance(SIGNING_ALGORITHM); + mac.init(key); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + // This should not normally happen + throw new RuntimeException("Unknown key?"); + } + } + + /** + * Create a token: a value with a creation time and signature. + * Can be used to create e.g. authentication tokens. + */ + public String createToken(String value) { + // We could also use a bigger radix for the timestamp to make it + // smaller (for example, a radix of 36 uses only 6 bytes instead of 10). + String timestamp = Long.toString(System.currentTimeMillis() / 1000); + + return value + ":" + timestamp + ":" + signToken(value + ":" + timestamp); + } + + /** + * Sign a token (value+timestamp) with a HMAC. Output the digest of the + * HMAC as base64 url-encoded string. + */ + private String signToken(String token) { + // See https://aykevl.nl/2015/01/south-stateless-authenticated-sessions-http-golang + // for background on the system. + byte[] digestBytes = mac.doFinal(token.getBytes()); + String digest = DatatypeConverter.printBase64Binary(digestBytes); + digest = digest.substring(0, 43); // strip tailing '=' + digest = digest // convert to base64 URL encoding + .replace('+', '-') + .replace('/', '_'); + + return digest; + } + + /** + * Verify the token. If it is verified and not expired, return the value. + * Otherwise, return null. + */ + public String verifyToken(String token) { + // Parse token + String[] parts = token.split(":"); + if (parts.length != 3) { + // invalid syntax + logger.error("Token does not have 3 parts"); + return null; + } + String value = parts[0]; + String timestamp = parts[1]; + String digestText = parts[2]; + + long creationTime; + try { + creationTime = Long.parseLong(timestamp); + } catch (NumberFormatException e) { + // Invalid syntax + logger.error("Token has non-integer creation time"); + return null; + } + + // Verify expired tokens + try { + long currentTime = System.currentTimeMillis() / 1000; + if (currentTime > Math.addExact(creationTime, tokenValidity)) { + // Token is no longer valid. + logger.error("Token has expired"); + return null; + } + } catch (ArithmeticException e) { + // Adding times resulted in a long overflow. + logger.error("Token has invalid creation time"); + return null; + } + + // Verify signature + String calculatedDigestText = signToken(value + ":" + timestamp); + if (isEqualsConstantTime(digestText.toCharArray(), + calculatedDigestText.toCharArray())) { + return value; + } else { + logger.error("Token has invalid HMAC"); + return null; + } + } + + /** + * Compare two byte arrays in constant time. + */ + public static boolean isEqualsConstantTime(char[] a, char[] b) { + if (a.length != b.length) { + return false; + } + + byte result = 0; + for (int i = 0; i < a.length; i++) { + result |= a[i] ^ b[i]; + } + return result == 0; + } + +} \ No newline at end of file diff --git a/src/main/java/foundation/privacybydesign/email/common/BaseConfiguration.java b/src/main/java/foundation/privacybydesign/email/common/BaseConfiguration.java new file mode 100644 index 0000000..c91c1e6 --- /dev/null +++ b/src/main/java/foundation/privacybydesign/email/common/BaseConfiguration.java @@ -0,0 +1,317 @@ +package foundation.privacybydesign.email.common; + +import com.google.gson.JsonSyntaxException; +import org.apache.commons.lang3.SystemUtils; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemReader; +import org.irmacard.api.common.util.GsonUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.*; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.security.*; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.ArrayList; +import java.util.HashMap; + +public class BaseConfiguration { + // Override these in a static {} block + public static Class> clazz; + public static Logger logger = LoggerFactory.getLogger(BaseConfiguration.class); + public static String filename = "config.json"; + public static String environmentVarPrefix = "IRMA_CONF_"; + public static String confDirEnvironmentVarName = "IRMA_CONF"; + public static String confDirName; + public static boolean printOnLoad = false; + public static boolean testing = false; + + // Return this from a static getInstance() + public static BaseConfiguration instance; + private static URI confPath; + + + public static void load() { + try { + String json = new String(getResource(filename)); + instance = GsonUtil.getGson().fromJson(json, clazz); + logger.info("Using configuration directory: " + BaseConfiguration.getConfigurationDirectory().toString()); + } catch (IOException|JsonSyntaxException e) { + logger.info("WARNING: could not load configuration file. Using default values or environment vars"); + instance = GsonUtil.getGson().fromJson("{}", clazz); + } + instance.loadEnvVars(); + + if (printOnLoad) { + logger.info("Configuration:"); + logger.info(instance.toString()); + } + } + + public static BaseConfiguration getInstance() { + if (instance == null) + load(); + return instance; + } + + public static FileInputStream getResourceStream(String filename) throws IOException { + return new FileInputStream(new File(getConfigurationDirectory().resolve(filename))); + } + + public static byte[] getResource(String filename) throws IOException { + return convertStreamToByteArray(getResourceStream(filename), 2048); + } + + public static byte[] convertStreamToByteArray(InputStream stream, int size) throws IOException { + byte[] buffer = new byte[size]; + ByteArrayOutputStream os = new ByteArrayOutputStream(); + + int line; + while ((line = stream.read(buffer)) != -1) { + os.write(buffer, 0, line); + } + stream.close(); + + os.flush(); + os.close(); + return os.toByteArray(); + } + + public static PublicKey getPublicKey(String filename) throws KeyManagementException { + try { + byte[] bytes = filename.endsWith(".pem") ? readPemFile(filename) : getResource(filename); + return decodePublicKey(bytes); + } catch (IOException e) { + throw new KeyManagementException(e); + } + } + + private static byte[] readPemFile(String filename) throws FileNotFoundException, IOException { + PemReader pemReader = new PemReader(new InputStreamReader(getResourceStream(filename))); + PemObject pemObject = pemReader.readPemObject(); + pemReader.close(); + return pemObject.getContent(); + } + + public static PublicKey decodePublicKey(byte[] bytes) throws KeyManagementException { + try { + if (bytes == null || bytes.length == 0) + throw new KeyManagementException("Could not read public key"); + X509EncodedKeySpec spec = new X509EncodedKeySpec(bytes); + return KeyFactory.getInstance("RSA").generatePublic(spec); + } catch (NoSuchAlgorithmException|InvalidKeySpecException e) { + throw new KeyManagementException(e); + } + } + + public static PrivateKey getPrivateKey(String filename) throws KeyManagementException { + try { + return decodePrivateKey(getResource(filename)); + } catch (IOException e) { + throw new KeyManagementException(e); + } + } + + public static PrivateKey decodePrivateKey(byte[] rawKey) throws KeyManagementException { + try { + if (rawKey == null || rawKey.length == 0) + throw new KeyManagementException("Could not read private key"); + + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(rawKey); + return KeyFactory.getInstance("RSA").generatePrivate(spec); + } catch (NoSuchAlgorithmException |InvalidKeySpecException e) { + throw new KeyManagementException(e); + } + } + + /** + * Override configuration with environment variables, if set + * Uses reflection to set variables, because otherwise it would be impossible to set all variable at once in a loop + */ + public void loadEnvVars() { + for (Field f : BaseConfiguration.clazz.getDeclaredFields()) { + if ( Modifier.isTransient(f.getModifiers()) || Modifier.isStatic(f.getModifiers())) { + // Skip transient and static fields + continue; + } + + Object envValue = getEnv(environmentVarPrefix + f.getName(), f.getType()); + if (envValue != null) { + try { + f.set(this, envValue); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } + } + } + } + + /** + * Obtain an environment variable and parse it to the right type + * @param confEntry name of environment variable + * @param cls class to be parsed into (either Integer, Boolean, String, HashMap) + * @param type of the variable + * @return a parsed variable in the right type (T) or null if environment variable isn't set + */ + public static T getEnv(String confEntry, Class cls) { + confEntry = confEntry.toUpperCase(); + String env = System.getenv(confEntry); + if (env== null || env.length() == 0) { + return null; + } + + T overrideValue; + + try { + if (cls == int.class) { + try { + Integer parsed = Integer.parseInt(env); + overrideValue = cls.cast(parsed); + } catch (NumberFormatException e) { + logger.warn("Could not parse config entry as int: " + confEntry + " with value: " + env); + return null; + } + } else if (cls == boolean.class) { + Boolean parsed = Boolean.parseBoolean(env); + overrideValue = cls.cast(parsed); + } else if (cls == String.class) { + overrideValue = cls.cast(env); + } else if (cls == HashMap.class){ // Try to parse as hashmap for authorized_??? entries + try { + overrideValue = cls.cast(GsonUtil.getGson().fromJson(env, cls)); + } catch (JsonSyntaxException e) { + logger.warn("Could not parse config entry as json: " + confEntry + " with value: " + env); + return null; + } + } else { + throw new IllegalArgumentException("Invalid class specified, must be one of: Integer, Boolean, String, HashMap"); + } + } catch (ClassCastException e){ + logger.warn("Could not cast config entry: " + confEntry + " with value: " + env); + return null; + } + + logger.info("Overriding config entry " + confEntry + " with value: " + env); + return overrideValue; + } + + /** + * If a path was set in the $confDirEnvironmentVarName environment variable, return it + */ + public static URI getEnvironmentVariableConfDir() throws URISyntaxException { + String envDir = System.getenv(confDirEnvironmentVarName); + if (envDir == null || envDir.length() == 0) + return null; + return pathToURI(envDir, true); + } + + /** + * Returns true if the specified path is a valid configuration directory. A directory + * is considered a valid configuration directory if it contains a file called $filename. + */ + public static boolean isConfDirectory(URI candidate) { + return candidate != null && new File(candidate.resolve(filename)).isFile(); + } + + /** + * Get the path to the Java resources directory, i.e., src/main/resources or src/test/resources; + * note that it must contain the file called $filename or "config.test.json" for this to work + */ + public static URI GetJavaResourcesDirectory() throws URISyntaxException { + // The only way to actually get the resource folder, as opposed to the classes folder, + // seems to be to ask for an existing file or directory within the resources. That is, + // BaseConfiguration.class.getClassLoader().getResource("/") or variants thereof + // give an incorrect path. + String testfile = BaseConfiguration.testing ? "config.test.json" : filename; + URL url = BaseConfiguration.class.getClassLoader().getResource(testfile); + if (url != null) // Construct an URI of the parent path + return pathToURI(new File(url.getPath()).getParent(), true); + else + return null; + } + + public static URI pathToURI(String path, boolean trailingSlash) throws URISyntaxException { + if (trailingSlash && !path.endsWith("/")) + path = path + "/"; + if (SystemUtils.IS_OS_WINDOWS) + path = path.replace("\\", "/"); + // path might contain file: or file:/ or file:// or file:/// or none of them + // Just throw them all away so we can fix it properly + path = path.replaceFirst("^file:/*", ""); + if (SystemUtils.IS_OS_WINDOWS) // Sometimes we get file:///C/blah, that doesn't work + path = path.replaceFirst("^(\\w)/", "$1:/"); + return new URI("file:///" + path); + } + + /** + * Get the configuration directory. + * @throws IllegalStateException If no suitable configuration directory was found + * @throws IllegalArgumentException If the path from the $confDirEnvironmentVarName environment variable was + * not a valid path + */ + public static URI getConfigurationDirectory() throws IllegalStateException, IllegalArgumentException { + if (confPath != null) + return confPath; + + try { + // If we're running unit tests, only accept src/test/resources + URI resourcesCandidate = GetJavaResourcesDirectory(); + if (BaseConfiguration.testing) { + if (resourcesCandidate != null) { + logger.info("Running tests: taking src/test/resources as configuration directory"); + confPath = resourcesCandidate; + return confPath; + } + else { + throw new IllegalStateException("No configuration found in in src/test/resources. " + + "(Have you run `git submodule init && git submodule update`?)"); + } + } + + // If a path was given in the $confDirEnvironmentVarName environment variable, prefer it + URI envCandidate = getEnvironmentVariableConfDir(); + if (envCandidate != null) { + if (isConfDirectory(envCandidate)) { + logger.info("Taking configuration directory specified by environment variable " + confDirEnvironmentVarName); + confPath = envCandidate; + return confPath; + } else { + // If the user specified an incorrect path (s)he will want to know, so bail out here + throw new IllegalArgumentException("Specified path in " + confDirEnvironmentVarName + + " is not a valid configuration directory"); + } + } + + // See if a number of other fixed candidates are suitable + ArrayList candidates = new ArrayList<>(4); + candidates.add(resourcesCandidate); + if (confDirName != null) { + candidates.add(pathToURI("/etc/" + confDirName, true)); + candidates.add(pathToURI("C:/" + confDirName, true)); + candidates.add(pathToURI(System.getProperty("user.home")+"/"+confDirName, true)); + } + + for (URI candidate : candidates) { + if (isConfDirectory(candidate)) { + confPath = candidate; + return confPath; + } + } + + throw new IllegalStateException("No valid configuration directory found"); + } catch (URISyntaxException e) { + throw new IllegalArgumentException(e); + } + } + + @Override + public String toString() { + return GsonUtil.getGson().toJson(this); + } +} \ No newline at end of file diff --git a/src/main/java/foundation/privacybydesign/email/ratelimit/MemoryRateLimit.java b/src/main/java/foundation/privacybydesign/email/ratelimit/MemoryRateLimit.java new file mode 100644 index 0000000..cd463ec --- /dev/null +++ b/src/main/java/foundation/privacybydesign/email/ratelimit/MemoryRateLimit.java @@ -0,0 +1,122 @@ +package foundation.privacybydesign.email.ratelimit; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Store rate limits in memory. Useful for debugging and rate limits that + * aren't very long. + * + * How it works: + * How much budget a user has, is expressed in a timestamp. The timestamp is + * initially some period in the past, but with every usage (countEmail) + * this timestamp is incremented. For e-mail addresses this + * amount is exponential. + * + * An algorithm with a similar goal is the Token Bucket algorithm. This + * algorithm probably works well, but seemed harder to implement. + * https://en.wikipedia.org/wiki/Token_bucket + */ +public class MemoryRateLimit extends RateLimit { + private static final long SECOND = 1000; // 1000ms = 1s + private static final long MINUTE = SECOND * 60; + private static final long HOUR = MINUTE * 60; + private static final long DAY = HOUR * 24; + + private static MemoryRateLimit instance; + + private final Map emailLimits; + + public MemoryRateLimit() { + emailLimits = new ConcurrentHashMap<>(); + } + + public static MemoryRateLimit getInstance() { + if (instance == null) { + instance = new MemoryRateLimit(); + } + return instance; + } + + // Is the user over the rate limit per e-mail address? + @Override + protected synchronized long nextTryEmail(String email, long now) { + // Rate limiter durations (sort-of logarithmic): + // 1 10 seconds + // 2 1 minute + // 3 5 minutes + // 4 1 hour + // 5 12 hours + // 6+ 2 per day + // Keep log 2 days for proper limiting. + Limit limit = emailLimits.get(email); + if (limit == null) { + limit = new Limit(now); + emailLimits.put(email, limit); + } + long nextTry; // timestamp when the next request is allowed + switch (limit.tries) { + case 0: // try 1: always succeeds + nextTry = limit.timestamp; + break; + case 1: // try 2: allowed after 10 seconds + nextTry = limit.timestamp + 10 * SECOND; + break; + case 2: // try 3: allowed after 1 minute + nextTry = limit.timestamp + MINUTE; + break; + case 3: // try 4: allowed after 5 minutes + nextTry = limit.timestamp + 5 * MINUTE; + break; + case 4: // try 5: allowed after 1 hour + nextTry = limit.timestamp + HOUR; + break; + case 5: // try 6: allowed after 12 hours + nextTry = limit.timestamp + 12 * HOUR; + break; + default: + throw new IllegalStateException("invalid tries count"); + } + return nextTry; + } + + // Count the usage of this rate limit - adding to the budget for this + // e-mail address. + @Override + protected synchronized void countEmail(String email, long now) { + long nextTry = nextTryEmail(email, now); + Limit limit = emailLimits.get(email); + if (nextTry > now) { + throw new IllegalStateException("counting rate limit while over the limit"); + } + limit.tries = Math.min(limit.tries+1, 6); // add 1, max at 6 + // If the last usage was e.g. ≥2 days ago, we should allow them 2 + // extra tries this day. + long lastTryDaysAgo = (now-limit.timestamp)/DAY; + long bonusTries = limit.tries - lastTryDaysAgo; + if (bonusTries >= 1) { + limit.tries = (int)bonusTries; + } + limit.timestamp = now; + } + + public void periodicCleanup() { + long now = System.currentTimeMillis(); + // Use enhanced for loop, because an iterator makes sure concurrency issues cannot occur. + for (Map.Entry entry : emailLimits.entrySet()) { + if (entry.getValue().timestamp < now - 2*DAY) { + emailLimits.remove(entry.getKey()); + } + } + } +} + +class Limit { + long timestamp; + int tries; + + Limit(long now) { + tries = 0; + timestamp = now; + } +} diff --git a/src/main/java/foundation/privacybydesign/email/ratelimit/RateLimit.java b/src/main/java/foundation/privacybydesign/email/ratelimit/RateLimit.java new file mode 100644 index 0000000..9e31dfa --- /dev/null +++ b/src/main/java/foundation/privacybydesign/email/ratelimit/RateLimit.java @@ -0,0 +1,34 @@ +package foundation.privacybydesign.email.ratelimit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Base class for rate limiting. Subclasses provide storage methods (memory + * for easier debugging and database for production). + */ +public abstract class RateLimit { + private static Logger logger = LoggerFactory.getLogger(RateLimit.class); + + /** Take an e-mail address and rate limit it. + * @param email e-mail address + * @return the number of milliseconds that the client should wait - 0 if + * it shouldn't wait. + */ + public long rateLimited(String email) { + long now = System.currentTimeMillis(); + long retryAfter = nextTryEmail(email, now); + + if (retryAfter > now) { + logger.warn("Denying request: email rate limit email exceeded"); + // Don't count this request if it has been denied. + return retryAfter - now; + } + + countEmail(email, now); + return 0; + } + + protected abstract long nextTryEmail(String email, long now); + protected abstract void countEmail(String email, long now); +} diff --git a/src/main/resources/clientmails/customclient-en.html b/src/main/resources/clientmails/customclient-en.html new file mode 100644 index 0000000..a8ee419 --- /dev/null +++ b/src/main/resources/clientmails/customclient-en.html @@ -0,0 +1,15 @@ + + + + Add e-mail address to Yivi app + + + +
+

Click the link to add the e-mail address to your Yivi app.

+ +
+ + diff --git a/src/main/resources/clientmails/customclient-nl.html b/src/main/resources/clientmails/customclient-nl.html new file mode 100644 index 0000000..07f46b5 --- /dev/null +++ b/src/main/resources/clientmails/customclient-nl.html @@ -0,0 +1,15 @@ + + + + Voeg e-mailadres toe aan Yivi-app + + + +
+

Klik op de link om het e-mailadres toe te voegen aan je Yivi-app.

+ +
+ + diff --git a/src/main/resources/config.sample.json b/src/main/resources/config.sample.json index 7ef0628..a0a61ff 100644 --- a/src/main/resources/config.sample.json +++ b/src/main/resources/config.sample.json @@ -1,25 +1,40 @@ { "verify_email_subject": { - "en": "Add email address to your Yivi app", + "en": "Add e-mail address to your Yivi app", "nl": "Voeg e-mailadres toe aan je Yivi-app" }, "server_url": { - "en": "http://localhost:8080/irma_email_issuer", - "nl": "http://localhost:8080/irma_email_issuer" + "en": "http://localhost:8080", + "nl": "http://localhost:8080" }, - "mail_host": "smtp.science.ru.nl", - "mail_port": 25, + "mail_host": "mailhog.localhost", + "mail_port": 1025, "mail_user": "", "mail_password": "", - "mail_from_address": "Yivi Email Issuer ", - "secret_key": "", + "mail_starttls_required": false, + "mail_from_address": "noreply@yivi.app", + "secret_key": "somesecretkey", "token_validity": 86400, "private_key_path": "sk.der", "server_name": "irma_email_issuer", "human_readable_name": "Privacy by Design Foundation", - "scheme_manager": "pbdf", - "email_issuer": "pbdf", + "scheme_manager": "irma-demo", + "email_issuer": "sidn-pbdf", "email_credential": "email", "email_attribute": "email", - "domain_attribute": "domain" + "domain_attribute": "domain", + "clients" : { + "customclient": { + "return_url": "", + "reply_to_email": "", + "name": "A custom client", + "email_files": { + "nl": "customclient-nl.html", + "en": "customclient-en.html" + }, + "email_subject": { + "en": "Add e-mail address to your Yivi app", + "nl": "Voeg e-mailadres toe aan je Yivi-app" + } + }} } diff --git a/src/main/resources/jetty-env.xml b/src/main/resources/jetty-env.xml index 60bc1c8..4d9ca44 100644 --- a/src/main/resources/jetty-env.xml +++ b/src/main/resources/jetty-env.xml @@ -1,4 +1,5 @@ +