feat(sprint-2): add security infrastructure

- Spring Security 6 with dual SecurityFilterChain (API stateless JWT + public Swagger)
- JwtService: generate/validate access + refresh tokens (JJWT 0.12.6)
- JwtAuthFilter: extract Bearer token, set SecurityContext + TenantContext
- GlobalExceptionHandler: RFC 9457 ProblemDetail responses
- Dependencies: spring-security, jjwt, springdoc-openapi, bean-validation, h2-test
- Application properties: JWT config + OpenAPI paths
This commit is contained in:
Patrick Plate
2026-06-11 10:46:48 +02:00
parent 10891e7b89
commit 86c922e1f9
6 changed files with 417 additions and 0 deletions
+46
View File
@@ -46,11 +46,57 @@
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Bean Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- JWT (JJWT) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<!-- OpenAPI / Swagger UI -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.6</version>
</dependency>
<!-- H2 for tests -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@@ -0,0 +1,83 @@
package de.cannamanage.api.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.net.URI;
import java.time.Instant;
/**
* Global exception handler producing application/problem+json responses.
* RFC 9457 compliant.
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BadCredentialsException.class)
public ProblemDetail handleBadCredentials(BadCredentialsException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.UNAUTHORIZED, "Invalid email or password");
problem.setTitle("Authentication Failed");
problem.setType(URI.create("urn:cannamanage:error:INVALID_CREDENTIALS"));
problem.setProperty("code", "INVALID_CREDENTIALS");
problem.setProperty("timestamp", Instant.now().toString());
return problem;
}
@ExceptionHandler(AccessDeniedException.class)
public ProblemDetail handleAccessDenied(AccessDeniedException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.FORBIDDEN, "Access denied");
problem.setTitle("Forbidden");
problem.setType(URI.create("urn:cannamanage:error:ACCESS_DENIED"));
problem.setProperty("code", "ACCESS_DENIED");
problem.setProperty("timestamp", Instant.now().toString());
return problem;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, "Validation failed");
problem.setTitle("Bad Request");
problem.setType(URI.create("urn:cannamanage:error:VALIDATION_FAILED"));
problem.setProperty("code", "VALIDATION_FAILED");
problem.setProperty("timestamp", Instant.now().toString());
var fieldErrors = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.toList();
problem.setProperty("errors", fieldErrors);
return problem;
}
@ExceptionHandler(IllegalArgumentException.class)
public ProblemDetail handleIllegalArgument(IllegalArgumentException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, ex.getMessage());
problem.setTitle("Bad Request");
problem.setType(URI.create("urn:cannamanage:error:BAD_REQUEST"));
problem.setProperty("code", "BAD_REQUEST");
problem.setProperty("timestamp", Instant.now().toString());
return problem;
}
@ExceptionHandler(Exception.class)
public ProblemDetail handleGeneric(Exception ex) {
log.error("Unhandled exception", ex);
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred");
problem.setTitle("Internal Server Error");
problem.setType(URI.create("urn:cannamanage:error:INTERNAL"));
problem.setProperty("code", "INTERNAL_ERROR");
problem.setProperty("timestamp", Instant.now().toString());
return problem;
}
}
@@ -0,0 +1,82 @@
package de.cannamanage.api.security;
import de.cannamanage.domain.entity.TenantContext;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
import java.util.UUID;
/**
* JWT authentication filter.
* Extracts Bearer token from Authorization header, validates it,
* sets SecurityContext and TenantContext for downstream processing.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtService jwtService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
final String token = authHeader.substring(7);
if (!jwtService.isTokenValid(token)) {
filterChain.doFilter(request, response);
return;
}
UUID userId = jwtService.extractUserId(token);
UUID tenantId = jwtService.extractTenantId(token);
String role = jwtService.extractRole(token);
// Set tenant context for schema routing
TenantContext.setCurrentTenant(tenantId);
// Build authentication with role-based authority
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role));
var authentication = new UsernamePasswordAuthenticationToken(
userId, null, authorities
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("Authenticated user {} for tenant {} with role {}", userId, tenantId, role);
try {
filterChain.doFilter(request, response);
} finally {
TenantContext.clear();
}
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getServletPath();
return path.startsWith("/api/v1/auth/")
|| path.startsWith("/swagger-ui")
|| path.startsWith("/v3/api-docs");
}
}
@@ -0,0 +1,114 @@
package de.cannamanage.api.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.time.Instant;
import java.util.Date;
import java.util.Map;
import java.util.UUID;
import java.util.function.Function;
/**
* JWT token generation and validation service.
* Access tokens: 1 hour expiry.
* Refresh tokens: 30 days expiry.
*/
@Service
public class JwtService {
@Value("${cannamanage.security.jwt.secret}")
private String secretKey;
@Value("${cannamanage.security.jwt.access-token-expiry:3600}")
private long accessTokenExpiry; // seconds
@Value("${cannamanage.security.jwt.refresh-token-expiry:2592000}")
private long refreshTokenExpiry; // seconds (30 days)
public String generateAccessToken(UUID userId, UUID tenantId, String role, String email) {
return buildToken(Map.of(
"tenant_id", tenantId.toString(),
"role", role,
"email", email
), userId.toString(), accessTokenExpiry);
}
public String generateRefreshToken(UUID userId, UUID tenantId) {
return buildToken(Map.of(
"tenant_id", tenantId.toString(),
"type", "refresh"
), userId.toString(), refreshTokenExpiry);
}
public String extractSubject(String token) {
return extractClaim(token, Claims::getSubject);
}
public UUID extractUserId(String token) {
return UUID.fromString(extractSubject(token));
}
public UUID extractTenantId(String token) {
return UUID.fromString(extractClaim(token, claims -> claims.get("tenant_id", String.class)));
}
public String extractRole(String token) {
return extractClaim(token, claims -> claims.get("role", String.class));
}
public String extractEmail(String token) {
return extractClaim(token, claims -> claims.get("email", String.class));
}
public boolean isTokenValid(String token) {
try {
extractAllClaims(token);
return !isTokenExpired(token);
} catch (Exception e) {
return false;
}
}
public boolean isTokenExpired(String token) {
return extractExpiration(token).before(Date.from(Instant.now()));
}
private Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
private <T> T extractClaim(String token, Function<Claims, T> resolver) {
final Claims claims = extractAllClaims(token);
return resolver.apply(claims);
}
private Claims extractAllClaims(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
private String buildToken(Map<String, Object> extraClaims, String subject, long expirySeconds) {
Instant now = Instant.now();
return Jwts.builder()
.claims(extraClaims)
.subject(subject)
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusSeconds(expirySeconds)))
.signWith(getSigningKey())
.compact();
}
private SecretKey getSigningKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
}
@@ -0,0 +1,81 @@
package de.cannamanage.api.security;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/**
* Dual SecurityFilterChain configuration:
* - /api/** → stateless JWT (Bearer token)
* - /portal/** → session-based (future Sprint 3)
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
/**
* API security — stateless JWT authentication.
* All /api/v1/** endpoints require authentication except /api/v1/auth/**.
*/
@Bean
@Order(1)
public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.requestMatchers("/api/v1/members/**").hasAnyRole("ADMIN", "STAFF")
.requestMatchers("/api/v1/distributions/**").hasAnyRole("ADMIN", "STAFF")
.requestMatchers("/api/v1/stock/**").hasAnyRole("ADMIN", "STAFF")
.requestMatchers("/api/v1/reports/**").hasRole("ADMIN")
.requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "STAFF")
.requestMatchers("/api/v1/me/**").authenticated()
.anyRequest().authenticated())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
/**
* Public endpoints — Swagger UI, actuator health.
*/
@Bean
@Order(2)
public SecurityFilterChain publicSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/swagger-ui/**", "/v3/api-docs/**", "/actuator/health")
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
@@ -2,3 +2,14 @@ spring.application.name=cannamanage
# Default profile — override with -Dspring.profiles.active=local
spring.jpa.hibernate.ddl-auto=validate
spring.flyway.enabled=false
# JWT Security
cannamanage.security.jwt.secret=Y2FubmFtYW5hZ2Utand0LXNlY3JldC1rZXktZm9yLWRldmVsb3BtZW50LW9ubHktMzI=
cannamanage.security.jwt.access-token-expiry=3600
cannamanage.security.jwt.refresh-token-expiry=2592000
# OpenAPI
springdoc.api-docs.path=/v3/api-docs
springdoc.swagger-ui.path=/swagger-ui.html
springdoc.swagger-ui.tags-sorter=alpha
springdoc.swagger-ui.operations-sorter=method