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

View File

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

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

View File

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

View File

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