From 86c922e1f932312d363fa7e9aa016f645f511701 Mon Sep 17 00:00:00 2001 From: Patrick Plate Date: Thu, 11 Jun 2026 10:46:48 +0200 Subject: [PATCH] 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 --- cannamanage-api/pom.xml | 46 +++++++ .../api/exception/GlobalExceptionHandler.java | 83 +++++++++++++ .../api/security/JwtAuthFilter.java | 82 +++++++++++++ .../cannamanage/api/security/JwtService.java | 114 ++++++++++++++++++ .../api/security/SecurityConfig.java | 81 +++++++++++++ .../src/main/resources/application.properties | 11 ++ 6 files changed, 417 insertions(+) create mode 100644 cannamanage-api/src/main/java/de/cannamanage/api/exception/GlobalExceptionHandler.java create mode 100644 cannamanage-api/src/main/java/de/cannamanage/api/security/JwtAuthFilter.java create mode 100644 cannamanage-api/src/main/java/de/cannamanage/api/security/JwtService.java create mode 100644 cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java diff --git a/cannamanage-api/pom.xml b/cannamanage-api/pom.xml index 33c8016..bfa4ee4 100644 --- a/cannamanage-api/pom.xml +++ b/cannamanage-api/pom.xml @@ -46,11 +46,57 @@ lombok true + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.boot + spring-boot-starter-validation + + + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.6 + + + + com.h2database + h2 + test + + org.springframework.boot spring-boot-starter-test test + + org.springframework.security + spring-security-test + test + diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/exception/GlobalExceptionHandler.java b/cannamanage-api/src/main/java/de/cannamanage/api/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..ada43eb --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/exception/GlobalExceptionHandler.java @@ -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; + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/security/JwtAuthFilter.java b/cannamanage-api/src/main/java/de/cannamanage/api/security/JwtAuthFilter.java new file mode 100644 index 0000000..9d92187 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/security/JwtAuthFilter.java @@ -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"); + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/security/JwtService.java b/cannamanage-api/src/main/java/de/cannamanage/api/security/JwtService.java new file mode 100644 index 0000000..c6b5be9 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/security/JwtService.java @@ -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 extractClaim(String token, Function 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 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); + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java b/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java new file mode 100644 index 0000000..6e48120 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java @@ -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(); + } +} diff --git a/cannamanage-api/src/main/resources/application.properties b/cannamanage-api/src/main/resources/application.properties index 490d972..a65b2db 100644 --- a/cannamanage-api/src/main/resources/application.properties +++ b/cannamanage-api/src/main/resources/application.properties @@ -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