This commit is contained in:
14
pom.xml
14
pom.xml
@@ -14,9 +14,9 @@
|
|||||||
<name>swiss-backend</name>
|
<name>swiss-backend</name>
|
||||||
<description>Swiss Backend</description>
|
<description>Swiss Backend</description>
|
||||||
<properties>
|
<properties>
|
||||||
<java.version>25</java.version>
|
<java.version>21</java.version>
|
||||||
<maven.compiler.source>25</maven.compiler.source>
|
<maven.compiler.source>21</maven.compiler.source>
|
||||||
<maven.compiler.target>25</maven.compiler.target>
|
<maven.compiler.target>21</maven.compiler.target>
|
||||||
</properties>
|
</properties>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -50,10 +50,6 @@
|
|||||||
<artifactId>h2</artifactId>
|
<artifactId>h2</artifactId>
|
||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-security</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
@@ -155,8 +151,8 @@
|
|||||||
<artifactId>maven-compiler-plugin</artifactId>
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
<version>3.14.1</version>
|
<version>3.14.1</version>
|
||||||
<configuration>
|
<configuration>
|
||||||
<source>25</source>
|
<source>21</source>
|
||||||
<target>25</target>
|
<target>21</target>
|
||||||
<annotationProcessorPaths>
|
<annotationProcessorPaths>
|
||||||
<path>
|
<path>
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
|
|||||||
@@ -1,36 +1,48 @@
|
|||||||
package nl.connectedit.swiss.auth;
|
package nl.connectedit.swiss.auth;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.GrantedAuthority;
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
|
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
|
||||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||||
import org.springframework.web.bind.annotation.CrossOrigin;
|
import org.springframework.web.bind.annotation.*;
|
||||||
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.time.Instant;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@CrossOrigin
|
|
||||||
@RequestMapping("/api/auth")
|
@RequestMapping("/api/auth")
|
||||||
public class AuthController {
|
public class AuthController {
|
||||||
|
|
||||||
private final JwtEncoder encoder;
|
private final JwtEncoder encoder;
|
||||||
|
|
||||||
|
@Value("${jwt.cookie.secure:true}")
|
||||||
|
private boolean secureCookie;
|
||||||
|
|
||||||
|
@Value("${jwt.cookie.samesite:false")
|
||||||
|
private String sameSite;
|
||||||
|
|
||||||
|
@Value("${jwt.cookie.domain:}")
|
||||||
|
private String cookieDomain;
|
||||||
|
|
||||||
public AuthController(JwtEncoder encoder) {
|
public AuthController(JwtEncoder encoder) {
|
||||||
this.encoder = encoder;
|
this.encoder = encoder;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public String auth(Authentication authentication) {
|
public void auth(Authentication authentication, HttpServletResponse response) {
|
||||||
Instant now = Instant.now();
|
Instant now = Instant.now();
|
||||||
long expiry = 36000L;
|
long expiry = 36000L;
|
||||||
|
|
||||||
String scope = authentication.getAuthorities().stream()
|
String scope = authentication.getAuthorities().stream()
|
||||||
.map(GrantedAuthority::getAuthority)
|
.map(GrantedAuthority::getAuthority)
|
||||||
.collect(Collectors.joining(","));
|
.collect(Collectors.joining(","));
|
||||||
|
|
||||||
JwtClaimsSet claims = JwtClaimsSet.builder()
|
JwtClaimsSet claims = JwtClaimsSet.builder()
|
||||||
.issuer("self")
|
.issuer("self")
|
||||||
.issuedAt(now)
|
.issuedAt(now)
|
||||||
@@ -38,7 +50,67 @@ public class AuthController {
|
|||||||
.subject(authentication.getName())
|
.subject(authentication.getName())
|
||||||
.claim("scope", scope)
|
.claim("scope", scope)
|
||||||
.build();
|
.build();
|
||||||
return this.encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
|
|
||||||
|
String token = this.encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
|
||||||
|
|
||||||
|
// Create HttpOnly cookie
|
||||||
|
Cookie cookie = new Cookie("AUTH-TOKEN", token);
|
||||||
|
cookie.setHttpOnly(true);
|
||||||
|
cookie.setSecure(secureCookie); // true for production (HTTPS), false for local dev
|
||||||
|
cookie.setPath("/");
|
||||||
|
cookie.setMaxAge((int) expiry); // 10 hours in seconds
|
||||||
|
|
||||||
|
// String sameSite = "None"; // required for cross-origin
|
||||||
|
boolean secure = secureCookie; // true in production, false for local dev
|
||||||
|
|
||||||
|
response.setHeader("Set-Cookie", String.format(
|
||||||
|
"%s=%s; Path=%s; Max-Age=%d; HttpOnly; %s SameSite=%s%s",
|
||||||
|
cookie.getName(),
|
||||||
|
cookie.getValue(),
|
||||||
|
cookie.getPath(),
|
||||||
|
cookie.getMaxAge(),
|
||||||
|
secure ? "Secure;" : "",
|
||||||
|
sameSite,
|
||||||
|
cookieDomain.isEmpty() ? "" : "; Domain=" + cookieDomain
|
||||||
|
));
|
||||||
|
|
||||||
|
response.setStatus(HttpServletResponse.SC_OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/logout")
|
||||||
|
public void logout(HttpServletResponse response) {
|
||||||
|
// Clear the cookie by setting Max-Age to 0
|
||||||
|
Cookie cookie = new Cookie("AUTH-TOKEN", "");
|
||||||
|
cookie.setHttpOnly(true);
|
||||||
|
cookie.setSecure(secureCookie);
|
||||||
|
cookie.setPath("/");
|
||||||
|
cookie.setMaxAge(0);
|
||||||
|
|
||||||
|
response.setHeader("Set-Cookie", String.format(
|
||||||
|
"%s=%s; Path=%s; Max-Age=%d; HttpOnly; %s SameSite=Lax",
|
||||||
|
cookie.getName(),
|
||||||
|
cookie.getValue(),
|
||||||
|
cookie.getPath(),
|
||||||
|
cookie.getMaxAge(),
|
||||||
|
secureCookie ? "Secure;" : ""
|
||||||
|
));
|
||||||
|
|
||||||
|
response.setStatus(HttpServletResponse.SC_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/me")
|
||||||
|
public ResponseEntity<?> me(Authentication authentication) {
|
||||||
|
if (authentication == null) {
|
||||||
|
return ResponseEntity.status(HttpServletResponse.SC_UNAUTHORIZED).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
Map.of(
|
||||||
|
"username", authentication.getName(),
|
||||||
|
"roles", authentication.getAuthorities().stream()
|
||||||
|
.map(GrantedAuthority::getAuthority)
|
||||||
|
.toList()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package nl.connectedit.swiss.config;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||||
|
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
public class CookieJwtAuthenticationFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private final JwtDecoder jwtDecoder;
|
||||||
|
private final JwtAuthenticationConverter jwtAuthenticationConverter;
|
||||||
|
|
||||||
|
public CookieJwtAuthenticationFilter(JwtDecoder jwtDecoder,
|
||||||
|
JwtAuthenticationConverter jwtAuthenticationConverter) {
|
||||||
|
this.jwtDecoder = jwtDecoder;
|
||||||
|
this.jwtAuthenticationConverter = jwtAuthenticationConverter;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
|
||||||
|
// Extract token from cookies only
|
||||||
|
String token = null;
|
||||||
|
Cookie[] cookies = request.getCookies();
|
||||||
|
|
||||||
|
if (cookies != null) {
|
||||||
|
for (Cookie cookie : cookies) {
|
||||||
|
if ("AUTH-TOKEN".equals(cookie.getName())) {
|
||||||
|
token = cookie.getValue();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||||
|
try {
|
||||||
|
Jwt jwt = jwtDecoder.decode(token);
|
||||||
|
var authentication = jwtAuthenticationConverter.convert(jwt);
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Invalid token, continue without authentication
|
||||||
|
logger.debug("JWT validation failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,6 @@ import com.nimbusds.jose.jwk.RSAKey;
|
|||||||
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
|
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
|
||||||
import com.nimbusds.jose.jwk.source.JWKSource;
|
import com.nimbusds.jose.jwk.source.JWKSource;
|
||||||
import com.nimbusds.jose.proc.SecurityContext;
|
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.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@@ -15,8 +13,6 @@ import org.springframework.http.HttpMethod;
|
|||||||
import org.springframework.security.config.Customizer;
|
import org.springframework.security.config.Customizer;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
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.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.config.http.SessionCreationPolicy;
|
||||||
import org.springframework.security.core.userdetails.User;
|
import org.springframework.security.core.userdetails.User;
|
||||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
@@ -30,6 +26,7 @@ import org.springframework.security.oauth2.server.resource.authentication.JwtAut
|
|||||||
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
|
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.BearerTokenAuthenticationEntryPoint;
|
||||||
import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler;
|
import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler;
|
||||||
|
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
|
||||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.web.cors.CorsConfiguration;
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
@@ -40,6 +37,8 @@ import java.security.interfaces.RSAPrivateKey;
|
|||||||
import java.security.interfaces.RSAPublicKey;
|
import java.security.interfaces.RSAPublicKey;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import nl.connectedit.swiss.auth.Roles;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
@@ -57,16 +56,20 @@ public class SecurityConfig {
|
|||||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
.csrf(csrf -> csrf.ignoringRequestMatchers("/api/auth"))
|
.csrf(csrf -> csrf.ignoringRequestMatchers("/api/auth", "/api/auth/logout", "/api/auth/me"))
|
||||||
.authorizeHttpRequests(authorize -> authorize
|
.authorizeHttpRequests(authorize -> authorize
|
||||||
// Allow OPTIONS requests for CORS preflight
|
|
||||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||||
// Allow POST to /api/auth without JWT (Basic Auth will still be required)
|
|
||||||
.requestMatchers(HttpMethod.POST, "/api/auth").permitAll()
|
.requestMatchers(HttpMethod.POST, "/api/auth").permitAll()
|
||||||
// All other requests require authentication
|
.requestMatchers(HttpMethod.POST, "/api/auth/logout").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/auth/me").authenticated()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
.httpBasic(Customizer.withDefaults())
|
.httpBasic(Customizer.withDefaults())
|
||||||
|
// Add cookie-based JWT filter before the Bearer token filter
|
||||||
|
.addFilterBefore(
|
||||||
|
new CookieJwtAuthenticationFilter(jwtDecoder(), jwtAuthenticationConverter()),
|
||||||
|
BearerTokenAuthenticationFilter.class
|
||||||
|
)
|
||||||
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
|
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
|
||||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.exceptionHandling(exceptions -> exceptions
|
.exceptionHandling(exceptions -> exceptions
|
||||||
@@ -80,11 +83,9 @@ public class SecurityConfig {
|
|||||||
public CorsConfigurationSource corsConfigurationSource() {
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
CorsConfiguration configuration = new CorsConfiguration();
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
|
|
||||||
// Allow your Angular app's origin(s)
|
|
||||||
configuration.setAllowedOrigins(Arrays.asList(
|
configuration.setAllowedOrigins(Arrays.asList(
|
||||||
"http://localhost:4200",
|
"http://localhost:4200",
|
||||||
"http://localhost:8080",
|
"http://localhost:8080",
|
||||||
// Add your production URL here when deploying
|
|
||||||
"https://badminton-toernooi.nl",
|
"https://badminton-toernooi.nl",
|
||||||
"https://test.badminton-toernooi.nl"
|
"https://test.badminton-toernooi.nl"
|
||||||
));
|
));
|
||||||
@@ -99,6 +100,7 @@ public class SecurityConfig {
|
|||||||
"Accept"
|
"Accept"
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// CRITICAL: Must be true for cookies to work cross-origin
|
||||||
configuration.setAllowCredentials(true);
|
configuration.setAllowCredentials(true);
|
||||||
configuration.setMaxAge(3600L);
|
configuration.setMaxAge(3600L);
|
||||||
|
|
||||||
@@ -137,12 +139,10 @@ public class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public JwtAuthenticationConverter jwtAuthenticationConverter() {
|
public JwtAuthenticationConverter jwtAuthenticationConverter() {
|
||||||
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
|
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
|
||||||
// Remove the SCOPE_ prefix
|
|
||||||
grantedAuthoritiesConverter.setAuthorityPrefix("");
|
grantedAuthoritiesConverter.setAuthorityPrefix("");
|
||||||
|
|
||||||
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
|
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
|
||||||
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
|
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
|
||||||
return jwtAuthenticationConverter;
|
return jwtAuthenticationConverter;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
|
||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: swiss
|
name: swiss
|
||||||
|
|
||||||
# autoconfigure:
|
# autoconfigure:
|
||||||
# exclude:
|
# exclude:
|
||||||
# - org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
|
# - org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
|
||||||
@@ -37,3 +39,10 @@ security: true
|
|||||||
# descriptor:
|
# descriptor:
|
||||||
# sql:
|
# sql:
|
||||||
# BasicBinder: TRACE
|
# BasicBinder: TRACE
|
||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
jwt:
|
||||||
|
cookie:
|
||||||
|
secure: false
|
||||||
|
samesite: Lax
|
||||||
@@ -37,3 +37,6 @@ jwt:
|
|||||||
key: classpath:certs/private.pem
|
key: classpath:certs/private.pem
|
||||||
public:
|
public:
|
||||||
key: classpath:certs/public.pem
|
key: classpath:certs/public.pem
|
||||||
|
cookie:
|
||||||
|
secure: true
|
||||||
|
samesite: None
|
||||||
Reference in New Issue
Block a user