diff --git a/backend/pom.xml b/backend/pom.xml index e4f52ff6..bb594ec3 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 3.0.0 + 3.0.1 org.springframework.samples @@ -37,6 +37,10 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + org.springframework.boot spring-boot-starter-validation @@ -86,33 +90,8 @@ test - org.springframework.data - spring-data-jdbc-core - 1.2.1.RELEASE - - - org.springframework - * - - - - - - io.jsonwebtoken - jjwt-api - 0.11.2 - - - io.jsonwebtoken - jjwt-impl - 0.11.2 - runtime - - - io.jsonwebtoken - jjwt-jackson - 0.11.2 - runtime + org.springframework.boot + spring-boot-starter-actuator diff --git a/backend/src/main/java/org/springframework/samples/petclinic/graphql/AuthController.java b/backend/src/main/java/org/springframework/samples/petclinic/graphql/AuthController.java index e086215e..dd74e007 100644 --- a/backend/src/main/java/org/springframework/samples/petclinic/graphql/AuthController.java +++ b/backend/src/main/java/org/springframework/samples/petclinic/graphql/AuthController.java @@ -1,18 +1,17 @@ package org.springframework.samples.petclinic.graphql; -import graphql.schema.DataFetchingEnvironment; -import graphql.schema.idl.RuntimeWiring; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.graphql.data.method.annotation.QueryMapping; -import org.springframework.graphql.execution.RuntimeWiringConfigurer; import org.springframework.samples.petclinic.auth.User; -import org.springframework.security.core.Authentication; +import org.springframework.samples.petclinic.auth.UserRepository; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; +import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.stereotype.Controller; +import java.security.Principal; + /** * EXAMPLE: * -------------------------- @@ -25,9 +24,17 @@ public class AuthController { private static final Logger log = LoggerFactory.getLogger(AuthController.class); + private final UserRepository userRepository; + + public AuthController(UserRepository userRepository) { + this.userRepository = userRepository; + } + @QueryMapping - public User me(@AuthenticationPrincipal User user) { - return user; + public User me(@AuthenticationPrincipal(errorOnInvalidType = true) Jwt jwt) { + String username = jwt.getSubject(); + log.info("JWT subject (username): '{}'", username); + return userRepository.findByUsername(username).orElseThrow(); } @QueryMapping diff --git a/backend/src/main/java/org/springframework/samples/petclinic/graphql/VetController.java b/backend/src/main/java/org/springframework/samples/petclinic/graphql/VetController.java index 3ba5f201..e96a365a 100644 --- a/backend/src/main/java/org/springframework/samples/petclinic/graphql/VetController.java +++ b/backend/src/main/java/org/springframework/samples/petclinic/graphql/VetController.java @@ -21,7 +21,7 @@ * GraphQL handler functions for Vet GraphQL type, Query and Mutation * * Note that the addVet mutation is secured in the domain layer, so that only - * users with ROLE_MANAGER are allowed to create new vets + * users with SCOPE_MANAGER are allowed to create new vets * * @author Nils Hartmann (nils@nilshartmann.net) */ diff --git a/backend/src/main/java/org/springframework/samples/petclinic/model/VetService.java b/backend/src/main/java/org/springframework/samples/petclinic/model/VetService.java index 6c30351a..713f3d4a 100644 --- a/backend/src/main/java/org/springframework/samples/petclinic/model/VetService.java +++ b/backend/src/main/java/org/springframework/samples/petclinic/model/VetService.java @@ -23,7 +23,7 @@ public VetService(VetRepository vetRepository, SpecialtyRepository specialtyRepo } @Transactional - @PreAuthorize("hasRole('ROLE_MANAGER')") + @PreAuthorize("hasAuthority('SCOPE_MANAGER')") public Vet createVet(String firstName, String lastName, List specialtyIds) throws InvalidVetDataException { Vet vet = new Vet(); vet.setFirstName(firstName); diff --git a/backend/src/main/java/org/springframework/samples/petclinic/security/JwtAuthenticationFilter.java b/backend/src/main/java/org/springframework/samples/petclinic/security/JwtAuthenticationFilter.java deleted file mode 100644 index 9ad9919d..00000000 --- a/backend/src/main/java/org/springframework/samples/petclinic/security/JwtAuthenticationFilter.java +++ /dev/null @@ -1,96 +0,0 @@ -package org.springframework.samples.petclinic.security; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.samples.petclinic.auth.User; -import org.springframework.samples.petclinic.auth.UserRepository; -import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; - -/** - * Parses JWT Token from Request Header and authenticated the user - * - * - if no authentication header is set, no authentication is done - * - if the given jwt token is either invalid (bad format for example) or expired, authentication is denied - * - * Note that this is an example only. DO NOT IMPLEMENT OWN SECURITY CODE IN REAL PRODUCTION APPS !!!!!!!!!! - * - * @author Nils Hartmann (nils@nilshartmann.net) - */ -@Component -public class JwtAuthenticationFilter extends OncePerRequestFilter { - private final Logger logger = LoggerFactory.getLogger(getClass()); - - private final JwtTokenService jwtTokenService; - private final UserRepository userRepository; - - public JwtAuthenticationFilter(JwtTokenService jwtTokenService, UserRepository userRepository) { - this.jwtTokenService = jwtTokenService; - this.userRepository = userRepository; - } - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - - try { - logger.trace("Inside JwtAuthenticationFilter"); - authenticateIfNeeded(request); - } catch (AuthenticationException bed) { - logger.error("Could not authenticate: " + bed, bed); - SecurityContextHolder.clearContext(); - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - return; - } - - filterChain.doFilter(request, response); - } - - private void authenticateIfNeeded(HttpServletRequest request) { - final String token = getJwtFromRequest(request); - if (token != null) { - if (!jwtTokenService.isValidToken(token)) { - throw new BadCredentialsException("Invalid authorization token"); - } - - String username = jwtTokenService.getUsernameFromToken(token); - User user = userRepository.findByUsername(username).orElseThrow(() -> new BadCredentialsException("Invalid User in Token")); - - UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user, null, - user.getAuthorities()); - authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authentication); - } - } - - private String getJwtFromRequest(HttpServletRequest request) { - String tokenParameter = request.getParameter("token"); - if (tokenParameter != null) { - return tokenParameter; - } - - - - String authHeader = request.getHeader("Authorization"); - if (authHeader == null) { - return null; - } - if (!authHeader.startsWith("Bearer ")) { - throw new BadCredentialsException( - "Invalid 'Authorization'-Header"); - } - return authHeader.substring(7, authHeader.length()); - } - -} diff --git a/backend/src/main/java/org/springframework/samples/petclinic/security/JwtTokenService.java b/backend/src/main/java/org/springframework/samples/petclinic/security/JwtTokenService.java index 5c268ac4..da4fb264 100644 --- a/backend/src/main/java/org/springframework/samples/petclinic/security/JwtTokenService.java +++ b/backend/src/main/java/org/springframework/samples/petclinic/security/JwtTokenService.java @@ -1,97 +1,49 @@ package org.springframework.samples.petclinic.security; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.security.Keys; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.samples.petclinic.auth.User; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; import org.springframework.stereotype.Service; -import jakarta.annotation.PostConstruct; -import javax.crypto.SecretKey; -import java.nio.charset.StandardCharsets; -import java.text.SimpleDateFormat; -import java.util.Date; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collection; +import java.util.stream.Collectors; /** - * Note that this is an example only. DO NOT IMPLEMENT OWN SECURITY CODE IN REAL PRODUCTION APPS !!!!!!!!!! - * - * @author Nils Hartmann (nils@nilshartmann.net) + * Based con code taken from Dan Vega https://github.com/danvega/jwt-username-password/blob/master/src/main/java/dev/danvega/jwt/service/TokenService.java */ @Service public class JwtTokenService { - private final Logger logger = LoggerFactory.getLogger(getClass()); - @Value("${jwt.expirationInMs:7200000}") - private int jwtExpirationInMs; + private final JwtEncoder encoder; - private final SecretKey secretKey; - - public JwtTokenService(@Value("${jwt.secretString:fasdfahsdufak4923674asbclbca73,f,a,dfw}") String secretString) { - this.secretKey = Keys.hmacShaKeyFor(secretString.getBytes(StandardCharsets.UTF_8)); - } - - /** Creates a token that will never expire and will be stable accross re-starts - * as longs as jwt.secretString does not change. - * - * This token can be used for easier testing using command line tools etc. - * YOU SHOULD NEVER DO THIS IN 'REAL' PRODUCTION APPS - */ - @PostConstruct - void createNeverExpiringToken() throws Exception { - SimpleDateFormat f = new SimpleDateFormat("dd.MM.yyyy HH:mm"); - String neverExpiringManagerToken = Jwts.builder() - .setSubject("susi") - .setIssuedAt(f.parse("25.12.2020 10:44")) - .setExpiration(f.parse("25.12.2044 10:44")) - .signWith(secretKey) - .compact(); - - String neverExpiringUserToken = Jwts.builder() - .setSubject("joe") - .setIssuedAt(f.parse("25.12.2020 10:44")) - .setExpiration(f.parse("25.12.2044 10:44")) - .signWith(secretKey) - .compact(); - logger.info("\n\nNever Expiring JWT Token\n\n - ROLE_MANAGER: '{}'\n As HTTP Header: 'Authorization: Bearer {}'\n\n - ROLE_USER: '{}'\n As HTTP Header: 'Authorization: Bearer {}'\n", neverExpiringManagerToken, neverExpiringManagerToken, neverExpiringUserToken, neverExpiringUserToken); + public JwtTokenService(JwtEncoder encoder) { + this.encoder = encoder; } - public String createTokenForUser(User user) { - - Date now = new Date(); - Date expiryDate = new Date(now.getTime() + jwtExpirationInMs); - - return Jwts.builder() - .setSubject(user.getUsername()) - .setIssuedAt(now) - .setExpiration(expiryDate) - .signWith(secretKey) - .compact(); + public String generateToken(Authentication authentication) { + return generateToken(authentication.getName(), + authentication.getAuthorities(), + Instant.now().plus(1, ChronoUnit.HOURS) + ); } - public String getUsernameFromToken(String token) { - Claims claims = Jwts.parserBuilder() - .setSigningKey(secretKey) - .build() - .parseClaimsJws(token) - .getBody(); - - return claims.getSubject(); - } + public String generateToken(String name, Collection authorities, Instant expiresAt) { + String scope = authorities.stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(" ")); - public boolean isValidToken(String authToken) { - try { - Jwts.parserBuilder() - .setSigningKey(secretKey) - .build() - .parseClaimsJws(authToken); - return true; - } catch (Exception ex) { - logger.info("Invalid JWT token: " + ex); - } + JwtClaimsSet claims = JwtClaimsSet.builder() + .issuer("self") + .issuedAt(Instant.now()) + .expiresAt(expiresAt) + .subject(name) + .claim("scope", scope) + .build(); - return false; + return this.encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue(); } } diff --git a/backend/src/main/java/org/springframework/samples/petclinic/security/LoginController.java b/backend/src/main/java/org/springframework/samples/petclinic/security/LoginController.java index 9b9c89a4..6d339004 100644 --- a/backend/src/main/java/org/springframework/samples/petclinic/security/LoginController.java +++ b/backend/src/main/java/org/springframework/samples/petclinic/security/LoginController.java @@ -1,28 +1,24 @@ package org.springframework.samples.petclinic.security; +import jakarta.validation.Valid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.samples.petclinic.auth.User; -import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; -import org.springframework.security.core.AuthenticationException; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; -import jakarta.validation.Valid; - /** - * Login via Username/Password with "REST" API. + * Login via Username/Password via HTTP POST endpoint. *

* - The /graphql endpoints requires a valid token. This token can be requests by invoking * HTTP "POST /login with" sending username and password. *

- * Note that this is an example only. DO NOT IMPLEMENT OWN SECURITY CODE IN REAL PRODUCTION APPS !!!!!!!!!! * * @author Nils Hartmann (nils@nilshartmann.net) */ @@ -31,31 +27,30 @@ public class LoginController { private static final Logger log = LoggerFactory.getLogger(LoginController.class); - private final AuthenticationProvider authenticationProvider; - private final JwtTokenService jwtTokenService; + private final JwtTokenService tokenService; + private final AuthenticationManager authenticationManager; - public LoginController(AuthenticationProvider authenticationProvider, JwtTokenService jwtTokenService) { - this.authenticationProvider = authenticationProvider; - this.jwtTokenService = jwtTokenService; + public LoginController(JwtTokenService tokenService, AuthenticationManager authenticationManager) { + this.tokenService = tokenService; + this.authenticationManager = authenticationManager; } @PostMapping("/login") public ResponseEntity login(@RequestBody @Valid LoginRequest request) { + log.info("Authorizing '{}'", request.getUsername()); try { - Authentication authenticate = authenticationProvider.authenticate(new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())); + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()) + ); - if (authenticate != null && (authenticate.getPrincipal() instanceof User)) { - User principal = (User) authenticate.getPrincipal(); - String jwtToken = jwtTokenService.createTokenForUser(principal); + String token = tokenService.generateToken(authentication); - return ResponseEntity.ok() - .body(new LoginResponse((jwtToken))); + return ResponseEntity.ok(new LoginResponse(token)); - } - } catch (AuthenticationException ex) { - log.error("Authentication failed: " + ex, ex); + } catch (Exception ex) { + log.error("could not authenticate: " + ex, ex); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } @GetMapping("/ping") diff --git a/backend/src/main/java/org/springframework/samples/petclinic/security/NeverExpiringTokenGenerator.java b/backend/src/main/java/org/springframework/samples/petclinic/security/NeverExpiringTokenGenerator.java new file mode 100644 index 00000000..963bd300 --- /dev/null +++ b/backend/src/main/java/org/springframework/samples/petclinic/security/NeverExpiringTokenGenerator.java @@ -0,0 +1,71 @@ +package org.springframework.samples.petclinic.security; + +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.samples.petclinic.auth.UserRepository; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + + +/** + * Generates a non-expiring token for testing. + * + * 👮 👮 👮 YOU SHOULD NEVER DO THIS IN PRODUCTION 👮 👮 👮 + * + * @author Nils Hartmann (nils@nilshartmann.net) + */ +@Component +class NeverExpiringTokenGenerator { + + private static final Logger log = LoggerFactory.getLogger(NeverExpiringTokenGenerator.class); + + private final UserRepository userRepository; + private final JwtTokenService tokenService; + + NeverExpiringTokenGenerator(UserRepository userRepository, JwtTokenService tokenService) { + this.userRepository = userRepository; + this.tokenService = tokenService; + } + + /** + * Creates a token that will never expire and will be stable accross re-starts + * as longs as the RSAKey does not change (keys from publicKey and privateKey application properties) + *

+ * This token can be used for easier testing using command line tools etc. + * 👮 👮 👮 YOU SHOULD NEVER DO THIS IN 'REAL' PRODUCTION APPS 👮 👮 👮 + */ + @PostConstruct + void createNonExpiringTokens() { + var somewhen = Instant.now().plus(10 * 365, ChronoUnit.DAYS); + + var susi = userRepository.findByUsername("susi").orElseThrow(); + var neverExpiringManagerToken = tokenService.generateToken(susi.getUsername(), susi.getRoles(), somewhen); + + var joe = userRepository.findByUsername("joe").orElseThrow(); + var neverExpiringUserToken = tokenService.generateToken(joe.getUsername(), joe.getRoles(), somewhen); + log.info(""" + + =============================================================== + 🚨 🚨 🚨 NEVER EXPIRING JWT TOKENS 🚨 🚨 🚨 + =============================================================== + SCOPE_MANAGER + login: '{}' + + {"Authorization": "Bearer {}"} + + SCOPE_USER + login: '{}' + + {"Authorization": "Bearer {}"} + + =============================================================== + """, + susi.getUsername(), + neverExpiringManagerToken, + joe.getUsername(), + neverExpiringUserToken); + } +} diff --git a/backend/src/main/java/org/springframework/samples/petclinic/security/RSAKeyProvider.java b/backend/src/main/java/org/springframework/samples/petclinic/security/RSAKeyProvider.java new file mode 100644 index 00000000..95ff3f09 --- /dev/null +++ b/backend/src/main/java/org/springframework/samples/petclinic/security/RSAKeyProvider.java @@ -0,0 +1,56 @@ +package org.springframework.samples.petclinic.security; + +import com.nimbusds.jose.jwk.RSAKey; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.KeyFactory; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import java.util.UUID; + +/** + * @author Nils Hartmann (nils@nilshartmann.net) + * + * Based on Code from Dan Vega: https://github.com/danvega/jwt-username-password/blob/master/src/main/java/dev/danvega/jwt/security/Jwks.java + */ +@Component +public class RSAKeyProvider { + private static final Logger log = LoggerFactory.getLogger(RSAKeyProvider.class); + + private final String privateKeyString; + private final String publicKeyString; + + private RSAKey rsaKey; + + public RSAKeyProvider(@Value("${publicKey}") String publicKeyString, @Value("${privateKey}") String privateKeyString) { + this.privateKeyString = privateKeyString; + this.publicKeyString = publicKeyString; + } + + public RSAKey getRsaKey() { + return rsaKey; + } + + @PostConstruct + private void generateRsaKey() throws Exception { + log.info("Generating RSA KEY......"); + KeyFactory kf = KeyFactory.getInstance("RSA"); + PKCS8EncodedKeySpec privateSpec = new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKeyString)); + RSAPrivateKey privateKey = (RSAPrivateKey) kf.generatePrivate(privateSpec); + + X509EncodedKeySpec keySpecX509 = new X509EncodedKeySpec(Base64.getDecoder().decode(publicKeyString)); + RSAPublicKey publicKey = (RSAPublicKey) kf.generatePublic(keySpecX509); + + this.rsaKey = new RSAKey.Builder(publicKey) + .privateKey(privateKey) + .keyID(UUID.randomUUID().toString()) + .build(); + } +} diff --git a/backend/src/main/java/org/springframework/samples/petclinic/security/SecurityConfig.java b/backend/src/main/java/org/springframework/samples/petclinic/security/SecurityConfig.java index 99b9c292..a9c0b3e9 100644 --- a/backend/src/main/java/org/springframework/samples/petclinic/security/SecurityConfig.java +++ b/backend/src/main/java/org/springframework/samples/petclinic/security/SecurityConfig.java @@ -1,21 +1,30 @@ package org.springframework.samples.petclinic.security; -import jakarta.servlet.http.HttpServletResponse; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; import org.springframework.samples.petclinic.auth.UserRepository; -import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -23,25 +32,27 @@ import java.util.Arrays; /** - * Adds JWT-based Authentication and Authorization to the server - *

- * Note that this is an example only. DO NOT IMPLEMENT OWN SECURITY CODE IN REAL PRODUCTION APPS !!!!!!!!!! + * Configures security for PetClinic. Ensures that all requests to /graphql are secured. * * @author Nils Hartmann (nils@nilshartmann.net) */ @Configuration @EnableMethodSecurity(prePostEnabled = true, securedEnabled = true) // Enable @PreAuthorize method-level security public class SecurityConfig { + private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class); - private static final Logger logger = LoggerFactory.getLogger(SecurityConfig.class); - - private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final RSAKey rsaKey; - public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, UserRepository userRepository) { - this.jwtAuthenticationFilter = jwtAuthenticationFilter; + public SecurityConfig(RSAKeyProvider RSAKeyProvider) { + this.rsaKey = RSAKeyProvider.getRsaKey(); } - private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class); + @Bean + public AuthenticationManager authManager(UserDetailsService userDetailsService) { + var authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailsService); + return new ProviderManager(authProvider); + } @Bean public UserDetailsService userDetailsService(UserRepository userRepository) { @@ -54,13 +65,6 @@ public UserDetailsService userDetailsService(UserRepository userRepository) { ); } - @Bean - public AuthenticationProvider daoAuthenticationProvider(UserDetailsService userDetailsService) { - DaoAuthenticationProvider p = new DaoAuthenticationProvider(); - p.setUserDetailsService(userDetailsService); - return p; - } - @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable(); @@ -72,24 +76,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // for h2 explorer http.headers().frameOptions().sameOrigin(); - // Exception Handling - http - .exceptionHandling() - .authenticationEntryPoint( - (request, response, ex) -> { - logger.error("Unauthorized request to '{}'- {}", - request.getRequestURL(), - ex.getMessage()); - response.sendError(HttpServletResponse.SC_UNAUTHORIZED); - } - ); http.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests .shouldFilterAllDispatcherTypes(false) // allow login .requestMatchers("/login/**").permitAll() - // allow access to graphiql +// // allow access to graphiql .requestMatchers("/").permitAll() .requestMatchers("/favicon.ico").permitAll() .requestMatchers("/s.html").permitAll() @@ -100,13 +93,27 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .anyRequest().authenticated() ); - - // Register JWT filter - http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt); return http.build(); } + @Bean + public JWKSource jwkSource(RSAKeyProvider RSAKeyProvider) { + JWKSet jwkSet = new JWKSet(rsaKey); + return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet); + } + + @Bean + JwtEncoder jwtEncoder(JWKSource jwks) { + return new NimbusJwtEncoder(jwks); + } + + @Bean + JwtDecoder jwtDecoder() throws JOSEException { + return NimbusJwtDecoder.withPublicKey(rsaKey.toRSAPublicKey()).build(); + } + @Bean public CorsConfigurationSource corsConfigurationSource(Environment env) { @@ -117,7 +124,7 @@ public CorsConfigurationSource corsConfigurationSource(Environment env) { Arrays.stream(allowedOrigins.split(",")) .forEach(origin -> { - logger.info("Allowing Cors for host '{}'", origin); + log.info("Allowing Cors for host '{}'", origin); config.addAllowedOrigin(origin); }); diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 40f79587..4e1b641e 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -28,6 +28,14 @@ logging.level.org.springframework=INFO # Spring Security #---------------------------------------------------------------- spring.security.filter.dispatcher-types=request,error +# Note that in real life: +# your would NEVER CHECK IN KEYS TO GIT (esp. no private keys) +# This is only to make the demo easier: +# - no need to generate the keys yourself +# - ability to provide stable, long living tokens for easier (live) demos +# NEVER check in your keys +privateKey=MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC6IqWZJBcNadJriNrR5VjouvTBENkNXEwc4IHimrKxmdUwG2RuT8gjq1kR7YN4//bY60tS35HQkILKL2c7ghfGuq4NMGT+jn4pb0Bum4O9/E5zw7rS0LsS9ZtIsiNmt/kS6HgbTHrFpruTCN0oUoYJb72MN+g43NvrFNdpehaRFXk8EU96HugCAHcMfBjBg5o7z3E9SSr864BgTClfb3EWMNKKFwClsu2NNgDLITrCGAzMxAguDhMrFUE/4wCfq0pu80C2XHq3w3nwhPghNKsR2MaBQzhgTjGqeLhfXYaiqKg2TBKesW1aJ02S45VBdvmfGQc6J0QnV6WZ/NgHxmclAgMBAAECggEATG8t0VUgokFyRvZL5SVo/SjImP9yIipklvcaXxNnjca95KNa+nGInh0e1l3SPz3c//afV8i2+A5fpucQXT9uxAykrIXS8zVQWXN14fW6g6m4DZGyhh9wsrhoq9F6+BNUFI+hwpQFVmTBYf+h0Y0RvY5FJ+9NTQxkwoQscQPRgIG8CXJQIuvSmCIvFgKG0mfSfm8vTNNLt/yjtZS8daSQH18jAixM8Qg0gL+1ycnVnqb7wUexAi+gIY1IIYn4rt2yZiS+8nm2NTEt9zXgJ29zfUD3En3S4q8uii+bkr610pU6uiLy4iBfvq48sK5JUwHhHEP/MgDyyYfXukgY94mNgQKBgQC/Ho3y4QOYoY2xp3eyiJw9Cm78wRLfXmTwBIQD2n+IuHU0FndoQvL7q1Vk52tDKZ1VffeO3MjDr6FtORlLNElZzHYmuRAJayJrcAv41GZmlRjljsZ5r1Q02Sh7cY+ZiCO3JchMcZcpRkjpIk6XVwXAu1lIBs6uDFTKIiFXkilE7wKBgQD5UvNxGGJVu0QhF9YpJKyRfXXvzhIkjHyPRaHszV/fMlztlUQhsf81Ece8+otbW6G7faS6XXDRTCSXnUsiKIGV4xsJVLspUbvQUxSgqEgTW64HuauSPoCsNwAgSwFJx+wBqQp/z8Xt8Qi/dyyjzR0EPwUxcg0MuL2/I4pZMgNdKwKBgQCpHFoyXZSXC+ybFDpACc1l3cOTdyxB5f6euwAtgLt0uPNMYczKvuLOei6XmIY66anEKjBRg19KeC/4u5t8BgoZoWeyn/JmwxuzwXN2fEsX3jondgx9Q/zYvoappDSSf/pKZu4zZheBeCWv1KGCHIiEn8JCj3N2YobsTQL/v98wlQKBgQDRyxUBs0z7aspXNmGE2VFEU1er47CshHJDdIpeYior+4rqd9GOsOJYW4/A2unsg9xBkeXM09ecpf+cfES+h2sTHrifT2b1y8rH70DKNw//CgQUiA1wz5siTl2SzspUtR9H/l/RUJnyMAH2amdrpaqm6giKpLeKXuF7NoKxrz3ZZQKBgEJGeKRrmJPH9guUPN81KxBqp/59FRW7vyMYMzBoGaW/8Vi3WHhcJpa6Mhv3JkMmqVVmVmQ6B5CuiD0dmaNlbZZkfoA452iZULG9Kml3s/JH1rBwI6nuRFi3rv9dh4vEtm2c7Ms9Mg93wUevXaQKLqQPqgndnMb1VT7SnqaFSpLi +publicKey=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuiKlmSQXDWnSa4ja0eVY6Lr0wRDZDVxMHOCB4pqysZnVMBtkbk/II6tZEe2DeP/22OtLUt+R0JCCyi9nO4IXxrquDTBk/o5+KW9AbpuDvfxOc8O60tC7EvWbSLIjZrf5Euh4G0x6xaa7kwjdKFKGCW+9jDfoONzb6xTXaXoWkRV5PBFPeh7oAgB3DHwYwYOaO89xPUkq/OuAYEwpX29xFjDSihcApbLtjTYAyyE6whgMzMQILg4TKxVBP+MAn6tKbvNAtlx6t8N58IT4ITSrEdjGgUM4YE4xqni4X12GoqioNkwSnrFtWidNkuOVQXb5nxkHOidEJ1elmfzYB8ZnJQIDAQAB #---------------------------------------------------------------- # spring-graphql config diff --git a/backend/src/main/resources/db/hsqldb/populateDB.sql b/backend/src/main/resources/db/hsqldb/populateDB.sql index cdb08b4e..358779b4 100644 --- a/backend/src/main/resources/db/hsqldb/populateDB.sql +++ b/backend/src/main/resources/db/hsqldb/populateDB.sql @@ -4,8 +4,8 @@ INSERT INTO users (USERNAME, PASSWORD, ENABLED, FULLNAME) VALUES ('joe', '{noop}joe', true, 'Joe Hill'); INSERT INTO users (USERNAME, PASSWORD, ENABLED, FULLNAME) VALUES ('susi', '{noop}susi', true, 'Susi Smith'); -INSERT INTO roles (ID, USERNAME, ROLE) VALUES (0, 'susi', 'ROLE_MANAGER'); -INSERT INTO roles (ID, USERNAME, ROLE) VALUES (1, 'joe', 'ROLE_USER'); +INSERT INTO roles (ID, USERNAME, ROLE) VALUES (0, 'susi', 'MANAGER'); +INSERT INTO roles (ID, USERNAME, ROLE) VALUES (1, 'joe', 'USER'); diff --git a/backend/src/test/java/org/springframework/samples/petclinic/graphql/AbstractClinicGraphqlTests.java b/backend/src/test/java/org/springframework/samples/petclinic/graphql/AbstractClinicGraphqlTests.java index a76d825c..cb6f61d9 100644 --- a/backend/src/test/java/org/springframework/samples/petclinic/graphql/AbstractClinicGraphqlTests.java +++ b/backend/src/test/java/org/springframework/samples/petclinic/graphql/AbstractClinicGraphqlTests.java @@ -7,14 +7,21 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.graphql.test.tester.WebGraphQlTester; import org.springframework.http.HttpHeaders; +import org.springframework.samples.petclinic.security.JwtTokenService; import org.springframework.test.context.ActiveProfiles; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + @SpringBootTest @ActiveProfiles(profiles = {"hsqldb"}) @AutoConfigureMockMvc @AutoConfigureHttpGraphQlTester public class AbstractClinicGraphqlTests { + @Autowired + private JwtTokenService tokenService; protected WebGraphQlTester managerRoleGraphQlTester; protected WebGraphQlTester userRoleGraphQlTester; protected WebGraphQlTester unauthorizedGraphqlTester; @@ -24,19 +31,21 @@ void setupWebGraphqlTester(@Autowired WebGraphQlTester graphQlTester) { this.unauthorizedGraphqlTester = graphQlTester; this.userRoleGraphQlTester = graphQlTester.mutate() - .headers(AbstractClinicGraphqlTests::withUserToken) + .headers(this::withUserToken) .build(); this.managerRoleGraphQlTester = graphQlTester.mutate() - .headers(AbstractClinicGraphqlTests::withManagerToken) + .headers(this::withManagerToken) .build(); } - private static void withManagerToken(HttpHeaders headers) { - headers.setBearerAuth("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzdXNpIiwiaWF0IjoxNjA4ODg5NDQwLCJleHAiOjIzNjYyNzE4NDB9.XG0SEtHiidGuy2A1zy_BfixVMFOv3gGbfwGqEc3F-KU"); + private void withManagerToken(HttpHeaders headers) { + var token = tokenService.generateToken("susi", List.of( () -> "MANAGER"), Instant.now().plus(1, ChronoUnit.HOURS)); + headers.setBearerAuth(token); } - private static void withUserToken(HttpHeaders headers) { - headers.setBearerAuth("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqb2UiLCJpYXQiOjE2MDg4ODk0NDAsImV4cCI6MjM2NjI3MTg0MH0.V36ynhDffqb9LQFsckOdk6lFhcVEDhOCFxFCQDAYG0o"); + private void withUserToken(HttpHeaders headers) { + var token = tokenService.generateToken("joe", List.of( () -> "USER"), Instant.now().plus(1, ChronoUnit.HOURS)); + headers.setBearerAuth(token); } } diff --git a/login.http b/login.http new file mode 100644 index 00000000..5e89defc --- /dev/null +++ b/login.http @@ -0,0 +1,14 @@ +### PING! +GET http://localhost:9977/ping +Authorization: Bearer eyJraWQiOiIxMTA4YzcxNS02MGIwLTQ4OGEtYjg4Yy04NGI1Njg1ZDU1MTEiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJzZWxmIiwic3ViIjoic3VzaSIsImV4cCI6MTk4NzYwMDQ0MywiaWF0IjoxNjcyMjQwNDQzLCJzY29wZSI6IlJPTEVfTUFOQUdFUiJ9.hNPB-kTUEzTWEeeOOQIbN3QUeB6jFX4NNesuDGNZ9KXt3T_dfEMIBsdOkwrcPqxtkXMzugwKfSx8VV_aqFznO2NlEzlKA7ngDbjWj0T4Ozr9q1E5aVieWzt9QvI7_AoG21e5upea8T7vUO7Dy64YLFcIPL6Gvhgw0zsSyxNxLbyqQBvwpwu5EzwTWmr9plAjGwIiRbnxkbMqK1mdHuh7QemG9am6-ocfPE5SaLXk1w6Y-wWhMQTDS2JtC__tFumLunUUMqm019lqrILQFRGWqRLZRKnRDP2QEyfrSbOdhacCIPY8KOn0Zk7bstgGCyG71soM0HPnpFXthsScZY4_Zg + +### LOGIN + +POST http://localhost:9977/login +Content-Type: application/json + +{ + "username": "joe", + "password": "joe" +} + diff --git a/readme.md b/readme.md index 129ea4da..6b05663d 100644 --- a/readme.md +++ b/readme.md @@ -3,7 +3,7 @@ This PetClinic version uses the new [spring-graphql](https://github.com/spring-projects/spring-graphql) project, that has been [introduced](https://spring.io/blog/2021/07/06/hello-spring-graphql) in july 2021 and has been [finally released as 1.0.0 GA version](https://spring.io/blog/2022/05/19/spring-for-graphql-1-0-release) in May 2022. -This version uses **Spring Boot 3.0** with **Spring for GraphQL 1.1**. +This version uses **Spring Boot 3.0.x** with **Spring for GraphQL 1.1**. It implements a [GraphQL API](http://graphql.org/) for the PetClinic and provides an example Frontend for the API.