인증/권한 업무 규칙과 전환 불변식 추출 #2

Open
forge-bot wants to merge 9 commits from forge/codex-bais-final3-20260713-192718-BAIS-SPRING-ANA-AUTH-001-attempt-1-run-f0808bb53869 into main
9 changed files with 617 additions and 0 deletions

View file

@ -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`.

View 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
View 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>

View file

@ -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
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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");
}
}

View file

@ -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
) {}
}

View file

@ -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));
}
}