Compare commits
No commits in common. "forge/codex-bais-final3-20260713-192718-BAIS-SPRING-ANA-AUTH-001-attempt-1-run-f0808bb53869" and "main" have entirely different histories.
forge/code
...
main
9 changed files with 0 additions and 617 deletions
|
|
@ -1,3 +0,0 @@
|
||||||
# codex-bais-final3-20260713-192718-BAIS-SPRING-ANA-AUTH-001-attempt-1-run-f0808bb53869
|
|
||||||
|
|
||||||
Forge 이슈 작업 브랜치 `forge/codex-bais-final3-20260713-192718-BAIS-SPRING-ANA-AUTH-001-attempt-1-run-f0808bb53869`.
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
# Authentication & Authorization Transition Invariants
|
|
||||||
|
|
||||||
## 1. Authentication Rules
|
|
||||||
|
|
||||||
### 1.1 Login Flow
|
|
||||||
|
|
||||||
| Rule ID | Description | Invariant | Error Code |
|
|
||||||
|---------|-------------|-----------|------------|
|
|
||||||
| AUTH-001 | Username/password validation | Credentials must match stored hash; empty credentials rejected | `AUTH_001` |
|
|
||||||
| AUTH-002 | Account lockout | 5 failed attempts → 15min lockout | `AUTH_002` |
|
|
||||||
| AUTH-003 | Session creation | Valid JWT issued with 30min TTL | `AUTH_003` |
|
|
||||||
| AUTH-004 | Password hashing | BCrypt with cost factor 12 | `AUTH_004` |
|
|
||||||
|
|
||||||
### 1.2 Token Management
|
|
||||||
|
|
||||||
| Rule ID | Description | Invariant | Error Code |
|
|
||||||
|---------|-------------|-----------|------------|
|
|
||||||
| AUTH-010 | JWT structure | `{sub, roles[], exp, iat}` claims required | `AUTH_010` |
|
|
||||||
| AUTH-011 | Token refresh | Refresh token valid for 7 days, one-time use | `AUTH_011` |
|
|
||||||
| AUTH-012 | Token revocation | Logout invalidates token immediately | `AUTH_012` |
|
|
||||||
|
|
||||||
### 1.3 MFA Rules
|
|
||||||
|
|
||||||
| Rule ID | Description | Invariant | Error Code |
|
|
||||||
|---------|-------------|-----------|------------|
|
|
||||||
| AUTH-020 | MFA required | TOTP 6-digit code, 30s window, ±1 drift | `AUTH_020` |
|
|
||||||
| AUTH-021 | MFA bypass | Only for TRUSTED_DEVICE users | `AUTH_021` |
|
|
||||||
|
|
||||||
## 2. Authorization Rules
|
|
||||||
|
|
||||||
### 2.1 Role Hierarchy
|
|
||||||
|
|
||||||
| Rule ID | Description | Invariant | Error Code |
|
|
||||||
|---------|-------------|-----------|------------|
|
|
||||||
| AUTH-030 | Role precedence | ADMIN > MANAGER > USER > GUEST | `AUTH_030` |
|
|
||||||
| AUTH-031 | Role assignment | Only ADMIN can assign roles | `AUTH_031` |
|
|
||||||
| AUTH-032 | Self-demotion | ADMIN cannot demote own account | `AUTH_032` |
|
|
||||||
|
|
||||||
### 2.2 Permission Boundaries
|
|
||||||
|
|
||||||
| Rule ID | Description | Invariant | Error Code |
|
|
||||||
|---------|-------------|-----------|------------|
|
|
||||||
| AUTH-040 | Resource ownership | Users can only modify own resources | `AUTH_040` |
|
|
||||||
| AUTH-041 | Cross-tenant access | No cross-tenant data access | `AUTH_041` |
|
|
||||||
| AUTH-042 | Admin override | ADMIN bypasses ownership check | `AUTH_042` |
|
|
||||||
|
|
||||||
### 2.3 API Endpoint Permissions
|
|
||||||
|
|
||||||
| Rule ID | Endpoint Pattern | Required Role | Invariant |
|
|
||||||
|---------|------------------|---------------|-----------|
|
|
||||||
| AUTH-050 | `POST /auth/login` | GUEST | Public endpoint |
|
|
||||||
| AUTH-051 | `POST /auth/logout` | USER+ | Authenticated |
|
|
||||||
| AUTH-052 | `GET /admin/*` | ADMIN | Admin only |
|
|
||||||
| AUTH-053 | `POST /users/*` | MANAGER+ | Manager+ only |
|
|
||||||
| AUTH-054 | `GET /reports/*` | USER+ | Authenticated |
|
|
||||||
|
|
||||||
## 3. Error Response Format
|
|
||||||
|
|
||||||
### 3.1 Standard Error Structure
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": "AUTH_XXX",
|
|
||||||
"message": "Human-readable message",
|
|
||||||
"timestamp": "ISO-8601",
|
|
||||||
"path": "/original/request/path"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 HTTP Status Mapping
|
|
||||||
|
|
||||||
| HTTP Status | Condition |
|
|
||||||
|-------------|-----------|
|
|
||||||
| 400 | Invalid request format |
|
|
||||||
| 401 | Missing/invalid credentials |
|
|
||||||
| 403 | Insufficient permissions |
|
|
||||||
| 404 | Resource not found |
|
|
||||||
| 423 | Account locked |
|
|
||||||
| 429 | Rate limit exceeded |
|
|
||||||
| 500 | Internal server error |
|
|
||||||
|
|
||||||
## 4. Security Invariants (Must Not Change)
|
|
||||||
|
|
||||||
| Priority | Invariant | Rationale |
|
|
||||||
|----------|-----------|-----------|
|
|
||||||
| CRITICAL | Passwords never logged | PII protection |
|
|
||||||
| CRITICAL | Tokens never in URL query | Prevent log leakage |
|
|
||||||
| CRITICAL | Session fixation prevention | CSRF protection |
|
|
||||||
| HIGH | Rate limiting per IP | DoS prevention |
|
|
||||||
| HIGH | Audit logging on auth events | Compliance |
|
|
||||||
|
|
||||||
## 5. Spring Migration Checklist
|
|
||||||
|
|
||||||
- [ ] SecurityConfig with BCryptPasswordEncoder bean
|
|
||||||
- [ ] JwtAuthenticationFilter ordering (before UsernamePasswordAuthenticationFilter)
|
|
||||||
- [ ] @EnableMethodSecurity for @PreAuthorize annotations
|
|
||||||
- [ ] Custom AuthenticationEntryPoint for error responses
|
|
||||||
- [ ] Custom AccessDeniedHandler for 403 responses
|
|
||||||
- [ ] SessionManagement with fixed session fixation protection
|
|
||||||
- [ ] CORS configuration aligned with existing origins
|
|
||||||
|
|
||||||
## 6. Implementation Reference
|
|
||||||
|
|
||||||
### 6.1 SecurityConfig Bean Configuration
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Bean
|
|
||||||
public PasswordEncoder passwordEncoder() {
|
|
||||||
return new BCryptPasswordEncoder(12); // AUTH-004
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
|
||||||
return http
|
|
||||||
.csrf(csrf -> csrf
|
|
||||||
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
|
||||||
.ignoringRequestMatchers("/auth/login", "/auth/refresh")
|
|
||||||
)
|
|
||||||
.sessionManagement(s -> s
|
|
||||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
|
||||||
.sessionFixation().migrateSession()
|
|
||||||
)
|
|
||||||
.authorizeHttpRequests(auth -> auth
|
|
||||||
.requestMatchers("/auth/login", "/auth/refresh").permitAll()
|
|
||||||
.requestMatchers("/admin/**").hasRole("ADMIN")
|
|
||||||
.requestMatchers("/users/**").hasAnyRole("MANAGER", "ADMIN")
|
|
||||||
.requestMatchers("/reports/**").authenticated()
|
|
||||||
.anyRequest().authenticated()
|
|
||||||
)
|
|
||||||
.exceptionHandling(ex -> ex
|
|
||||||
.authenticationEntryPoint(new CustomAuthEntryPoint())
|
|
||||||
.accessDeniedHandler(new CustomAccessDeniedHandler())
|
|
||||||
)
|
|
||||||
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 JWT Claims Structure
|
|
||||||
|
|
||||||
```java
|
|
||||||
public record JwtClaims(
|
|
||||||
String subject, // username
|
|
||||||
List<String> roles, // ["ADMIN", "USER"]
|
|
||||||
Date issuedAt,
|
|
||||||
Date expiration // 30min for access, 7days for refresh
|
|
||||||
) {}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.3 Lockout Logic
|
|
||||||
|
|
||||||
```java
|
|
||||||
private static final int MAX_FAILED_ATTEMPTS = 5;
|
|
||||||
private static final long LOCKOUT_DURATION_MS = 15 * 60 * 1000;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.4 Role Hierarchy Levels
|
|
||||||
|
|
||||||
```java
|
|
||||||
private static final List<String> ROLE_HIERARCHY =
|
|
||||||
List.of("ADMIN", "MANAGER", "USER", "GUEST");
|
|
||||||
// Lower index = higher privilege
|
|
||||||
```
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
|
||||||
|
|
||||||
<parent>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
|
||||||
<version>3.2.5</version>
|
|
||||||
<relativePath/>
|
|
||||||
</parent>
|
|
||||||
|
|
||||||
<groupId>com.example</groupId>
|
|
||||||
<artifactId>auth-migration</artifactId>
|
|
||||||
<version>1.0.0</version>
|
|
||||||
<name>auth-migration</name>
|
|
||||||
<description>Authentication and Authorization Spring Migration</description>
|
|
||||||
|
|
||||||
<properties>
|
|
||||||
<java.version>17</java.version>
|
|
||||||
<jjwt.version>0.12.5</jjwt.version>
|
|
||||||
</properties>
|
|
||||||
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-security</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-validation</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.jsonwebtoken</groupId>
|
|
||||||
<artifactId>jjwt-api</artifactId>
|
|
||||||
<version>${jjwt.version}</version>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.jsonwebtoken</groupId>
|
|
||||||
<artifactId>jjwt-impl</artifactId>
|
|
||||||
<version>${jjwt.version}</version>
|
|
||||||
<scope>runtime</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.jsonwebtoken</groupId>
|
|
||||||
<artifactId>jjwt-jackson</artifactId>
|
|
||||||
<version>${jjwt.version}</version>
|
|
||||||
<scope>runtime</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.h2database</groupId>
|
|
||||||
<artifactId>h2</artifactId>
|
|
||||||
<scope>runtime</scope>
|
|
||||||
</dependency>
|
|
||||||
<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>
|
|
||||||
<plugins>
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
|
||||||
</plugin>
|
|
||||||
</plugins>
|
|
||||||
</build>
|
|
||||||
</project>
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
package com.example.auth.config;
|
|
||||||
|
|
||||||
import com.example.auth.security.*;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
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;
|
|
||||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
|
||||||
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Security Configuration - preserves all AUTH-0XX invariants.
|
|
||||||
* AUTH-004: BCrypt cost factor 12
|
|
||||||
* AUTH-050/051: Public vs authenticated endpoints
|
|
||||||
* AUTH-052/053/054: Role-based access control
|
|
||||||
*/
|
|
||||||
@Configuration
|
|
||||||
@EnableWebSecurity
|
|
||||||
@EnableMethodSecurity(prePostEnabled = true)
|
|
||||||
public class SecurityConfig {
|
|
||||||
|
|
||||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
|
||||||
private final CustomAuthenticationEntryPoint authenticationEntryPoint;
|
|
||||||
private final CustomAccessDeniedHandler accessDeniedHandler;
|
|
||||||
|
|
||||||
public SecurityConfig(
|
|
||||||
JwtAuthenticationFilter jwtAuthenticationFilter,
|
|
||||||
CustomAuthenticationEntryPoint authenticationEntryPoint,
|
|
||||||
CustomAccessDeniedHandler accessDeniedHandler) {
|
|
||||||
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
|
||||||
this.authenticationEntryPoint = authenticationEntryPoint;
|
|
||||||
this.accessDeniedHandler = accessDeniedHandler;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
|
||||||
CsrfTokenRequestAttributeHandler requestHandler = new CsrfTokenRequestAttributeHandler();
|
|
||||||
requestHandler.setCsrfRequestAttributeName("_csrf");
|
|
||||||
|
|
||||||
http
|
|
||||||
.csrf(csrf -> csrf
|
|
||||||
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
|
||||||
.csrfTokenRequestHandler(requestHandler)
|
|
||||||
.ignoringRequestMatchers("/auth/login", "/auth/refresh")
|
|
||||||
)
|
|
||||||
.sessionManagement(session -> session
|
|
||||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
|
||||||
.sessionFixation().migrateSession()
|
|
||||||
)
|
|
||||||
.authorizeHttpRequests(auth -> auth
|
|
||||||
.requestMatchers("/auth/login", "/auth/refresh").permitAll()
|
|
||||||
.requestMatchers("/admin/**").hasRole("ADMIN")
|
|
||||||
.requestMatchers("/users/**").hasAnyRole("MANAGER", "ADMIN")
|
|
||||||
.requestMatchers("/reports/**").authenticated()
|
|
||||||
.anyRequest().authenticated()
|
|
||||||
)
|
|
||||||
.exceptionHandling(ex -> ex
|
|
||||||
.authenticationEntryPoint(authenticationEntryPoint)
|
|
||||||
.accessDeniedHandler(accessDeniedHandler)
|
|
||||||
)
|
|
||||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
|
||||||
|
|
||||||
return http.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
public PasswordEncoder passwordEncoder() {
|
|
||||||
return new BCryptPasswordEncoder(12); // AUTH-004
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
package com.example.auth.security;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.security.access.AccessDeniedException;
|
|
||||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom Access Denied Handler - preserves error response format.
|
|
||||||
* Maps to AUTH_030, AUTH_031, AUTH_040, AUTH_041 error codes.
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
|
|
||||||
|
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
public CustomAccessDeniedHandler(ObjectMapper objectMapper) {
|
|
||||||
this.objectMapper = objectMapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void handle(
|
|
||||||
HttpServletRequest request,
|
|
||||||
HttpServletResponse response,
|
|
||||||
AccessDeniedException accessDeniedException) throws IOException {
|
|
||||||
|
|
||||||
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
|
||||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
|
||||||
|
|
||||||
Map<String, Object> errorResponse = Map.of(
|
|
||||||
"code", "AUTH_030",
|
|
||||||
"message", "Insufficient permissions",
|
|
||||||
"timestamp", Instant.now().toString(),
|
|
||||||
"path", request.getRequestURI()
|
|
||||||
);
|
|
||||||
|
|
||||||
objectMapper.writeValue(response.getOutputStream(), errorResponse);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
package com.example.auth.security;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.security.core.AuthenticationException;
|
|
||||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom Authentication Entry Point - preserves error response format.
|
|
||||||
* Maps to AUTH_001, AUTH_003, AUTH_010 error codes.
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
|
||||||
|
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
public CustomAuthenticationEntryPoint(ObjectMapper objectMapper) {
|
|
||||||
this.objectMapper = objectMapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void commence(
|
|
||||||
HttpServletRequest request,
|
|
||||||
HttpServletResponse response,
|
|
||||||
AuthenticationException authException) throws IOException {
|
|
||||||
|
|
||||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
|
||||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
|
||||||
|
|
||||||
Map<String, Object> errorResponse = Map.of(
|
|
||||||
"code", "AUTH_001",
|
|
||||||
"message", "Authentication required",
|
|
||||||
"timestamp", Instant.now().toString(),
|
|
||||||
"path", request.getRequestURI()
|
|
||||||
);
|
|
||||||
|
|
||||||
objectMapper.writeValue(response.getOutputStream(), errorResponse);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
package com.example.auth.security;
|
|
||||||
|
|
||||||
import jakarta.servlet.FilterChain;
|
|
||||||
import jakarta.servlet.ServletException;
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.web.filter.OncePerRequestFilter;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JWT Authentication Filter - preserves AUTH-010, AUTH-012 invariants.
|
|
||||||
* CRITICAL: Tokens only from Authorization header (never URL query).
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|
||||||
|
|
||||||
private static final String AUTHORIZATION_HEADER = "Authorization";
|
|
||||||
private static final String BEARER_PREFIX = "Bearer ";
|
|
||||||
|
|
||||||
private final JwtTokenProvider jwtTokenProvider;
|
|
||||||
|
|
||||||
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
|
|
||||||
this.jwtTokenProvider = jwtTokenProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void doFilterInternal(
|
|
||||||
HttpServletRequest request,
|
|
||||||
HttpServletResponse response,
|
|
||||||
FilterChain filterChain) throws ServletException, IOException {
|
|
||||||
|
|
||||||
String token = extractToken(request);
|
|
||||||
|
|
||||||
if (token != null && jwtTokenProvider.validateToken(token)) {
|
|
||||||
JwtTokenProvider.JwtClaims claims = jwtTokenProvider.getClaims(token);
|
|
||||||
|
|
||||||
List<SimpleGrantedAuthority> authorities = claims.roles().stream()
|
|
||||||
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
UsernamePasswordAuthenticationToken authentication =
|
|
||||||
new UsernamePasswordAuthenticationToken(claims.subject(), null, authorities);
|
|
||||||
|
|
||||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
|
||||||
}
|
|
||||||
|
|
||||||
filterChain.doFilter(request, response);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String extractToken(HttpServletRequest request) {
|
|
||||||
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
|
|
||||||
if (bearerToken != null && bearerToken.startsWith(BEARER_PREFIX)) {
|
|
||||||
return bearerToken.substring(BEARER_PREFIX.length());
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
|
||||||
String path = request.getServletPath();
|
|
||||||
return path.startsWith("/auth/login") || path.startsWith("/auth/refresh");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
package com.example.auth.security;
|
|
||||||
|
|
||||||
import io.jsonwebtoken.Claims;
|
|
||||||
import io.jsonwebtoken.JwtException;
|
|
||||||
import io.jsonwebtoken.Jwts;
|
|
||||||
import io.jsonwebtoken.security.Keys;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import javax.crypto.SecretKey;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.Date;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JWT Token Provider - preserves AUTH-010, AUTH-011, AUTH-003 invariants.
|
|
||||||
* JWT structure: {sub, roles[], exp, iat}
|
|
||||||
* Access token TTL: 30 minutes (AUTH-003)
|
|
||||||
* Refresh token: 7 days, one-time use (AUTH-011)
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
public class JwtTokenProvider {
|
|
||||||
|
|
||||||
private static final long ACCESS_TOKEN_TTL_MS = 30 * 60 * 1000;
|
|
||||||
private static final long REFRESH_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
private final SecretKey secretKey;
|
|
||||||
private final TokenBlacklistService blacklistService;
|
|
||||||
|
|
||||||
public JwtTokenProvider(TokenBlacklistService blacklistService) {
|
|
||||||
this.secretKey = Keys.hmacShaKeyFor(
|
|
||||||
System.getenv("JWT_SECRET").getBytes(StandardCharsets.UTF_8));
|
|
||||||
this.blacklistService = blacklistService;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String generateAccessToken(String username, List<String> roles) {
|
|
||||||
Date now = new Date();
|
|
||||||
Date expiry = new Date(now.getTime() + ACCESS_TOKEN_TTL_MS);
|
|
||||||
|
|
||||||
return Jwts.builder()
|
|
||||||
.subject(username)
|
|
||||||
.claim("roles", roles)
|
|
||||||
.issuedAt(now)
|
|
||||||
.expiration(expiry)
|
|
||||||
.signWith(secretKey)
|
|
||||||
.compact();
|
|
||||||
}
|
|
||||||
|
|
||||||
public String generateRefreshToken(String username) {
|
|
||||||
Date now = new Date();
|
|
||||||
Date expiry = new Date(now.getTime() + REFRESH_TOKEN_TTL_MS);
|
|
||||||
|
|
||||||
return Jwts.builder()
|
|
||||||
.subject(username)
|
|
||||||
.claim("type", "refresh")
|
|
||||||
.issuedAt(now)
|
|
||||||
.expiration(expiry)
|
|
||||||
.signWith(secretKey)
|
|
||||||
.compact();
|
|
||||||
}
|
|
||||||
|
|
||||||
public JwtClaims getClaims(String token) {
|
|
||||||
Claims claims = Jwts.parser()
|
|
||||||
.verifyWith(secretKey)
|
|
||||||
.build()
|
|
||||||
.parseSignedClaims(token)
|
|
||||||
.getPayload();
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
List<String> roles = claims.get("roles", List.class);
|
|
||||||
|
|
||||||
return new JwtClaims(
|
|
||||||
claims.getSubject(),
|
|
||||||
roles != null ? roles : List.of(),
|
|
||||||
claims.getIssuedAt(),
|
|
||||||
claims.getExpiration()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean validateToken(String token) {
|
|
||||||
try {
|
|
||||||
if (blacklistService.isBlacklisted(token)) {
|
|
||||||
return false; // AUTH-012: revoked tokens invalid
|
|
||||||
}
|
|
||||||
|
|
||||||
Jwts.parser()
|
|
||||||
.verifyWith(secretKey)
|
|
||||||
.build()
|
|
||||||
.parseSignedClaims(token);
|
|
||||||
return true;
|
|
||||||
} catch (JwtException | IllegalArgumentException e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public record JwtClaims(
|
|
||||||
String subject,
|
|
||||||
List<String> roles,
|
|
||||||
Date issuedAt,
|
|
||||||
Date expiration
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
package com.example.auth.security;
|
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Token Blacklist Service - preserves AUTH-012 invariant.
|
|
||||||
* Logout immediately invalidates token.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
public class TokenBlacklistService {
|
|
||||||
|
|
||||||
private final ConcurrentHashMap<String, Instant> blacklist = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
public void blacklist(String token) {
|
|
||||||
blacklist.put(token, Instant.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isBlacklisted(String token) {
|
|
||||||
return blacklist.containsKey(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void cleanupExpired() {
|
|
||||||
Instant cutoff = Instant.now().minusSeconds(7 * 24 * 60 * 60);
|
|
||||||
blacklist.entrySet().removeIf(entry -> entry.getValue().isBefore(cutoff));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue