diff --git a/pom.xml b/pom.xml index 9fdc693..5e5cfe7 100755 --- a/pom.xml +++ b/pom.xml @@ -31,6 +31,10 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + com.auth0 java-jwt diff --git a/src/main/java/nl/connectedit/swiss/auth/AuthController.java b/src/main/java/nl/connectedit/swiss/auth/AuthController.java new file mode 100644 index 0000000..8e21159 --- /dev/null +++ b/src/main/java/nl/connectedit/swiss/auth/AuthController.java @@ -0,0 +1,44 @@ +package nl.connectedit.swiss.auth; + +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.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Instant; +import java.util.stream.Collectors; + +@RestController +@CrossOrigin +@RequestMapping("/api/auth") +public class AuthController { + + private final JwtEncoder encoder; + + public AuthController(JwtEncoder encoder) { + this.encoder = encoder; + } + + @PostMapping + public String auth(Authentication authentication) { + Instant now = Instant.now(); + long expiry = 36000L; + String scope = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + JwtClaimsSet claims = JwtClaimsSet.builder() + .issuer("self") + .issuedAt(now) + .expiresAt(now.plusSeconds(expiry)) + .subject(authentication.getName()) + .claim("scope", scope) + .build(); + return this.encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue(); + } + +} diff --git a/src/main/java/nl/connectedit/swiss/auth/Roles.java b/src/main/java/nl/connectedit/swiss/auth/Roles.java new file mode 100644 index 0000000..112d941 --- /dev/null +++ b/src/main/java/nl/connectedit/swiss/auth/Roles.java @@ -0,0 +1,9 @@ +package nl.connectedit.swiss.auth; + +public final class Roles { + + public static final String USER = "USER"; + + private Roles() { + } +} \ No newline at end of file diff --git a/src/main/java/nl/connectedit/swiss/config/SecurityConfig.java b/src/main/java/nl/connectedit/swiss/config/SecurityConfig.java index 846858c..1ebb3c7 100644 --- a/src/main/java/nl/connectedit/swiss/config/SecurityConfig.java +++ b/src/main/java/nl/connectedit/swiss/config/SecurityConfig.java @@ -1,26 +1,148 @@ package nl.connectedit.swiss.config; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; import jakarta.servlet.DispatcherType; +import nl.connectedit.swiss.auth.Roles; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +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.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +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.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; +import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; +import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -//@Configuration -//@EnableWebSecurity +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.util.Arrays; + +@Configuration +@EnableWebSecurity public class SecurityConfig { -// @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + private final RSAPublicKey key; + private final RSAPrivateKey priv; + + public SecurityConfig(@Value("${jwt.public.key}") RSAPublicKey key, + @Value("${jwt.private.key}") RSAPrivateKey priv) { + this.key = key; + this.priv = priv; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http - .csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); -// .authorizeHttpRequests(request -> { -// request.dispatcherTypeMatchers(DispatcherType.FORWARD).permitAll(); -// request.requestMatchers("/error").permitAll(); -// }); + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(csrf -> csrf.ignoringRequestMatchers("/api/auth")) + .authorizeHttpRequests(authorize -> authorize + // Allow OPTIONS requests for CORS preflight + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + // Allow POST to /api/auth without JWT (Basic Auth will still be required) + .requestMatchers(HttpMethod.POST, "/api/auth").permitAll() + // All other requests require authentication + .anyRequest().authenticated() + ) + .httpBasic(Customizer.withDefaults()) + .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(exceptions -> exceptions + .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint()) + .accessDeniedHandler(new BearerTokenAccessDeniedHandler()) + ); return http.build(); } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + // Allow your Angular app's origin(s) + configuration.setAllowedOrigins(Arrays.asList( + "http://localhost:4200", + "http://localhost:8080", + // Add your production URL here when deploying + "https://badminton-toernooi.nl", + "https://test.badminton-toernooi.nl" + )); + + configuration.setAllowedMethods(Arrays.asList( + "GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH" + )); + + configuration.setAllowedHeaders(Arrays.asList( + "Authorization", + "Content-Type", + "Accept" + )); + + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + @Bean + UserDetailsService users() { + return new InMemoryUserDetailsManager( + User.withUsername("bcholten") + .password(passwordEncoder().encode("bcholten")) + .roles(Roles.USER) + .build() + ); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + JwtDecoder jwtDecoder() { + return NimbusJwtDecoder.withPublicKey(this.key).build(); + } + + @Bean + JwtEncoder jwtEncoder() { + JWK jwk = new RSAKey.Builder(this.key).privateKey(this.priv).build(); + JWKSource jwks = new ImmutableJWKSet<>(new JWKSet(jwk)); + return new NimbusJwtEncoder(jwks); + } + + @Bean + public JwtAuthenticationConverter jwtAuthenticationConverter() { + JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); + // Remove the SCOPE_ prefix + grantedAuthoritiesConverter.setAuthorityPrefix(""); + + JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); + jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter); + return jwtAuthenticationConverter; + } + } diff --git a/src/main/java/nl/connectedit/swiss/controller/PlayerController.java b/src/main/java/nl/connectedit/swiss/controller/PlayerController.java index 06ee06f..6f44cb1 100644 --- a/src/main/java/nl/connectedit/swiss/controller/PlayerController.java +++ b/src/main/java/nl/connectedit/swiss/controller/PlayerController.java @@ -1,6 +1,8 @@ package nl.connectedit.swiss.controller; +import jakarta.annotation.security.RolesAllowed; import lombok.RequiredArgsConstructor; +import nl.connectedit.swiss.auth.Roles; import nl.connectedit.swiss.domain.entity.Player; import nl.connectedit.swiss.dto.PlayerDto; import nl.connectedit.swiss.mapper.PlayerMapper; @@ -12,6 +14,7 @@ import java.util.List; @RestController @CrossOrigin +@RolesAllowed(Roles.USER) @RequiredArgsConstructor public class PlayerController { diff --git a/src/main/java/nl/connectedit/swiss/controller/RegistrationController.java b/src/main/java/nl/connectedit/swiss/controller/RegistrationController.java index 3c757e9..4d00778 100644 --- a/src/main/java/nl/connectedit/swiss/controller/RegistrationController.java +++ b/src/main/java/nl/connectedit/swiss/controller/RegistrationController.java @@ -1,6 +1,8 @@ package nl.connectedit.swiss.controller; +import jakarta.annotation.security.RolesAllowed; import lombok.RequiredArgsConstructor; +import nl.connectedit.swiss.auth.Roles; import nl.connectedit.swiss.domain.TournamentStatus; import nl.connectedit.swiss.dto.TournamentRegistrationDto; import nl.connectedit.swiss.mapper.TournamentPlayerRegistrationMapper; @@ -20,6 +22,7 @@ import java.util.List; @RestController @CrossOrigin +@RolesAllowed(Roles.USER) @RequiredArgsConstructor public class RegistrationController { diff --git a/src/main/java/nl/connectedit/swiss/controller/TestController.java b/src/main/java/nl/connectedit/swiss/controller/TestController.java index b253528..74cbfdf 100644 --- a/src/main/java/nl/connectedit/swiss/controller/TestController.java +++ b/src/main/java/nl/connectedit/swiss/controller/TestController.java @@ -3,10 +3,12 @@ package nl.connectedit.swiss.controller; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; +import jakarta.annotation.security.RolesAllowed; import jakarta.servlet.http.HttpServletRequest; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; +import nl.connectedit.swiss.auth.Roles; import nl.connectedit.swiss.domain.*; import nl.connectedit.swiss.domain.entity.Player; import nl.connectedit.swiss.domain.entity.Registration; @@ -39,6 +41,7 @@ import static nl.connectedit.swiss.domain.PlayerStrength.*; @RestController @CrossOrigin +@RolesAllowed(Roles.USER) @RequiredArgsConstructor public class TestController { diff --git a/src/main/java/nl/connectedit/swiss/controller/TournamentController.java b/src/main/java/nl/connectedit/swiss/controller/TournamentController.java index 3453d7e..76bb435 100755 --- a/src/main/java/nl/connectedit/swiss/controller/TournamentController.java +++ b/src/main/java/nl/connectedit/swiss/controller/TournamentController.java @@ -1,7 +1,9 @@ package nl.connectedit.swiss.controller; +import jakarta.annotation.security.RolesAllowed; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; +import nl.connectedit.swiss.auth.Roles; import nl.connectedit.swiss.domain.TournamentStatus; import nl.connectedit.swiss.domain.entity.Tournament; import nl.connectedit.swiss.dto.ResultDto; @@ -20,6 +22,7 @@ import java.util.Objects; @RestController @CrossOrigin +@RolesAllowed(Roles.USER) @RequiredArgsConstructor public class TournamentController { diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 40dbcee..83d09de 100755 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -30,4 +30,10 @@ management: probes: enabled: true -security: true \ No newline at end of file +security: true + +jwt: + private: + key: classpath:certs/private.pem + public: + key: classpath:certs/public.pem