Compare commits
5 commits
main
...
forge/code
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f39f1ee17 | |||
| e00a6b3be4 | |||
| 817bce3478 | |||
| 7689c9b968 | |||
| f804f7bafd |
5 changed files with 320 additions and 0 deletions
|
|
@ -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`.
|
||||
51
auth-migration/INVARIANTS.md
Normal file
51
auth-migration/INVARIANTS.md
Normal 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
76
auth-migration/pom.xml
Normal 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>
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue