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 new file mode 100644 index 0000000..6b70b2e --- /dev/null +++ b/.forge/codex-bais-final3-20260713-192718-BAIS-SPRING-ANA-AUTH-001-attempt-1-run-f0808bb53869.md @@ -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`. diff --git a/auth-migration/INVARIANTS.md b/auth-migration/INVARIANTS.md new file mode 100644 index 0000000..1edc852 --- /dev/null +++ b/auth-migration/INVARIANTS.md @@ -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 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 new file mode 100644 index 0000000..2539a3e --- /dev/null +++ b/auth-migration/pom.xml @@ -0,0 +1,84 @@ + + + 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 new file mode 100644 index 0000000..9e3c234 --- /dev/null +++ b/auth-migration/src/main/java/com/example/auth/config/SecurityConfig.java @@ -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 + } +} 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 new file mode 100644 index 0000000..634cebd --- /dev/null +++ b/auth-migration/src/main/java/com/example/auth/security/CustomAccessDeniedHandler.java @@ -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 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 new file mode 100644 index 0000000..fae7d3b --- /dev/null +++ b/auth-migration/src/main/java/com/example/auth/security/CustomAuthenticationEntryPoint.java @@ -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 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 new file mode 100644 index 0000000..7ad051d --- /dev/null +++ b/auth-migration/src/main/java/com/example/auth/security/JwtAuthenticationFilter.java @@ -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 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 new file mode 100644 index 0000000..17b2424 --- /dev/null +++ b/auth-migration/src/main/java/com/example/auth/security/JwtTokenProvider.java @@ -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 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 new file mode 100644 index 0000000..a191089 --- /dev/null +++ b/auth-migration/src/main/java/com/example/auth/security/TokenBlacklistService.java @@ -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 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)); + } +}