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:
@@ -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>
|
||||
|
||||
+83
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user