This commit is contained in:
14
pom.xml
14
pom.xml
@@ -14,9 +14,9 @@
|
||||
<name>swiss-backend</name>
|
||||
<description>Swiss Backend</description>
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
<maven.compiler.source>25</maven.compiler.source>
|
||||
<maven.compiler.target>25</maven.compiler.target>
|
||||
<java.version>21</java.version>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
@@ -50,10 +50,6 @@
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
@@ -155,8 +151,8 @@
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.14.1</version>
|
||||
<configuration>
|
||||
<source>25</source>
|
||||
<target>25</target>
|
||||
<source>21</source>
|
||||
<target>21</target>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
|
||||
@@ -1,36 +1,48 @@
|
||||
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.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 org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@CrossOrigin
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
|
||||
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) {
|
||||
this.encoder = encoder;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public String auth(Authentication authentication) {
|
||||
public void auth(Authentication authentication, HttpServletResponse response) {
|
||||
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)
|
||||
@@ -38,7 +50,67 @@ public class AuthController {
|
||||
.subject(authentication.getName())
|
||||
.claim("scope", scope)
|
||||
.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.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;
|
||||
@@ -15,8 +13,6 @@ 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;
|
||||
@@ -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.web.BearerTokenAuthenticationEntryPoint;
|
||||
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.web.SecurityFilterChain;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
@@ -40,6 +37,8 @@ import java.security.interfaces.RSAPrivateKey;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.util.Arrays;
|
||||
|
||||
import nl.connectedit.swiss.auth.Roles;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
@@ -57,16 +56,20 @@ public class SecurityConfig {
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.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
|
||||
// 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
|
||||
.requestMatchers(HttpMethod.POST, "/api/auth/logout").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/auth/me").authenticated()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.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()))
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.exceptionHandling(exceptions -> exceptions
|
||||
@@ -80,11 +83,9 @@ public class SecurityConfig {
|
||||
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"
|
||||
));
|
||||
@@ -99,6 +100,7 @@ public class SecurityConfig {
|
||||
"Accept"
|
||||
));
|
||||
|
||||
// CRITICAL: Must be true for cookies to work cross-origin
|
||||
configuration.setAllowCredentials(true);
|
||||
configuration.setMaxAge(3600L);
|
||||
|
||||
@@ -137,12 +139,10 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public JwtAuthenticationConverter jwtAuthenticationConverter() {
|
||||
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
|
||||
// Remove the SCOPE_ prefix
|
||||
grantedAuthoritiesConverter.setAuthorityPrefix("");
|
||||
|
||||
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
|
||||
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
|
||||
return jwtAuthenticationConverter;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: swiss
|
||||
|
||||
# autoconfigure:
|
||||
# exclude:
|
||||
# - org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
|
||||
@@ -37,3 +39,10 @@ security: true
|
||||
# descriptor:
|
||||
# sql:
|
||||
# BasicBinder: TRACE
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
jwt:
|
||||
cookie:
|
||||
secure: false
|
||||
samesite: Lax
|
||||
@@ -37,3 +37,6 @@ jwt:
|
||||
key: classpath:certs/private.pem
|
||||
public:
|
||||
key: classpath:certs/public.pem
|
||||
cookie:
|
||||
secure: true
|
||||
samesite: None
|
||||
Reference in New Issue
Block a user