Compare commits
9 commits
main
...
forge/code
| Author | SHA1 | Date | |
|---|---|---|---|
| f2bec9f746 | |||
| 9d2e1fd4ff | |||
| f449b32ccd | |||
| a3d57302e7 | |||
| 01f2a87199 | |||
| cd37bbe7d1 | |||
| 51208857e9 | |||
| 8fb5989bae | |||
| 281c5e8f75 |
9 changed files with 617 additions and 0 deletions
|
|
@ -0,0 +1,3 @@
|
||||||
|
# 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`.
|
||||||
163
auth-migration/INVARIANTS.md
Normal file
163
auth-migration/INVARIANTS.md
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
# 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
|
||||||
|
```
|
||||||
84
auth-migration/pom.xml
Normal file
84
auth-migration/pom.xml
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
<?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>
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
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