diff --git a/pom.xml b/pom.xml index 4d1da651..8981ec5a 100644 --- a/pom.xml +++ b/pom.xml @@ -77,7 +77,12 @@ spring-boot-starter-security ${spring_boot_version} - + + + + + + org.springframework.boot @@ -309,10 +314,10 @@ - - ch.qos.logback - logback-classic - + + + + @@ -543,6 +548,25 @@ ${logback-classic.version} + + io.jsonwebtoken + jjwt + 0.9.1 + + + com.fasterxml.jackson.core + jackson-databind + + + + + + com.nimbusds + nimbus-jose-jwt + 9.37.3 + + + diff --git a/src/main/database/create-mysql-database.sql b/src/main/database/create-mysql-database.sql index 1430f952..80da5021 100644 --- a/src/main/database/create-mysql-database.sql +++ b/src/main/database/create-mysql-database.sql @@ -49,4 +49,5 @@ CREATE TABLE `tenant` ( --insert into user_access (org_id, access_name, access_key) values (1, 'Mercy', 'password1234'); --insert into user_access (org_id, access_name, access_key) values (2, 'Bob', '1234password1234'); --insert into user_access (org_id, access_name, access_key) values (3, 'DEFAULT', 'BabySharkJaws'); ---insert into user_access (org_id, access_name, access_key) values (4, 'Connectathon', 'SundaysR0ck!'); \ No newline at end of file +insert into user_access (org_id, access_name, access_key) values (4, 'Connectathon', 'SundaysR0ck!'); +insert into user_access (org_id, access_name, access_key) values (4, 'admin', '?whooosM0reSorino?); \ No newline at end of file diff --git a/src/main/java/org/immregistries/iis/kernal/JwtAuthProvider.java b/src/main/java/org/immregistries/iis/kernal/JwtAuthProvider.java new file mode 100644 index 00000000..e456a060 --- /dev/null +++ b/src/main/java/org/immregistries/iis/kernal/JwtAuthProvider.java @@ -0,0 +1,120 @@ +package org.immregistries.iis.kernal; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.crypto.ECDSAVerifier; +import com.nimbusds.jose.crypto.RSASSAVerifier; +import com.nimbusds.jwt.SignedJWT; +import org.hibernate.Session; +import org.immregistries.iis.kernal.fhir.security.ServletHelper; +import org.immregistries.iis.kernal.model.UserAccess; +import org.immregistries.iis.kernal.servlet.PopServlet; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.security.PublicKey; +import java.security.interfaces.ECPublicKey; +import java.security.interfaces.RSAPublicKey; +import java.text.ParseException; +import java.util.HashMap; +import java.util.Map; + +@RestController() +public class JwtAuthProvider { + @Autowired + JwtUtils jwtUtils; + Map keystore; + Map jwtStore; + Logger logger = LoggerFactory.getLogger(JwtAuthProvider.class); + + public JwtAuthProvider() { + this.keystore = new HashMap<>(10); + this.jwtStore = new HashMap<>(10); + } + + @GetMapping("/.well-known/smart-configuration") + public String wellKnownConfiguration() { + return "{\n" + + " \"token_endpoint\": \"/token\",\n" + + " \"token_endpoint_auth_methods_supported\": [\"private_key_jwt\"],\n" + + " \"token_endpoint_auth_signing_alg_values_supported\": [\"RS384\", \"ES384\"],\n" + + " \"scopes_supported\": [\"system/*.rs\"]\n" + + "}"; + } + + @PostMapping("/registerClient") + public String register(@RequestBody String jwkString) { //TODO TLS config + // assert(ServletHelper.getUserAccess().getAccessName().equals("admin")); TODO safely define admin user + logger.info("Registering client JWK: {}", jwkString); //TODO not log key + Map parsedJwk = new Gson().fromJson(jwkString, new TypeToken>() {}.getType()); + String alg = (String) parsedJwk.get("alg"); + String kid = (String) parsedJwk.get("kid"); + switch (alg) { + case "RS384": + RSAPublicKey rsaJwk = new Gson().fromJson(jwkString, RSAPublicKey.class); + keystore.put(kid,rsaJwk); // TODO change key ? + break; + case "ES384" : + ECPublicKey ecJwk = new Gson().fromJson(jwkString, ECPublicKey.class); + keystore.put(kid,ecJwk); // TODO change key ? + break; + default: + throw new RuntimeException("Unsupported Algorithm"); + } + return "ok"; + } + + /** + * Only allowed for connectathons users + */ + @PostMapping("/token") + public String smartJwtAuth(@RequestParam String client_assertion_type, @RequestParam String client_assertion) throws ParseException, JOSEException { + SignedJWT signedJWT = SignedJWT.parse(client_assertion); + /** + * Reading Jwt Headers + */ + String alg = signedJWT.getHeader().getAlgorithm().getName(); + String typ = signedJWT.getHeader().getType().getType(); + String kid = signedJWT.getHeader().getKeyID(); +// String jku = signedJWT.getHeader().getJWKURL(); not supported + + assert (typ.equals("JWT")); + assert (keystore.containsKey(kid)); + JWSVerifier verifier; + switch (alg) { + case "RS384": + verifier = new RSASSAVerifier((RSAPublicKey) keystore.get(kid)); + break; + case "ES384" : + verifier = new ECDSAVerifier((ECPublicKey) keystore.get(kid)); + break; + default: + throw new RuntimeException("Unsupported Algorithm"); + } + assert(signedJWT.verify(verifier)); + assert(signedJWT.getJWTClaimsSet().getClaim("jti") != null); + /** + * check that the jti value has not been previously encountered for the given iss within the maximum allowed authentication JWT lifetime (e.g., 5 minutes). This check prevents replay attacks. + */ + SignedJWT olderJwt = jwtStore.get((String) signedJWT.getJWTClaimsSet().getClaim("jti")); + if (olderJwt != null && olderJwt.verify(verifier) && olderJwt.getJWTClaimsSet().getClaim("iss").equals(signedJWT.getJWTClaimsSet().getClaim("iss")) && ((long) olderJwt.getJWTClaimsSet().getClaim("exp")) < System.currentTimeMillis()) { + throw new RuntimeException("Token already used"); + } + jwtStore.put((String) signedJWT.getJWTClaimsSet().getClaim("jti"), signedJWT); + Session dataSession = null; + try { + dataSession = PopServlet.getDataSession(); + UserAccess userAccess = ServletHelper.authenticateUserAccessUsernamePassword("Connectathon","Connectathon",dataSession); + return jwtUtils.generateJwtToken(userAccess); + } finally { + if (dataSession != null) { + dataSession.close(); + } + } + } + +} diff --git a/src/main/java/org/immregistries/iis/kernal/JwtUtils.java b/src/main/java/org/immregistries/iis/kernal/JwtUtils.java new file mode 100644 index 00000000..9e25565d --- /dev/null +++ b/src/main/java/org/immregistries/iis/kernal/JwtUtils.java @@ -0,0 +1,49 @@ +package org.immregistries.iis.kernal; + +import io.jsonwebtoken.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import java.util.Date; + +@Component +public class JwtUtils { + private static final Logger logger = LoggerFactory.getLogger(JwtUtils.class); + @Value("${iis.api.app.jwtSecret}") + private String jwtSecret; + @Value("${iis.api.app.jwtExpirationMs}") + private int jwtExpirationMs; + public String generateJwtToken(Authentication authentication) { + UserDetails userPrincipal = (UserDetails) authentication.getPrincipal(); + return Jwts.builder() + .setSubject((userPrincipal.getUsername())) + .setIssuedAt(new Date()) + .setExpiration(new Date((new Date()).getTime() + jwtExpirationMs)) + .signWith(SignatureAlgorithm.HS512, jwtSecret) + .compact(); + } + public String getUserNameFromJwtToken(String token) { + return Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token).getBody().getSubject(); + } + public boolean validateJwtToken(String authToken) { + try { + Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(authToken); + return true; + } catch (SignatureException e) { + logger.error("Invalid JWT signature: {}", e.getMessage()); + } catch (MalformedJwtException e) { + logger.error("Invalid JWT token: {}", e.getMessage()); + } catch (ExpiredJwtException e) { + logger.error("JWT token is expired: {}", e.getMessage()); + } catch (UnsupportedJwtException e) { + logger.error("JWT token is unsupported: {}", e.getMessage()); + } catch (IllegalArgumentException e) { + logger.error("JWT claims string is empty: {}", e.getMessage()); + } + return false; + } +} \ No newline at end of file diff --git a/src/main/java/org/immregistries/iis/kernal/fhir/security/ServerSecurityConfig.java b/src/main/java/org/immregistries/iis/kernal/fhir/security/ServerSecurityConfig.java index b48e176e..58fc3825 100644 --- a/src/main/java/org/immregistries/iis/kernal/fhir/security/ServerSecurityConfig.java +++ b/src/main/java/org/immregistries/iis/kernal/fhir/security/ServerSecurityConfig.java @@ -24,7 +24,7 @@ public SecurityFilterChain filterChain(HttpSecurity http, CustomOAuthSuccessHand .antMatchers(HttpMethod.GET, "/", "/home", "/pop", "/SubscriptionTopic/**", "/img/**").permitAll() .antMatchers("/loginForm", "/oauth2/**", "/login" ).permitAll() // API AUTHORIZATION AND AUTHENTICATION SEPARATED - .antMatchers("/fhir/**", "/soap").permitAll() + .antMatchers("/fhir/**", "/soap","/.well-known/smart-configuration","/registerClient", "/token").permitAll() .anyRequest().authenticated() .and() //USERNAME diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 0567f46d..cd9c7159 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -11,6 +11,12 @@ management: server: servlet: contextPath: "/iis" +iis: + api: + app: + secret: "LeVentNousPorterasT0utDizp4ra1tra" + jwtExpirationMs: 30000 +# TODO change secret spring: security: oauth2: