diff --git a/authentication-module/src/main/java/net/jami/jams/authmodule/UserAuthenticationModule.java b/authentication-module/src/main/java/net/jami/jams/authmodule/UserAuthenticationModule.java index 0902e623a68f95f76616abc7c88d10464a7b1e5d..182204bc43dcb637f06a764eba73d428eb7a34bf 100644 --- a/authentication-module/src/main/java/net/jami/jams/authmodule/UserAuthenticationModule.java +++ b/authentication-module/src/main/java/net/jami/jams/authmodule/UserAuthenticationModule.java @@ -59,6 +59,8 @@ import java.util.Date; import java.util.List; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @Slf4j public class UserAuthenticationModule implements AuthenticationModule { @@ -237,18 +239,47 @@ public class UserAuthenticationModule implements AuthenticationModule { if (crl.getRevokedCertificate(certificate.getSerialNumber()) != null) return null; } // Now that the certificate chain is valid, we can extract the user and device - X509Certificate clientCert = certificates[1]; - X509Certificate deviceCert = certificates[0]; - String username = X509Utils.extractDNFromCertificate(clientCert).get("CN"); + X509Certificate clientCertificate = certificates[1]; + X509Certificate deviceCertificate = certificates[0]; + String username = X509Utils.extractDNFromCertificate(clientCertificate).get("CN"); // We need to extract the deviceId from the certificate User user = datastore.getUserDao().getByUsername(username).orElseThrow(); return tokenController.getToken( - user, X509Utils.extractDNFromCertificate(deviceCert).get("UID")); + user, X509Utils.extractDNFromCertificate(deviceCertificate).get("UID")); } catch (Exception e) { return null; } } + public AuthTokenResponse authenticateUser(X509Certificate deviceCertificate) { + // In thise case we only generate the token because the reverse proxy already validated the + // client's certificate chain + try { + String username = getIssuerCN(deviceCertificate); + User user = datastore.getUserDao().getByUsername(username).orElseThrow(); + return tokenController.getToken( + user, X509Utils.extractDNFromCertificate(deviceCertificate).get("UID")); + } catch (Exception e) { + return null; + } + } + + public static String getIssuerCN(X509Certificate certificate) { + // Get the issuer DN as a string + String issuerDN = certificate.getIssuerX500Principal().getName(); + + // Define a regex pattern to extract the CN part + Pattern pattern = Pattern.compile("CN=([^,]+)"); + Matcher matcher = pattern.matcher(issuerDN); + + // If a CN is found, return it + if (matcher.find()) { + return matcher.group(1); + } else { + return null; // or handle the case where CN is not found + } + } + public static boolean isSelfSigned(X509Certificate cert) { try { PublicKey key = cert.getPublicKey(); diff --git a/extras/scripts/setup_jams.sh b/extras/scripts/setup_jams.sh index f73bf773fc9edfe1a355350ed6563b11c718bc5e..e6881facc890ce8612ef6e1f729f67bb7b9abc44 100755 --- a/extras/scripts/setup_jams.sh +++ b/extras/scripts/setup_jams.sh @@ -72,6 +72,7 @@ install_settings() { post '/api/install/settings' \ '{ "serverDomain":"http://localhost:8080", + "reverseProxy":false, "crlLifetime":300000, "deviceLifetime":31556952000, "userLifetime":31556952000, diff --git a/jams-ca/src/main/java/net/jami/jams/ca/JamsCA.java b/jams-ca/src/main/java/net/jami/jams/ca/JamsCA.java index 99b11f6219a243e7619c1702ba43f1186001a240..300cd9d067e1bbfc969992b841ecc3ce4fef8919 100644 --- a/jams-ca/src/main/java/net/jami/jams/ca/JamsCA.java +++ b/jams-ca/src/main/java/net/jami/jams/ca/JamsCA.java @@ -62,6 +62,8 @@ public class JamsCA implements CertificateAuthority, RevocationCallback { // CA certificate & OCSP Certificates, because they are often used. public static SystemAccount CA; public static SystemAccount OCSP; + // Whether JAMS is behind a reverse proxy or not. + public static boolean reverseProxy = false; // Flag to indicate completion of the revokeCertificate method private boolean revokeCompleted = false; private final Object lock = new Object(); @@ -83,6 +85,7 @@ public class JamsCA implements CertificateAuthority, RevocationCallback { crlLifetime = config.getCrlLifetime(); userLifetime = config.getUserLifetime(); deviceLifetime = config.getDeviceLifetime(); + reverseProxy = config.getReverseProxy(); if (deviceLifetime > userLifetime) { log.warn( diff --git a/jams-ca/src/main/java/net/jami/jams/ca/workers/csr/builders/SystemAccountBuilder.java b/jams-ca/src/main/java/net/jami/jams/ca/workers/csr/builders/SystemAccountBuilder.java index 182681d925ebaf0cf522b3e9c0fd7897e89348b9..d251932dccbc97def0e0081c97c5ce7a1ef8bbd4 100644 --- a/jams-ca/src/main/java/net/jami/jams/ca/workers/csr/builders/SystemAccountBuilder.java +++ b/jams-ca/src/main/java/net/jami/jams/ca/workers/csr/builders/SystemAccountBuilder.java @@ -27,11 +27,14 @@ import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; import org.bouncycastle.cert.X509v3CertificateBuilder; import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import java.io.FileWriter; import java.math.BigInteger; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.SecureRandom; +import java.security.cert.X509Certificate; import java.util.Date; @Slf4j @@ -40,9 +43,12 @@ public class SystemAccountBuilder { // Self-signed because it is a CA public static SystemAccount generateCA(SystemAccount systemAccount) { try { + // Generate RSA Key Pair KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(4096); KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + // Build the X509 Certificate X509v3CertificateBuilder builder = new X509v3CertificateBuilder( new X500Name(systemAccount.getX509Fields().getDN()), @@ -53,11 +59,23 @@ public class SystemAccountBuilder { + systemAccount.getX509Fields().getLifetime()), new X500Name(systemAccount.getX509Fields().getDN()), SubjectPublicKeyInfo.getInstance(keyPair.getPublic().getEncoded())); - systemAccount.setPrivateKey(keyPair.getPrivate()); - systemAccount.setCertificate( + + // Sign the Certificate + X509Certificate certificate = CertificateSigner.signCertificate( - keyPair.getPrivate(), builder, ExtensionLibrary.caExtensions)); + keyPair.getPrivate(), builder, ExtensionLibrary.caExtensions); + + // Set the private key and certificate in the system account + systemAccount.setPrivateKey(keyPair.getPrivate()); + systemAccount.setCertificate(certificate); + + // Save the certificate to a PEM file + JcaPEMWriter pemWriter = new JcaPEMWriter(new FileWriter("CA.pem")); + pemWriter.writeObject(certificate); + pemWriter.close(); + return systemAccount; + } catch (Exception e) { log.error("Could not generate the system's CA: " + e); return null; diff --git a/jams-ca/src/test/java/net/jami/jams/ca/workers/csr/builders/SystemAccountBuilderTest.java b/jams-ca/src/test/java/net/jami/jams/ca/workers/csr/builders/SystemAccountBuilderTest.java index 69e5a96fb8bcd81f15a1d102616437565084f353..77a2ce80718a50ee95807064a47c2f93461af12e 100644 --- a/jams-ca/src/test/java/net/jami/jams/ca/workers/csr/builders/SystemAccountBuilderTest.java +++ b/jams-ca/src/test/java/net/jami/jams/ca/workers/csr/builders/SystemAccountBuilderTest.java @@ -141,6 +141,7 @@ class SystemAccountBuilderTest { config.setServerDomain("http://localhost"); config.setCrlLifetime(1000000L); config.setDeviceLifetime(1000L); + config.setReverseProxy(false); Gson gson = GsonFactory.createGson(); JamsCA jamsCA = new JamsCA(); @@ -164,7 +165,9 @@ class SystemAccountBuilderTest { @AfterAll static void afterAll() { - File file = new File(System.getProperty("user.dir") + File.separator + "jams.crl"); - file.delete(); + File jamsCrlFile = new File(System.getProperty("user.dir") + File.separator + "jams.crl"); + File jamsCAFile = new File(System.getProperty("user.dir") + File.separator + "CA.pem"); + jamsCAFile.delete(); + jamsCrlFile.delete(); } } diff --git a/jams-common/src/main/java/net/jami/jams/common/cryptoengineapi/CertificateAuthorityConfig.java b/jams-common/src/main/java/net/jami/jams/common/cryptoengineapi/CertificateAuthorityConfig.java index 9b669d28e4946a94327d7d124cfc1c1134ec1a4f..9bfba87efc9964db6ba72a9146d3a3c4bc3989c3 100644 --- a/jams-common/src/main/java/net/jami/jams/common/cryptoengineapi/CertificateAuthorityConfig.java +++ b/jams-common/src/main/java/net/jami/jams/common/cryptoengineapi/CertificateAuthorityConfig.java @@ -23,6 +23,7 @@ import lombok.Setter; @Setter public class CertificateAuthorityConfig { private String serverDomain; + private Boolean reverseProxy; private String signingAlgorithm; private Long crlLifetime; private Long userLifetime; diff --git a/jams-react-client/src/components/ServerParameters/ServerParameters.tsx b/jams-react-client/src/components/ServerParameters/ServerParameters.tsx index 1fb75b57f17c96a0a9b576b572777b4b8baefcdf..f0a0846badd2bc3da686da2107a113ea32c99fc7 100644 --- a/jams-react-client/src/components/ServerParameters/ServerParameters.tsx +++ b/jams-react-client/src/components/ServerParameters/ServerParameters.tsx @@ -25,7 +25,6 @@ import { Formik } from "formik"; import CustomPopupState from "../CustomPopupState/CustomPopupState"; import Select, { SelectChangeEvent } from "@mui/material/Select"; -import Input from "@mui/material/Input"; import * as tool from "../../tools"; @@ -39,7 +38,7 @@ import auth from "auth"; import * as Yup from "yup"; import i18next from "i18next"; -import { Theme } from "@mui/material"; +import { Checkbox, FormControlLabel, Theme } from "@mui/material"; const useStyles = makeStyles((theme: Theme) => ({ paper: { @@ -71,7 +70,7 @@ export default function ServerParameters({ setErrorMessage, }: ServerParametersProps) { // Formik validation fields - const initialValues = { domain: backend_address.origin }; + const initialValues = { domain: backend_address.origin, reverseProxy: false }; const validationSchema = Yup.object().shape({ domain: Yup.string().required( i18next.t("domain_is_required", "Domain is required.") as string @@ -80,6 +79,7 @@ export default function ServerParameters({ type Settings = { domain: string; + reverseProxy: boolean; }; const certificateRevocationTypes = [ @@ -154,6 +154,7 @@ export default function ServerParameters({ crlLifetime: certificateRevocation.value, deviceLifetime: deviceLifetime.value, userLifetime: userAccountLifetime.value, + reverseProxy: values.reverseProxy, signingAlgorithm: "SHA512WITHRSA", }; axios(configApiCall(api_path_post_install_server, "POST", jsonData, null)) @@ -327,7 +328,29 @@ export default function ServerParameters({ } </span> ) : null} - + <Typography variant="subtitle1" gutterBottom> + {i18next.t("reverse_proxy", "Reverse proxy") as string} + <CustomPopupState + message={i18next.t( + "set_whether_or_not_reverse_proxy", + "Set whether or not your JAMS installation uses a dedicated reverse proxy like Nginx." + )} + /> + </Typography> + <FormControlLabel + control={ + <Checkbox + checked={values.reverseProxy} + id="reverseProxy" + onChange={handleChange} + inputProps={{ "aria-label": "primary checkbox" }} + color="primary" + /> + } + label={ + i18next.t("use_reverse_proxy", "Use a reverse proxy") as string + } + /> <Button type="submit" fullWidth diff --git a/jams-server/src/main/java/net/jami/jams/server/Server.java b/jams-server/src/main/java/net/jami/jams/server/Server.java index e6a83102eae0fc8d22f88c246d304a8a65407221..4c5273bcafa083116ef10f8ec4f284433006f96d 100644 --- a/jams-server/src/main/java/net/jami/jams/server/Server.java +++ b/jams-server/src/main/java/net/jami/jams/server/Server.java @@ -41,9 +41,13 @@ import net.jami.jams.server.startup.CryptoEngineLoader; import net.jami.jams.server.update.JAMSUpdater; import net.jami.jams.server.update.UpdateInterface; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; + import java.awt.Desktop; import java.io.File; import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; import java.io.Reader; import java.net.URI; import java.util.concurrent.atomic.AtomicBoolean; @@ -109,6 +113,8 @@ public class Server { try { Reader reader = new FileReader(configJsonFile); loadConfig(reader); + // This covers the case where the server is installed but the CA file is missing. + createCAFile(); log.info("All services are UP and ready for use..."); } catch (Exception e) { log.error( @@ -144,6 +150,18 @@ public class Server { } } + private static void createCAFile() { + if (!new File("CA.pem").exists()) { + try { + JcaPEMWriter pemWriter = new JcaPEMWriter(new FileWriter("CA.pem")); + pemWriter.writeObject(certificateAuthority.getCA()); + pemWriter.close(); + } catch (IOException e) { + log.error("Could not create CA file: " + e); + } + } + } + private static void loadConfig(Reader reader) { ServerSettings serverSettings = gson.fromJson(reader, ServerSettings.class); diff --git a/jams-server/src/main/java/net/jami/jams/server/servlets/LoginServlet.java b/jams-server/src/main/java/net/jami/jams/server/servlets/LoginServlet.java index b126ac4248a0c86c34d4aef48a42aa705d86e47c..91785f95854dd5a76790588fe0129d68286af619 100644 --- a/jams-server/src/main/java/net/jami/jams/server/servlets/LoginServlet.java +++ b/jams-server/src/main/java/net/jami/jams/server/servlets/LoginServlet.java @@ -28,6 +28,7 @@ import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import net.jami.jams.ca.JamsCA; import net.jami.jams.common.annotations.JsonContent; import net.jami.jams.common.authmodule.AuthTokenResponse; import net.jami.jams.common.objects.user.User; @@ -35,8 +36,14 @@ import net.jami.jams.common.serialization.adapters.GsonFactory; import net.jami.jams.common.serialization.tomcat.TomcatCustomErrorHandler; import net.jami.jams.server.servlets.api.auth.login.LoginRequest; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; +import java.util.Base64; import java.util.Optional; @WebServlet("/api/login") @@ -65,13 +72,39 @@ public class LoginServlet extends HttpServlet { res = processUsernamePasswordAuth(req.getHeader("authorization")); } // Case 2 SSL Certificate - else if (req.getAttribute("jakarta.servlet.request.X509Certificate") != null) { + String clientCert = req.getHeader("X-Client-Cert"); + if (JamsCA.reverseProxy && clientCert != null) { + try { + // URL-decode the header value + String decodedHeader = URLDecoder.decode(clientCert, StandardCharsets.UTF_8.name()); + + // Remove the PEM header and footer + String pem = + decodedHeader + .replace("-----BEGIN CERTIFICATE-----", "") + .replace("-----END CERTIFICATE-----", "") + .replaceAll("\\s+", ""); // Remove all whitespace characters + + // Decode the Base64 encoded certificate + byte[] decodedBytes = Base64.getDecoder().decode(pem); + + // Generate the X509Certificate object + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + InputStream in = new ByteArrayInputStream(decodedBytes); + X509Certificate certificate = (X509Certificate) factory.generateCertificate(in); + + // Process the certificate as needed + res = processX509Auth(certificate); + } catch (Exception e) { + e.printStackTrace(); + } + } else if (req.getAttribute("jakarta.servlet.request.X509Certificate") != null) { res = processX509Auth( (X509Certificate[]) req.getAttribute("jakarta.servlet.request.X509Certificate")); } else { - // Case 3: form submitted username/password + // Case 4: form submitted username/password LoginRequest object = gson.fromJson(req.getReader(), LoginRequest.class); if (object.getUsername() != null && object.getPassword() != null) { res = processUsernamePasswordAuth(object.getUsername(), object.getPassword()); diff --git a/jams-server/src/main/java/net/jami/jams/server/servlets/api/auth/login/AuthRequestProcessor.java b/jams-server/src/main/java/net/jami/jams/server/servlets/api/auth/login/AuthRequestProcessor.java index 18e1a8825171ee85d35878cda929b67bfd921695..a2d0209041a15e3a06853ab24bebbfc69e4d312b 100644 --- a/jams-server/src/main/java/net/jami/jams/server/servlets/api/auth/login/AuthRequestProcessor.java +++ b/jams-server/src/main/java/net/jami/jams/server/servlets/api/auth/login/AuthRequestProcessor.java @@ -18,6 +18,7 @@ package net.jami.jams.server.servlets.api.auth.login; import static net.jami.jams.server.Server.userAuthenticationModule; +import net.jami.jams.authmodule.UserAuthenticationModule; import net.jami.jams.common.authmodule.AuthTokenResponse; import java.security.cert.X509Certificate; @@ -29,6 +30,11 @@ public class AuthRequestProcessor { return userAuthenticationModule.authenticateUser(certificates); } + // This is only called when JAMS is behind a reverse proxy + public static AuthTokenResponse processX509Auth(X509Certificate certificate) { + return ((UserAuthenticationModule) userAuthenticationModule).authenticateUser(certificate); + } + public static AuthTokenResponse processUsernamePasswordAuth(String username, String password) { return userAuthenticationModule.authenticateUser(username, password); } diff --git a/userguide/docs/admin.md b/userguide/docs/admin.md index 7a33cce3d6ebff5927cd661ef89dbd7db662023e..d83ab60aa223f597409bb9e66e80aff4979f17ff 100644 --- a/userguide/docs/admin.md +++ b/userguide/docs/admin.md @@ -23,25 +23,34 @@ The following is an example map of how you could configure JAMS behind Nginx (th The IP 10.10.0.1 is random, and should be seen as an example. - Typically you would add a new site called ``jams-site.conf`` to your nginx configurations which would contain the following entries if you wanted to place an SSL certificate at the Nginx level: <pre> <b>server { listen 443 ssl; listen [::]:443 ssl; ssl on; - ssl_certificate /etc/certificates/mycertificate.pem - ssl_certificate_key /etc/certificates/mycertificatekey.pem + ssl_certificate /etc/certificates/mycertificate.pem + ssl_certificate_key /etc/certificates/mycertificatekey.pem + ssl_client_certificate /jams/installation/path/CA.pem; + ssl_verify_client optional; + ssl_verify_depth 2; client_max_body_size 100M; server_name jams.mycompany.com; location / { + # Block client-supplied headers that could be used to spoof + if ($http_x_client_cert) { + return 400; + } proxy_pass http://10.10.0.1:8080/; proxy_set_header X-Real-IP $remote_addr; proxy_set_header Host $http_host; + proxy_set_header X-Client-Cert $ssl_client_escaped_cert; } }</b></pre> This is the preferred setup method by most admins, as local traffic is usually run unencrypted since it is usually either inter-VM connection, a VLAN or another dedicated link. +*Note: Since the CA is generated during the JAMS initial configuration, Nginx needs to be restarted once the initial setup is completed* + ## Troubleshooting and resetting If you ever need to restart from 0 (i.e. reset everything and drop existing data) you can do so by deleting the following files in the distribution folder (<your project root folder>/jams): <pre> @@ -63,7 +72,7 @@ Visit [https://jami.biz/](https://jami.biz/) and download JAMS. Extract JAMS to c:\jams -### Download and install JDK 11 +### Download and install JDK 11 Download JDK 11 from https://www.oracle.com/java/technologies/javase-jdk11-downloads.html (choose the corresponding architecture of your VM) @@ -82,14 +91,14 @@ Create a new file inside bin named openssl.cnf (make sure that the file extensio # # OpenSSL configuration file. # - + # Establish working directory. - + dir = . - + [ ca ] default_ca = CA_default - + [ CA_default ] serial = $dir/serial database = $dir/certindex.txt @@ -103,7 +112,7 @@ email_in_dn = no nameopt = default_ca certopt = default_ca policy = policy_match - + [ policy_match ] countryName = match stateOrProvinceName = match @@ -111,7 +120,7 @@ organizationName = match organizationalUnitName = optional commonName = supplied emailAddress = optional - + [ req ] default_bits = 1024 # Size of keys default_keyfile = key.pem # name of generated keys @@ -119,7 +128,7 @@ default_md = md5 # message digest algorithm string_mask = nombstr # permitted characters distinguished_name = req_distinguished_name req_extensions = v3_req - + [ req_distinguished_name ] # Variable name Prompt string #------------------------- ---------------------------------- @@ -134,7 +143,7 @@ countryName_min = 2 countryName_max = 2 commonName = Common Name (hostname, IP, or your name) commonName_max = 64 - + # Default values for the above, for consistency and less typing. # Variable name Value ------------------------ ------------------------------ @@ -142,12 +151,12 @@ commonName_max = 64 localityName_default = My Town stateOrProvinceName_default = State or Providence countryName_default = US - + [ v3_ca ] basicConstraints = CA:TRUE subjectKeyIdentifier = hash authorityKeyIdentifier = keyid:always,issuer:always - + [ v3_req ] basicConstraints = CA:FALSE subjectKeyIdentifier = hash @@ -254,7 +263,7 @@ Source: [https://medium.com/@lk.snatch/jar-file-as-windows-service-bonus-jar-to- ## Running JAMS as a Linux Service -Running JAMS as a Linux Service is fairly straightforward with systemd - you simply created a service unit file with the following structure: +Running JAMS as a Linux Service is fairly straightforward with systemd - you simply created a service unit file with the following structure: <b>[Unit] Description=JAMS Server