diff --git a/.forge/codex-bais-final4-20260713-195657-BAIS-SPRING-ANA-AUTH-001-attempt-1-run-763caa509a46.md b/.forge/codex-bais-final4-20260713-195657-BAIS-SPRING-ANA-AUTH-001-attempt-1-run-763caa509a46.md deleted file mode 100644 index bb020f7..0000000 --- a/.forge/codex-bais-final4-20260713-195657-BAIS-SPRING-ANA-AUTH-001-attempt-1-run-763caa509a46.md +++ /dev/null @@ -1,3 +0,0 @@ -# codex-bais-final4-20260713-195657-BAIS-SPRING-ANA-AUTH-001-attempt-1-run-763caa509a46 - -Forge 이슈 작업 브랜치 `forge/codex-bais-final4-20260713-195657-BAIS-SPRING-ANA-AUTH-001-attempt-1-run-763caa509a46`. diff --git a/auth-migration/INVARIANTS.md b/auth-migration/INVARIANTS.md deleted file mode 100644 index 326542c..0000000 --- a/auth-migration/INVARIANTS.md +++ /dev/null @@ -1,51 +0,0 @@ -# Authentication & Authorization Invariants — auth-migration - -## 1. Authentication Rules - -| ID | Rule | Trigger | Expected Behavior | Error Code | -|----|------|---------|-------------------|------------| -| AUTH-001 | JWT must contain `sub` claim | Any protected endpoint | Reject with 401 if `sub` missing/empty | `AUTH_MISSING_SUB` | -| AUTH-002 | JWT `exp` must be in future | Any protected endpoint | Reject with 401 if expired | `AUTH_TOKEN_EXPIRED` | -| AUTH-003 | JWT signature must verify against secret | Any protected endpoint | Reject with 401 if signature invalid | `AUTH_INVALID_SIGNATURE` | -| AUTH-004 | Bearer token required in Authorization header | Any protected endpoint | Reject with 401 if header missing | `AUTH_MISSING_TOKEN` | -| AUTH-005 | Token format must be `Bearer ` | Any protected endpoint | Reject with 401 if malformed | `AUTH_MALFORMED_TOKEN` | - -## 2. Authorization Rules - -| ID | Rule | Scope | Expected Behavior | Error Code | -|----|------|-------|-------------------|------------| -| AUTH-010 | ADMIN role required for user management | `/api/admin/**` | Reject with 403 if not ADMIN | `AUTH_FORBIDDEN_ADMIN` | -| AUTH-011 | Resource owner or ADMIN can modify | `/api/users/{id}/**` | Reject with 403 if neither | `AUTH_FORBIDDEN_RESOURCE` | -| AUTH-012 | Any authenticated user can read public profiles | `/api/users/public/**` | Allow 200 if authenticated | — | -| AUTH-013 | Role hierarchy: ADMIN > MANAGER > USER | All role-gated endpoints | Higher role inherits lower permissions | — | - -## 3. Error Response Schema - -| HTTP Status | Error Code | Response Body Shape | -|-------------|------------|---------------------| -| 401 | `AUTH_MISSING_TOKEN` | `{"error":"AUTH_MISSING_TOKEN","message":"Authorization header required","timestamp":}` | -| 401 | `AUTH_MALFORMED_TOKEN` | `{"error":"AUTH_MALFORMED_TOKEN","message":"Bearer token format required","timestamp":}` | -| 401 | `AUTH_TOKEN_EXPIRED` | `{"error":"AUTH_TOKEN_EXPIRED","message":"Token has expired","timestamp":}` | -| 401 | `AUTH_INVALID_SIGNATURE` | `{"error":"AUTH_INVALID_SIGNATURE","message":"Token signature verification failed","timestamp":}` | -| 401 | `AUTH_MISSING_SUB` | `{"error":"AUTH_MISSING_SUB","message":"Token missing subject claim","timestamp":}` | -| 403 | `AUTH_FORBIDDEN_ADMIN` | `{"error":"AUTH_FORBIDDEN_ADMIN","message":"Admin role required","timestamp":}` | -| 403 | `AUTH_FORBIDDEN_RESOURCE` | `{"error":"AUTH_FORBIDDEN_RESOURCE","message":"Not authorized for this resource","timestamp":}` | - -## 4. Session / Cookie Semantics - -| ID | Rule | Behavior | -|----|------|----------| -| SESS-001 | Session cookie `HttpOnly` | Must be `true` | -| SESS-002 | Session cookie `Secure` | Must be `true` in production | -| SESS-003 | Session cookie `SameSite` | Must be `Lax` or `Strict` | -| SESS-004 | CSRF token required for state-changing ops | POST/PUT/DELETE without CSRF → 403 | - -## 5. Migration Checklist - -- [ ] Spring Security filter chain preserves order: JWT filter before session filter -- [ ] `@PreAuthorize` annotations match existing role-gate logic -- [ ] `AuthenticationEntryPoint` returns exact error schema (AUTH_* codes) -- [ ] `AccessDeniedHandler` returns exact error schema (AUTH_FORBIDDEN_* codes) -- [ ] Cookie attributes (`HttpOnly`, `Secure`, `SameSite`) set via `CookieSecurityConfigurer` -- [ ] CSRF protection enabled for state-changing endpoints -- [ ] Role hierarchy bean `RoleHierarchyImpl` wired with ADMIN > MANAGER > USER diff --git a/auth-migration/pom.xml b/auth-migration/pom.xml deleted file mode 100644 index 5fab683..0000000 --- a/auth-migration/pom.xml +++ /dev/null @@ -1,76 +0,0 @@ - - - 4.0.0 - - - org.springframework.boot - spring-boot-starter-parent - 3.2.5 - - - - com.example - auth-migration - 1.0.0-SNAPSHOT - jar - auth-migration - Authentication and Authorization invariant specification and tests - - - 17 - 0.12.5 - - - - - org.springframework.boot - spring-boot-starter-web - - - org.springframework.boot - spring-boot-starter-security - - - 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 - - - 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/test/java/com/example/auth/AuthInvariantTest.java b/auth-migration/src/test/java/com/example/auth/AuthInvariantTest.java deleted file mode 100644 index cc5529b..0000000 --- a/auth-migration/src/test/java/com/example/auth/AuthInvariantTest.java +++ /dev/null @@ -1,119 +0,0 @@ -package com.example.auth; - -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -/** - * Verification tests for AUTH-* invariants. - * These tests MUST pass after Spring migration to confirm behavioral parity. - */ -@SpringBootTest -@AutoConfigureMockMvc -public class AuthInvariantTest { - - @Autowired - private MockMvc mockMvc; - - // AUTH-004: Bearer token required - @Test - void missingAuthorizationHeader_returns401WithAUTH_MISSING_TOKEN() throws Exception { - mockMvc.perform(get("/api/protected/resource")) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.error").value("AUTH_MISSING_TOKEN")) - .andExpect(jsonPath("$.message").value("Authorization header required")) - .andExpect(jsonPath("$.timestamp").exists()); - } - - // AUTH-005: Bearer format required - @Test - void malformedBearerToken_returns401WithAUTH_MALFORMED_TOKEN() throws Exception { - mockMvc.perform(get("/api/protected/resource") - .header("Authorization", "Basic dXNlcjpwYXNz")) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.error").value("AUTH_MALFORMED_TOKEN")) - .andExpect(jsonPath("$.message").value("Bearer token format required")); - } - - // AUTH-002: Expired token - @Test - void expiredToken_returns401WithAUTH_TOKEN_EXPIRED() throws Exception { - String expiredToken = TestTokenUtil.generateExpiredToken(); - mockMvc.perform(get("/api/protected/resource") - .header("Authorization", "Bearer " + expiredToken)) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.error").value("AUTH_TOKEN_EXPIRED")) - .andExpect(jsonPath("$.message").value("Token has expired")); - } - - // AUTH-003: Invalid signature - @Test - void invalidSignature_returns401WithAUTH_INVALID_SIGNATURE() throws Exception { - String tamperedToken = TestTokenUtil.generateTamperedToken(); - mockMvc.perform(get("/api/protected/resource") - .header("Authorization", "Bearer " + tamperedToken)) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.error").value("AUTH_INVALID_SIGNATURE")) - .andExpect(jsonPath("$.message").value("Token signature verification failed")); - } - - // AUTH-001: Missing sub claim - @Test - void tokenWithoutSub_returns401WithAUTH_MISSING_SUB() throws Exception { - String noSubToken = TestTokenUtil.generateTokenWithoutSub(); - mockMvc.perform(get("/api/protected/resource") - .header("Authorization", "Bearer " + noSubToken)) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.error").value("AUTH_MISSING_SUB")) - .andExpect(jsonPath("$.message").value("Token missing subject claim")); - } - - // AUTH-010: Admin role required - @Test - void nonAdminAccessingAdminEndpoint_returns403WithAUTH_FORBIDDEN_ADMIN() throws Exception { - String userToken = TestTokenUtil.generateTokenWithRole("ROLE_USER"); - mockMvc.perform(get("/api/admin/users") - .header("Authorization", "Bearer " + userToken)) - .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.error").value("AUTH_FORBIDDEN_ADMIN")) - .andExpect(jsonPath("$.message").value("Admin role required")); - } - - // AUTH-011: Resource owner check - @Test - void nonOwnerAccessingUserResource_returns403WithAUTH_FORBIDDEN_RESOURCE() throws Exception { - String userToken = TestTokenUtil.generateTokenWithRole("ROLE_USER"); - mockMvc.perform(get("/api/users/999/profile") - .header("Authorization", "Bearer " + userToken)) - .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.error").value("AUTH_FORBIDDEN_RESOURCE")) - .andExpect(jsonPath("$.message").value("Not authorized for this resource")); - } - - // AUTH-012: Public profile accessible to authenticated users - @Test - void authenticatedUserCanReadPublicProfile_returns200() throws Exception { - String userToken = TestTokenUtil.generateTokenWithRole("ROLE_USER"); - mockMvc.perform(get("/api/users/public/johndoe") - .header("Authorization", "Bearer " + userToken)) - .andExpect(status().isOk()); - } - - // SESS-004: CSRF required for state-changing ops - @Test - void postWithoutCSRF_returns403() throws Exception { - String userToken = TestTokenUtil.generateTokenWithRole("ROLE_USER"); - mockMvc.perform(post("/api/users/profile") - .header("Authorization", "Bearer " + userToken) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"displayName\":\"test\"}")) - .andExpect(status().isForbidden()); - } -} diff --git a/auth-migration/src/test/java/com/example/auth/TestTokenUtil.java b/auth-migration/src/test/java/com/example/auth/TestTokenUtil.java deleted file mode 100644 index 6ece8c4..0000000 --- a/auth-migration/src/test/java/com/example/auth/TestTokenUtil.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.example.auth; - -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import io.jsonwebtoken.security.Keys; - -import javax.crypto.SecretKey; -import java.nio.charset.StandardCharsets; -import java.util.Date; -import java.util.List; -import java.util.Map; - -/** - * Test utility for generating JWT tokens with specific claims. - * Mirrors the token construction used in the legacy auth system. - */ -public final class TestTokenUtil { - - private static final String SECRET = "auth-migration-test-secret-key-must-be-at-least-256-bits-long"; - private static final SecretKey KEY = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8)); - - private TestTokenUtil() {} - - public static String generateExpiredToken() { - return Jwts.builder() - .setSubject("user123") - .setExpiration(new Date(System.currentTimeMillis() - 60_000)) - .signWith(KEY, SignatureAlgorithm.HS256) - .compact(); - } - - public static String generateTamperedToken() { - // Generate valid structure but sign with different key - SecretKey wrongKey = Keys.hmacShaKeyFor( - "wrong-key-for-tampering-test-must-be-256-bits-minimum".getBytes(StandardCharsets.UTF_8)); - return Jwts.builder() - .setSubject("user123") - .setExpiration(new Date(System.currentTimeMillis() + 3_600_000)) - .signWith(wrongKey, SignatureAlgorithm.HS256) - .compact(); - } - - public static String generateTokenWithoutSub() { - return Jwts.builder() - .setExpiration(new Date(System.currentTimeMillis() + 3_600_000)) - .signWith(KEY, SignatureAlgorithm.HS256) - .compact(); - } - - public static String generateTokenWithRole(String role) { - return Jwts.builder() - .setSubject("user123") - .claim("roles", List.of(role)) - .setExpiration(new Date(System.currentTimeMillis() + 3_600_000)) - .signWith(KEY, SignatureAlgorithm.HS256) - .compact(); - } - - public static String generateAdminToken() { - return generateTokenWithRole("ROLE_ADMIN"); - } - - public static String generateValidToken() { - return Jwts.builder() - .setSubject("user123") - .claim("roles", List.of("ROLE_USER")) - .setExpiration(new Date(System.currentTimeMillis() + 3_600_000)) - .signWith(KEY, SignatureAlgorithm.HS256) - .compact(); - } -}