Use cookies
All checks were successful
Gitea/swiss-backend/pipeline/head This commit looks good

This commit is contained in:
Michel ten Voorde
2025-10-27 14:17:17 +01:00
parent cca133d67c
commit e953ad6d53
6 changed files with 168 additions and 30 deletions

14
pom.xml
View File

@@ -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>

View File

@@ -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()
)
);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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

View File

@@ -37,3 +37,6 @@ jwt:
key: classpath:certs/private.pem
public:
key: classpath:certs/public.pem
cookie:
secure: true
samesite: None