diff --git a/.forge/codex-bais-final3-20260713-192718-BAIS-SPRING-ANA-AUTH-001-attempt-1-run-f0808bb53869.md b/.forge/codex-bais-final3-20260713-192718-BAIS-SPRING-ANA-AUTH-001-attempt-1-run-f0808bb53869.md deleted file mode 100644 index 6b70b2e..0000000 --- a/.forge/codex-bais-final3-20260713-192718-BAIS-SPRING-ANA-AUTH-001-attempt-1-run-f0808bb53869.md +++ /dev/null @@ -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`. diff --git a/auth-migration/INVARIANTS.md b/auth-migration/INVARIANTS.md deleted file mode 100644 index 1edc852..0000000 --- a/auth-migration/INVARIANTS.md +++ /dev/null @@ -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 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 ROLE_HIERARCHY = - List.of("ADMIN", "MANAGER", "USER", "GUEST"); -// Lower index = higher privilege -``` diff --git a/auth-migration/pom.xml b/auth-migration/pom.xml deleted file mode 100644 index 2539a3e..0000000 --- a/auth-migration/pom.xml +++ /dev/null @@ -1,84 +0,0 @@ - - - 4.0.0 - - - org.springframework.boot - spring-boot-starter-parent - 3.2.5 - - - - com.example - auth-migration - 1.0.0 - auth-migration - Authentication and Authorization Spring Migration - - - 17 - 0.12.5 - - - - - org.springframework.boot - spring-boot-starter-web - - - org.springframework.boot - spring-boot-starter-security - - - org.springframework.boot - spring-boot-starter-data-jpa - - - org.springframework.boot - spring-boot-starter-validation - - - io.jsonwebtoken - jjwt-api - ${jjwt.version} - - - io.jsonwebtoken - jjwt-impl - ${jjwt.version} - runtime - - - io.jsonwebtoken - jjwt-jackson - ${jjwt.version} - runtime - - - com.h2database - h2 - runtime - - - org.springframework.boot - spring-boot-starter-test - test - - - org.springframework.security - spring-security-test - test - - - - - - - org.springframework.boot - spring-boot-maven-plugin - - - - diff --git a/auth-migration/src/main/java/com/example/auth/config/SecurityConfig.java b/auth-migration/src/main/java/com/example/auth/config/SecurityConfig.java deleted file mode 100644 index 9e3c234..0000000 --- a/auth-migration/src/main/java/com/example/auth/config/SecurityConfig.java +++ /dev/null @@ -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 - } -} diff --git a/auth-migration/src/main/java/com/example/auth/security/CustomAccessDeniedHandler.java b/auth-migration/src/main/java/com/example/auth/security/CustomAccessDeniedHandler.java deleted file mode 100644 index 634cebd..0000000 --- a/auth-migration/src/main/java/com/example/auth/security/CustomAccessDeniedHandler.java +++ /dev/null @@ -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 errorResponse = Map.of( - "code", "AUTH_030", - "message", "Insufficient permissions", - "timestamp", Instant.now().toString(), - "path", request.getRequestURI() - ); - - objectMapper.writeValue(response.getOutputStream(), errorResponse); - } -} diff --git a/auth-migration/src/main/java/com/example/auth/security/CustomAuthenticationEntryPoint.java b/auth-migration/src/main/java/com/example/auth/security/CustomAuthenticationEntryPoint.java deleted file mode 100644 index fae7d3b..0000000 --- a/auth-migration/src/main/java/com/example/auth/security/CustomAuthenticationEntryPoint.java +++ /dev/null @@ -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 errorResponse = Map.of( - "code", "AUTH_001", - "message", "Authentication required", - "timestamp", Instant.now().toString(), - "path", request.getRequestURI() - ); - - objectMapper.writeValue(response.getOutputStream(), errorResponse); - } -} diff --git a/auth-migration/src/main/java/com/example/auth/security/JwtAuthenticationFilter.java b/auth-migration/src/main/java/com/example/auth/security/JwtAuthenticationFilter.java deleted file mode 100644 index 7ad051d..0000000 --- a/auth-migration/src/main/java/com/example/auth/security/JwtAuthenticationFilter.java +++ /dev/null @@ -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 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"); - } -} diff --git a/auth-migration/src/main/java/com/example/auth/security/JwtTokenProvider.java b/auth-migration/src/main/java/com/example/auth/security/JwtTokenProvider.java deleted file mode 100644 index 17b2424..0000000 --- a/auth-migration/src/main/java/com/example/auth/security/JwtTokenProvider.java +++ /dev/null @@ -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 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 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 roles, - Date issuedAt, - Date expiration - ) {} -} diff --git a/auth-migration/src/main/java/com/example/auth/security/TokenBlacklistService.java b/auth-migration/src/main/java/com/example/auth/security/TokenBlacklistService.java deleted file mode 100644 index a191089..0000000 --- a/auth-migration/src/main/java/com/example/auth/security/TokenBlacklistService.java +++ /dev/null @@ -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 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)); - } -}