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>
|
<artifactId>lombok</artifactId>
|
||||||
<optional>true</optional>
|
<optional>true</optional>
|
||||||
</dependency>
|
</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>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.security</groupId>
|
||||||
|
<artifactId>spring-security-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<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
|
# Default profile — override with -Dspring.profiles.active=local
|
||||||
spring.jpa.hibernate.ddl-auto=validate
|
spring.jpa.hibernate.ddl-auto=validate
|
||||||
spring.flyway.enabled=false
|
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