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

Open
forge-bot wants to merge 5 commits from forge/codex-bais-final4-20260713-195657-BAIS-SPRING-ANA-AUTH-001-attempt-1-run-763caa509a46 into main
5 changed files with 320 additions and 0 deletions

View file

@ -0,0 +1,3 @@
# 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`.

View file

@ -0,0 +1,51 @@
# 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 <token>` | 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":<epoch>}` |
| 401 | `AUTH_MALFORMED_TOKEN` | `{"error":"AUTH_MALFORMED_TOKEN","message":"Bearer token format required","timestamp":<epoch>}` |
| 401 | `AUTH_TOKEN_EXPIRED` | `{"error":"AUTH_TOKEN_EXPIRED","message":"Token has expired","timestamp":<epoch>}` |
| 401 | `AUTH_INVALID_SIGNATURE` | `{"error":"AUTH_INVALID_SIGNATURE","message":"Token signature verification failed","timestamp":<epoch>}` |
| 401 | `AUTH_MISSING_SUB` | `{"error":"AUTH_MISSING_SUB","message":"Token missing subject claim","timestamp":<epoch>}` |
| 403 | `AUTH_FORBIDDEN_ADMIN` | `{"error":"AUTH_FORBIDDEN_ADMIN","message":"Admin role required","timestamp":<epoch>}` |
| 403 | `AUTH_FORBIDDEN_RESOURCE` | `{"error":"AUTH_FORBIDDEN_RESOURCE","message":"Not authorized for this resource","timestamp":<epoch>}` |
## 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

76
auth-migration/pom.xml Normal file
View file

@ -0,0 +1,76 @@
<?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 http://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-SNAPSHOT</version>
<packaging>jar</packaging>
<name>auth-migration</name>
<description>Authentication and Authorization invariant specification and tests</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-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>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,119 @@
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());
}
}

View file

@ -0,0 +1,71 @@
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();
}
}