diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..357f9af --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +### gateway:auth — JWT ### +# HS256 서명 키. 최소 256비트(32바이트) 이상이어야 한다 +JWT_SECRET_KEY=replace-with-32-byte-or-longer-secret-key +JWT_ISSUER=stream-server +# Access Token 만료 시간 (ms) +JWT_ACCESS_TOKEN_EXPIRY=3600000 diff --git a/.gitignore b/.gitignore index c2065bc..4364c23 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ out/ ### VS Code ### .vscode/ + +.DS_Store + +docs/plans \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 4f6de78..c21a2d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,5 +53,6 @@ infrastructure/ - 커밋: `type: 제목` (한글, 마침표 없음). type은 `feat`/`fix`/`refactor`/`docs`/`test`/`chore`/`init` - 작업 단위별로 커밋을 나눈다 +- 커밋 메시지·PR 본문에 `Co-Authored-By: Claude ...`, `Claude-Session: ...` 등 AI 트레일러를 넣지 않는다 - 브랜치: `{type}/#{이슈번호}-{작업내용}` - PR 제목: `[{Type}/#{이슈번호}] {설명}`, Squash Merge 기본, `main` 직접 push 금지 diff --git a/api/common-api/src/main/java/kr/ac/kookmin/stream/ApiResponse.java b/api/common-api/src/main/java/kr/ac/kookmin/stream/ApiResponse.java new file mode 100644 index 0000000..4664abe --- /dev/null +++ b/api/common-api/src/main/java/kr/ac/kookmin/stream/ApiResponse.java @@ -0,0 +1,41 @@ +package kr.ac.kookmin.stream; + +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.ErrorCode; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public final class ApiResponse { + + private static final String SUCCESS_CODE = "SUCCESS"; + private static final String SUCCESS_MESSAGE = "요청에 성공했습니다."; + + private final boolean success; + private final String code; + private final String message; + private final T data; + + public static ApiResponse success(T data) { + return new ApiResponse<>(true, SUCCESS_CODE, SUCCESS_MESSAGE, data); + } + + public static ApiResponse success() { + return success(null); + } + + public static ApiResponse error(BusinessException exception) { + ErrorCode errorCode = exception.getErrorCode(); + return new ApiResponse<>(false, errorCode.name(), exception.getMessage(), null); + } + + public static ApiResponse error(ErrorCode errorCode) { + return new ApiResponse<>(false, errorCode.name(), errorCode.message(), null); + } + + public static ApiResponse error(String code, String message) { + return new ApiResponse<>(false, code, message, null); + } +} diff --git a/api/common-api/src/main/java/kr/ac/kookmin/stream/CursorSliceResponse.java b/api/common-api/src/main/java/kr/ac/kookmin/stream/CursorSliceResponse.java new file mode 100644 index 0000000..21e6277 --- /dev/null +++ b/api/common-api/src/main/java/kr/ac/kookmin/stream/CursorSliceResponse.java @@ -0,0 +1,15 @@ +package kr.ac.kookmin.stream; + +import java.util.List; +import kr.ac.kookmin.stream.common.CursorSliceResult; + +public record CursorSliceResponse(List content, boolean hasNext, Long nextCursor) { + + public static CursorSliceResponse from(CursorSliceResult result) { + return new CursorSliceResponse<>( + result.content(), + result.hasNext(), + result.nextCursor() + ); + } +} diff --git a/bootstrap/src/main/java/kr/ac/kookmin/stream/StreamServerApplication.java b/bootstrap/src/main/java/kr/ac/kookmin/stream/StreamServerApplication.java index 5702901..7e19e23 100644 --- a/bootstrap/src/main/java/kr/ac/kookmin/stream/StreamServerApplication.java +++ b/bootstrap/src/main/java/kr/ac/kookmin/stream/StreamServerApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; @SpringBootApplication +@ConfigurationPropertiesScan public class StreamServerApplication { public static void main(String[] args) { diff --git a/bootstrap/src/main/resources/application.yaml b/bootstrap/src/main/resources/application.yaml index 49237be..ca0f3a9 100644 --- a/bootstrap/src/main/resources/application.yaml +++ b/bootstrap/src/main/resources/application.yaml @@ -1,3 +1,6 @@ spring: application: name: stream-server + config: + import: + - classpath:application-gateway-auth.yml diff --git a/bootstrap/src/test/resources/application-gateway-auth.yml b/bootstrap/src/test/resources/application-gateway-auth.yml new file mode 100644 index 0000000..26ee8b5 --- /dev/null +++ b/bootstrap/src/test/resources/application-gateway-auth.yml @@ -0,0 +1,6 @@ +# 테스트 클래스패스가 gateway:auth의 동명 파일을 가린다. +# 실제 값은 환경변수로 주입되므로 테스트에서는 더미 값을 쓴다. +jwt: + secret-key: test-secret-key-must-be-at-least-32-bytes-long + issuer: stream-server + access-token-expiry: 3600000 diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/CouncilDepartment.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/CouncilDepartment.java new file mode 100644 index 0000000..2a61785 --- /dev/null +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/CouncilDepartment.java @@ -0,0 +1,15 @@ +package kr.ac.kookmin.stream.common; + +/** + * 학생회 부서. ADMIN에게만 부여되며, member 도메인의 학부(Department)와는 다른 개념이다. + */ +public enum CouncilDepartment { + PRESIDENCY, // 회장단 + EXECUTIVE, // 집행부 + GENERAL_AFFAIRS, // 총무부 + PLANNING, // 기획부 + PR, // 홍보부 + MEDIA, // 미디어부 + WELFARE, // 복지부 + COMMUNICATION // 소통부 +} diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/CursorSliceResult.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/CursorSliceResult.java new file mode 100644 index 0000000..4bd0d3d --- /dev/null +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/CursorSliceResult.java @@ -0,0 +1,5 @@ +package kr.ac.kookmin.stream.common; + +import java.util.List; + +public record CursorSliceResult(List content, boolean hasNext, Long nextCursor) {} diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/PrincipalProvider.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/PrincipalProvider.java new file mode 100644 index 0000000..ae97287 --- /dev/null +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/PrincipalProvider.java @@ -0,0 +1,9 @@ +package kr.ac.kookmin.stream.common; + +import java.util.Set; + +public interface PrincipalProvider { + Long userId(); + Set roles(); + Set councilDepartments(); +} diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/Role.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/Role.java new file mode 100644 index 0000000..9ce4af1 --- /dev/null +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/Role.java @@ -0,0 +1,6 @@ +package kr.ac.kookmin.stream.common; + +public enum Role { + STUDENT, + ADMIN +} diff --git a/docs/conventions/00-index.md b/docs/conventions/00-index.md index 8f48814..b8bcedd 100644 --- a/docs/conventions/00-index.md +++ b/docs/conventions/00-index.md @@ -12,7 +12,7 @@ Java 21 + Spring Boot 4.1 + Spring Modulith 기반, 단일 학생회 플랫폼 | 문서 | 다루는 내용 | 언제 참조하는가 | | --- | --- | --- | | [`architecture.md`](./architecture.md) | 모듈 구조, 의존 방향, Modulith 경계 규칙, 레이어, 도메인 간 통신(UseCase·이벤트·아웃박스) | 새 모듈/도메인 설계, 의존성 리뷰 | -| [`coding-style.md`](./coding-style.md) | 네이밍, 도메인 객체(record)/DTO/Command/Entity/Repository/Service/UseCase 패턴, Validation | 실제 코드 작성/리뷰 | +| [`coding-style.md`](./coding-style.md) | 네이밍, 도메인 객체(record)/DTO/Command/Entity/Repository/Service/UseCase 패턴, 정적 팩토리·Lombok, Validation | 실제 코드 작성/리뷰 | | [`error-handling.md`](./error-handling.md) | `ErrorCode`/`BusinessException`, `GlobalExceptionHandler`, `@ApiErrorCode` Swagger 문서화 | 에러 코드 추가, 예외 처리 | | [`config-and-auth.md`](./config-and-auth.md) | 설정 바인딩, 2계층 권한 모델(role + 부서), `PrincipalProvider`, `DepartmentAccessChecker` | 설정값 추가, 인증·인가 작업 | | [`logging.md`](./logging.md) | MDC 요청 추적, `MdcFilter`/`LoggingFilter`, 로그 레벨, JSON 로깅 | 로깅 코드, MDC 필드 추가 | @@ -28,6 +28,7 @@ Java 21 + Spring Boot 4.1 + Spring Modulith 기반, 단일 학생회 플랫폼 - "A 도메인 변화에 B가 반응" → `architecture.md` 6-2절 (이벤트 + 아웃박스) - "부서 권한으로 승인 제한" → `config-and-auth.md` 4-4절 (`DepartmentAccessChecker`) - "soft delete 컬럼 인덱스/유니크" → `flyway-migration.md` 3-4절 +- "객체를 어떻게 생성하지 / Lombok 어디까지" → `coding-style.md` 2-10·2-11절 - "에러 코드 추가" → `error-handling.md` - "도메인 내부 구현 숨기기" → `architecture.md` 4-3절 (최상위 공개 / `internal`) diff --git a/docs/conventions/architecture.md b/docs/conventions/architecture.md index f8ef64d..edb96d7 100644 --- a/docs/conventions/architecture.md +++ b/docs/conventions/architecture.md @@ -47,7 +47,7 @@ root │ ├── welfare/ # 물품 대여·회비·공지 (대여는 자체 신청 프로세스) │ └── internal/ # 학생회 내부 운영 (운영진·부서 관리 등 어드민 전용) ├── gateway/ # 횡단관심사 그룹 -│ ├── auth/ # 인증/인가 (Spring Security, JWT, DepartmentAccessChecker). config-and-auth.md 참조 +│ ├── auth/ # 인증/인가 (SecurityConfig(role→URL), JWT, DepartmentAccessChecker). config-and-auth.md 참조 │ └── logging/ # MDC 기반 요청 추적. logging.md 참조 └── infrastructure/ # 기술 구현 (아웃바운드 어댑터) ├── db/ # JPA Entity, Repository 구현체, Flyway 마이그레이션 (MySQL) @@ -83,12 +83,12 @@ root `api:*`는 **클라이언트(admin/app)를 모듈 경계**로 삼는다(팀·도메인이 아니라). 팀 소유권은 모듈을 쪼개지 않고 **모듈 내부를 팀(bounded context) 단위 패키지**로 가른다. - `admin-api`·`app-api` 내부를 **팀(bounded context) 단위 패키지**로 나눠 팀별 파일이 서로 겹치지 않게 한다. 한 팀이 여러 도메인을 묶을 수 있고(예: core = auth·member), admin·app 양쪽에 컨트롤러를 둘 수 있다. -- 여러 팀이 같은 파일을 편집하는 지점은 **보안 설정(role→URL)·라우팅·공통 응답/예외**뿐이며 `common-api`로 한정한다. +- 여러 팀이 같은 파일을 편집하는 지점은 **라우팅·공통 응답/예외**뿐이며 `common-api`로 한정한다. 보안 설정(`SecurityConfig`의 role→URL 인가)은 `gateway:auth`가 소유한다. - admin 별도 배포가 필요해지면 `admin-api` + 필요한 도메인을 조립하는 bootstrap을 추가한다(현재는 단일 bootstrap). ``` api/ -├── common-api # 여러 팀이 공유하는 유일한 지점 (보안·라우팅·응답/예외) +├── common-api # 여러 팀이 공유하는 유일한 지점 (라우팅·응답/예외) ├── admin-api # ADMIN /v1/admin/** │ └── {basePackage}.{팀} # 팀(bounded context) 패키지 = 소유 단위 └── app-api # STUDENT /v1/app/** @@ -100,7 +100,7 @@ api/ ``` api/ ├── common-api -│ └── {basePackage} # SecurityConfig(role→URL), WebMvcConfig, ApiResponse, GlobalExceptionHandler +│ └── {basePackage} # WebMvcConfig, ApiResponse, GlobalExceptionHandler ├── admin-api │ └── {basePackage} │ ├── core # core 팀 (auth·member) @@ -122,7 +122,7 @@ api/ - 한 팀 패키지(`core`, `welfare`)의 파일은 그 팀만 건드린다 — admin·app에 흩어져 있어도 소유는 팀 단위다. - `core` 팀처럼 **여러 도메인(auth·member)을 한 팀이 묶을 수 있다.** 팀 패키지명은 도메인명과 1:1일 필요가 없다. - 컨트롤러는 접두사(`Admin`/`App`)로 클라이언트를 구분하고, 각 도메인의 공개 `{Domain}Service`(또는 교차 도메인 시 `UseCase`)만 호출한다(5절·6-1절). -- 팀이 겹쳐 충돌하는 지점은 `common-api`의 보안·라우팅·공통 응답뿐이다 — 이 파일들만 변경 시 팀 간 조율이 필요하다. +- 팀이 겹쳐 충돌하는 지점은 `common-api`의 라우팅·공통 응답과 `gateway:auth`의 `SecurityConfig`뿐이다 — 이 파일들만 변경 시 팀 간 조율이 필요하다. --- diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index 10e9750..fe8d892 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -124,7 +124,7 @@ public record PageResult(List content, int page, int size, long totalCount ### 2-5. JPA Entity -- `infrastructure:db`에 선언한다. 도메인 객체를 받는 생성자와 `toDomain()`을 제공하고, 용도에 맞는 Base Entity를 상속한다. JPA용 `protected` 기본 생성자를 둔다. +- `infrastructure:db`에 선언한다. 도메인 객체를 받는 정적 팩토리 `from(...)`과 `toDomain()`을 제공하고, 용도에 맞는 Base Entity를 상속한다. JPA용 기본 생성자는 `@NoArgsConstructor(access = AccessLevel.PROTECTED)`로 둔다(2-10·2-11절). **Base Entity 선택 기준** (`infrastructure:db` 공통 패키지) @@ -143,6 +143,7 @@ public record PageResult(List content, int page, int size, long totalCount // infrastructure:db @Entity @Table(name = "members") +@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA public class MemberJpaEntity extends BaseSoftDeleteEntity { @Id @@ -152,14 +153,16 @@ public class MemberJpaEntity extends BaseSoftDeleteEntity { private String studentNo; private String name; - protected MemberJpaEntity() {} // JPA - - public MemberJpaEntity(Member member) { + private MemberJpaEntity(Member member) { this.id = member.id(); this.studentNo = member.studentNo(); this.name = member.name(); } + public static MemberJpaEntity from(Member member) { + return new MemberJpaEntity(member); + } + public Member toDomain() { return new Member(id, studentNo, name); } @@ -198,14 +201,11 @@ public interface MemberJpaRepository extends JpaRepository findById(Long id) { return memberJpaRepository.findById(id).map(MemberJpaEntity::toDomain); @@ -218,7 +218,7 @@ public class MemberRepositoryImpl implements MemberRepository { @Override public Member save(Member member) { - return memberJpaRepository.save(new MemberJpaEntity(member)).toDomain(); + return memberJpaRepository.save(MemberJpaEntity.from(member)).toDomain(); } } ``` @@ -241,14 +241,11 @@ public interface MemberService { ```java // core:domain:member/internal (감춰짐) @Service +@RequiredArgsConstructor class MemberServiceImpl implements MemberService { private final MemberRepository memberRepository; - MemberServiceImpl(MemberRepository memberRepository) { - this.memberRepository = memberRepository; - } - @Override @Transactional public Member register(MemberRegisterCommand command) { @@ -278,14 +275,11 @@ class MemberServiceImpl implements MemberService { // api:admin-api — 운영진 회원 등록 @RestController @RequestMapping("/v1/admin/members") +@RequiredArgsConstructor public class AdminMemberController { private final MemberService memberService; - public AdminMemberController(MemberService memberService) { - this.memberService = memberService; - } - @PostMapping public ApiResponse register(@Valid @RequestBody MemberRegisterRequest request) { Member member = memberService.register(request.toCommand()); @@ -298,14 +292,11 @@ public class AdminMemberController { // api:app-api — 학생 내 정보 조회 @RestController @RequestMapping("/v1/app/members") +@RequiredArgsConstructor public class AppMemberController { private final MemberService memberService; - public AppMemberController(MemberService memberService) { - this.memberService = memberService; - } - @GetMapping("/me") public ApiResponse me(StudentApiUser apiUser) { Member member = memberService.getById(apiUser.userId()); @@ -322,18 +313,13 @@ public class AppMemberController { ```java // api:admin-api — 조회 조합 (운영진 대시보드) @Component +@RequiredArgsConstructor public class AdminDashboardUseCase { private final MemberService memberService; private final EventService eventService; private final NoticeService noticeService; - public AdminDashboardUseCase(MemberService memberService, EventService eventService, NoticeService noticeService) { - this.memberService = memberService; - this.eventService = eventService; - this.noticeService = noticeService; - } - @Transactional(readOnly = true) public AdminDashboardResponse getDashboard() { long memberCount = memberService.countActive(); @@ -347,16 +333,12 @@ public class AdminDashboardUseCase { ```java // api:app-api — 원자적 쓰기 오케스트레이션 (유료 행사 신청) @Component +@RequiredArgsConstructor public class EventApplicationUseCase { private final EventService eventService; private final FeeService feeService; - public EventApplicationUseCase(EventService eventService, FeeService feeService) { - this.eventService = eventService; - this.feeService = feeService; - } - @Transactional // 정원 차감 + 회비 반영을 원자적으로 public EventApplicationResponse apply(Long memberId, EventApplyCommand command) { Event event = eventService.apply(memberId, command); // 정원 차감·신청 등록 @@ -366,6 +348,78 @@ public class EventApplicationUseCase { } ``` +### 2-10. 객체 생성 — 정적 팩토리 메서드 + +객체는 `new`로 직접 만들지 않고 정적 팩토리 메서드로 생성한다. 생성자는 `private`(JPA처럼 프레임워크가 요구하면 `protected`)으로 감춘다. + +| 이름 | 쓰임 | 예 | +| --- | --- | --- | +| `from` | 다른 타입 하나를 받아 변환 | `MemberResponse.from(member)`, `MemberJpaEntity.from(member)` | +| `of` | 값 여러 개를 받아 조합 | `JwtAuthFilter.of(jwtProvider, handlerExceptionResolver)` | +| `create` | 새로 만든다는 의미를 드러낼 때 | `Member.create(studentNo, name)` | + +```java +// gateway:auth +@Getter +@Accessors(fluent = true) +public class UserAuthentication extends AbstractAuthenticationToken { + + private final Long userId; + + private UserAuthentication(JwtPayload payload) { + super(toAuthorities(payload)); + this.userId = payload.userId(); + setAuthenticated(true); + } + + public static UserAuthentication from(JwtPayload payload) { + return new UserAuthentication(payload); + } +} +``` + +- 이름이 생성 의도를 드러내므로, 인자 목록만으로는 구분되지 않는 여러 생성 경로를 표현할 수 있다. +- 생성자를 감추면 호출부가 `new`로 우회할 수 없어 생성 경로가 하나로 모인다. +- **예외** — 아래는 `new`를 그대로 쓴다. + - `record`(도메인 객체·Command·Request/Response): 표준 생성자를 쓴다. 단 타입 변환이 끼면 `from(...)`/`toCommand()`를 둔다(2-2·2-3절). + - 예외 클래스: `throw new BusinessException(...)`. + - 스프링이 생성·주입하는 빈: 애초에 직접 생성하지 않는다(2-11절). + +### 2-11. Lombok + +루트 `build.gradle.kts`에서 전 모듈에 적용된다. 모듈별 `build.gradle.kts`에 다시 선언하지 않는다. 동작 설정은 루트 `lombok.config`에 둔다. + +| 어노테이션 | 용도 | +| --- | --- | +| `@Getter` | 필드 접근자. 클래스 단위로 붙인다 | +| `@Accessors(fluent = true)` | `getXxx()` 대신 `xxx()` 접근자. `record`와 표기를 맞출 때 (`CommonErrorCode`, `UserAuthentication`) | +| `@RequiredArgsConstructor` | 스프링 빈(`@Service`/`@Repository`/`@Component`/`@RestController`)의 생성자 주입 | +| `@RequiredArgsConstructor(access = AccessLevel.PRIVATE)` | 정적 팩토리(2-10절)와 짝지어 생성자를 감출 때 | +| `@NoArgsConstructor(access = AccessLevel.PROTECTED)` | JPA Entity의 기본 생성자 | +| `@AllArgsConstructor` | 필드를 갖는 enum (`{Domain}ErrorCode`) | + +- `@Data`·`@Setter`는 쓰지 않는다. 객체는 불변을 기본으로 하고, 상태 변경은 의도가 드러나는 메서드(`entity.delete()` 등)로 표현한다. +- `record`에는 Lombok을 붙이지 않는다. 접근자·`equals`/`hashCode`가 이미 제공된다. +- 스프링 빈의 생성자 주입은 **항상 `@RequiredArgsConstructor`**로 한다. 생성자를 직접 쓰는 경우는 하나뿐이다 — 주입받은 값으로 다른 필드를 초기화해야 할 때. 예: `JwtProperties`로 `SecretKey`를 만드는 `JwtProvider`. +- 주입할 빈을 지목해야 하면 **필드에** `@Qualifier`를 붙인다. 루트 `lombok.config`의 `lombok.copyableAnnotations`가 이를 생성자 파라미터로 복사한다. + +```java +// gateway:auth — HandlerExceptionResolver 빈이 여럿이라 이름으로 지목 +@Component +@RequiredArgsConstructor +public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Qualifier("handlerExceptionResolver") + private final HandlerExceptionResolver handlerExceptionResolver; +} +``` + +```properties +# lombok.config (루트) +config.stopBubbling = true +lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier +``` + --- ## 3. Validation diff --git a/docs/conventions/config-and-auth.md b/docs/conventions/config-and-auth.md index 8162799..d061a83 100644 --- a/docs/conventions/config-and-auth.md +++ b/docs/conventions/config-and-auth.md @@ -133,14 +133,11 @@ Controller / UseCase — 부서 단위 인가는 DepartmentAccessChec ```java // gateway:auth — DepartmentAccessChecker.java @Component +@RequiredArgsConstructor public class DepartmentAccessChecker { private final PrincipalProvider principalProvider; - public DepartmentAccessChecker(PrincipalProvider principalProvider) { - this.principalProvider = principalProvider; - } - // 요구 부서 권한이 없으면 403 public void requireDepartment(Department required) { if (!principalProvider.departments().contains(required)) { @@ -153,16 +150,12 @@ public class DepartmentAccessChecker { ```java // api:admin-api — 사물함 승인은 복지부만 @Component +@RequiredArgsConstructor public class LockerApprovalUseCase { private final DepartmentAccessChecker departmentAccessChecker; private final LockerService lockerService; - public LockerApprovalUseCase(DepartmentAccessChecker departmentAccessChecker, LockerService lockerService) { - this.departmentAccessChecker = departmentAccessChecker; - this.lockerService = lockerService; - } - public void approve(Long userId, Long applicationId) { departmentAccessChecker.requireDepartment(Department.WELFARE); lockerService.approve(applicationId); diff --git a/docs/conventions/git-convention.md b/docs/conventions/git-convention.md index a72bdc4..3e5a6f4 100644 --- a/docs/conventions/git-convention.md +++ b/docs/conventions/git-convention.md @@ -24,7 +24,14 @@ type: 제목 - #PR번호 - **제목**: 한글, 명사형/동사 종결형, 마침표 없음 - **PR번호**: Squash Merge 커밋에만 붙인다 -### 1-2. Type 종류 +### 1-2. 본문·트레일러 + +- 커밋 메시지는 **제목 한 줄**을 기본으로 한다. 배경 설명이 필요하면 빈 줄 뒤에 본문을 덧붙인다. +- **AI 도구가 남기는 트레일러를 넣지 않는다.** `Co-Authored-By: Claude ...`, `Claude-Session: ...`, + `Generated with ...` 같은 줄은 커밋 메시지·PR 본문 어디에도 추가하지 않는다. +- 커밋 작성자는 실제 작업자 본인이다. AI 에이전트를 공동 작성자로 표기하지 않는다. + +### 1-3. Type 종류 | type | 설명 | | --- | --- | @@ -36,7 +43,7 @@ type: 제목 - #PR번호 | `chore` | 빌드 설정, 의존성, 기타 잡무 | | `init` | 프로젝트·모듈 초기 세팅 | -### 1-3. 예시 +### 1-4. 예시 ``` feat: 사물함 신청 API 추가 diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/SecurityPrincipalProvider.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/SecurityPrincipalProvider.java new file mode 100644 index 0000000..ae308fb --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/SecurityPrincipalProvider.java @@ -0,0 +1,39 @@ +package kr.ac.kookmin.stream.security; + +import java.util.Set; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.CommonErrorCode; +import kr.ac.kookmin.stream.common.CouncilDepartment; +import kr.ac.kookmin.stream.common.PrincipalProvider; +import kr.ac.kookmin.stream.common.Role; +import kr.ac.kookmin.stream.security.jwt.UserAuthentication; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +@Component +public class SecurityPrincipalProvider implements PrincipalProvider { + + @Override + public Long userId() { + return currentAuthentication().userId(); + } + + @Override + public Set roles() { + return currentAuthentication().roles(); + } + + @Override + public Set councilDepartments() { + return currentAuthentication().councilDepartments(); + } + + private UserAuthentication currentAuthentication() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (!(authentication instanceof UserAuthentication userAuthentication)) { + throw new BusinessException(CommonErrorCode.UNAUTHORIZED); + } + return userAuthentication; + } +} diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/PublicEndpoints.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/PublicEndpoints.java new file mode 100644 index 0000000..cd5a808 --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/PublicEndpoints.java @@ -0,0 +1,51 @@ +package kr.ac.kookmin.stream.security.config; + +import java.util.Arrays; +import java.util.List; +import lombok.Getter; +import lombok.experimental.Accessors; +import org.springframework.http.server.PathContainer; +import org.springframework.web.util.pattern.PathPattern; +import org.springframework.web.util.pattern.PathPatternParser; + +/** + * 인증 없이 여는 엔드포인트. SecurityConfig의 permitAll 대상이며 용도별로 묶어 관리한다. + */ +@Getter +@Accessors(fluent = true) +public enum PublicEndpoints { + + HEALTH_CHECK(List.of( + "/actuator/health" + )), + SWAGGER(List.of( + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs/**" + )); + + private static final List ALL_PATH_PATTERNS = Arrays.stream(values()) + .flatMap(endpoints -> endpoints.pathPatterns.stream()) + .toList(); + + private final List patterns; + private final List pathPatterns; + + PublicEndpoints(List patterns) { + PathPatternParser parser = new PathPatternParser(); + this.patterns = patterns; + this.pathPatterns = patterns.stream().map(parser::parse).toList(); + } + + public static String[] allPatterns() { + return Arrays.stream(values()) + .flatMap(endpoints -> endpoints.patterns.stream()) + .toArray(String[]::new); + } + + public static boolean isPublic(String path) { + PathContainer pathContainer = PathContainer.parsePath(path); + return ALL_PATH_PATTERNS.stream() + .anyMatch(pathPattern -> pathPattern.matches(pathContainer)); + } +} diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java new file mode 100644 index 0000000..e3fa55b --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java @@ -0,0 +1,46 @@ +package kr.ac.kookmin.stream.security.config; + +import kr.ac.kookmin.stream.common.Role; +import kr.ac.kookmin.stream.security.handler.RestAccessDeniedHandler; +import kr.ac.kookmin.stream.security.handler.RestAuthenticationEntryPoint; +import kr.ac.kookmin.stream.security.jwt.JwtAuthFilter; +import kr.ac.kookmin.stream.security.jwt.JwtProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.intercept.AuthorizationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtProvider jwtProvider; + private final RestAuthenticationEntryPoint authenticationEntryPoint; + private final RestAccessDeniedHandler accessDeniedHandler; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(request -> request + .requestMatchers(PublicEndpoints.allPatterns()).permitAll() + .requestMatchers("/v1/admin/**").hasAuthority(Role.ADMIN.name()) + .requestMatchers("/v1/app/**").hasAuthority(Role.STUDENT.name()) + .anyRequest().authenticated()) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler)) + // ExceptionTranslationFilter 뒤에 두어야 필터가 던진 인증 예외가 EntryPoint로 넘어간다 + .addFilterBefore(JwtAuthFilter.of(jwtProvider), AuthorizationFilter.class) + .build(); + } +} diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/handler/RestAccessDeniedHandler.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/handler/RestAccessDeniedHandler.java new file mode 100644 index 0000000..baa1876 --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/handler/RestAccessDeniedHandler.java @@ -0,0 +1,33 @@ +package kr.ac.kookmin.stream.security.handler; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.CommonErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerExceptionResolver; + +/** + * 인증은 됐으나 권한이 없는 요청(403)을 공통 에러 응답으로 내보낸다. + */ +@Component +@RequiredArgsConstructor +public class RestAccessDeniedHandler implements AccessDeniedHandler { + + @Qualifier("handlerExceptionResolver") + private final HandlerExceptionResolver handlerExceptionResolver; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) { + handlerExceptionResolver.resolveException( + request, response, null, new BusinessException(CommonErrorCode.FORBIDDEN)); + } +} diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/handler/RestAuthenticationEntryPoint.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/handler/RestAuthenticationEntryPoint.java new file mode 100644 index 0000000..e5181b5 --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/handler/RestAuthenticationEntryPoint.java @@ -0,0 +1,33 @@ +package kr.ac.kookmin.stream.security.handler; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.CommonErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerExceptionResolver; + +/** + * 인증되지 않은 요청(401). 로그인 페이지로 리다이렉트하는 기본 동작 대신 공통 에러 응답으로 내보낸다. + */ +@Component +@RequiredArgsConstructor +public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Qualifier("handlerExceptionResolver") + private final HandlerExceptionResolver handlerExceptionResolver; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authenticationException + ) { + handlerExceptionResolver.resolveException( + request, response, null, new BusinessException(CommonErrorCode.UNAUTHORIZED)); + } +} diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/InvalidTokenException.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/InvalidTokenException.java new file mode 100644 index 0000000..ec4ab65 --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/InvalidTokenException.java @@ -0,0 +1,14 @@ +package kr.ac.kookmin.stream.security.jwt; + +import org.springframework.security.core.AuthenticationException; + +/** + * 토큰이 만료됐거나 서명·형식이 올바르지 않을 때. ExceptionTranslationFilter가 잡아 + * SecurityConfig에 설정된 AuthenticationEntryPoint로 넘긴다. + */ +public class InvalidTokenException extends AuthenticationException { + + public InvalidTokenException(Throwable cause) { + super("유효하지 않은 토큰입니다.", cause); + } +} diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtAuthFilter.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtAuthFilter.java new file mode 100644 index 0000000..8bf8a8b --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtAuthFilter.java @@ -0,0 +1,45 @@ +package kr.ac.kookmin.stream.security.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class JwtAuthFilter extends OncePerRequestFilter { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtProvider jwtProvider; + + public static JwtAuthFilter of(JwtProvider jwtProvider) { + return new JwtAuthFilter(jwtProvider); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + String token = resolveToken(request); + if (token != null) { + SecurityContextHolder.getContext().setAuthentication(UserAuthentication.from(jwtProvider.parse(token))); + } + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String header = request.getHeader(AUTHORIZATION_HEADER); + if (header == null || !header.startsWith(BEARER_PREFIX)) { + return null; + } + return header.substring(BEARER_PREFIX.length()); + } +} diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtPayload.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtPayload.java new file mode 100644 index 0000000..41af91b --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtPayload.java @@ -0,0 +1,11 @@ +package kr.ac.kookmin.stream.security.jwt; + +import java.util.Set; +import kr.ac.kookmin.stream.common.CouncilDepartment; +import kr.ac.kookmin.stream.common.Role; + +public record JwtPayload( + Long userId, + Set roles, + Set councilDepartments +) {} diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtProperties.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtProperties.java new file mode 100644 index 0000000..530b678 --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtProperties.java @@ -0,0 +1,10 @@ +package kr.ac.kookmin.stream.security.jwt; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "jwt") +public record JwtProperties( + String secretKey, + String issuer, + long accessTokenExpiry +) {} diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtProvider.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtProvider.java new file mode 100644 index 0000000..a30e0cb --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtProvider.java @@ -0,0 +1,80 @@ +package kr.ac.kookmin.stream.security.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.Date; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import javax.crypto.SecretKey; +import kr.ac.kookmin.stream.common.CouncilDepartment; +import kr.ac.kookmin.stream.common.Role; +import org.springframework.stereotype.Component; + +@Component +public class JwtProvider { + + private static final String ROLES_CLAIM = "roles"; + private static final String COUNCIL_CLAIM = "council"; + + private final JwtProperties jwtProperties; + private final SecretKey secretKey; + + public JwtProvider(JwtProperties jwtProperties) { + this.jwtProperties = jwtProperties; + this.secretKey = Keys.hmacShaKeyFor(jwtProperties.secretKey().getBytes(StandardCharsets.UTF_8)); + } + + public String generateAccessToken(Long userId, Set roles, Set councilDepartments) { + Date issuedAt = new Date(); + Date expiration = new Date(issuedAt.getTime() + jwtProperties.accessTokenExpiry()); + + return Jwts.builder() + .issuer(jwtProperties.issuer()) + .subject(String.valueOf(userId)) + .issuedAt(issuedAt) + .expiration(expiration) + .claim(ROLES_CLAIM, names(roles)) + .claim(COUNCIL_CLAIM, names(councilDepartments)) + .signWith(secretKey) + .compact(); + } + + public JwtPayload parse(String token) { + try { + Claims claims = Jwts.parser() + .verifyWith(secretKey) + .requireIssuer(jwtProperties.issuer()) + .build() + .parseSignedClaims(token) + .getPayload(); + + return new JwtPayload( + Long.valueOf(claims.getSubject()), + toEnumSet(claims, ROLES_CLAIM, Role.class), + toEnumSet(claims, COUNCIL_CLAIM, CouncilDepartment.class) + ); + } catch (JwtException | IllegalArgumentException e) { + throw new InvalidTokenException(e); + } + } + + private List names(Collection> values) { + return values.stream().map(Enum::name).toList(); + } + + private > Set toEnumSet(Claims claims, String claimName, Class type) { + List values = claims.get(claimName, List.class); + if (values == null) { + return EnumSet.noneOf(type); + } + return values.stream() + .map(value -> Enum.valueOf(type, String.valueOf(value))) + .collect(Collectors.toCollection(() -> EnumSet.noneOf(type))); + } +} diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/UserAuthentication.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/UserAuthentication.java new file mode 100644 index 0000000..5c631ff --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/UserAuthentication.java @@ -0,0 +1,54 @@ +package kr.ac.kookmin.stream.security.jwt; + +import java.util.Collection; +import java.util.Set; +import java.util.stream.Stream; +import kr.ac.kookmin.stream.common.CouncilDepartment; +import kr.ac.kookmin.stream.common.Role; +import lombok.Getter; +import lombok.experimental.Accessors; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +@Getter +@Accessors(fluent = true) +public class UserAuthentication extends AbstractAuthenticationToken { + + private static final String COUNCIL_AUTHORITY_PREFIX = "COUNCIL_"; + + private final Long userId; + private final Set roles; + private final Set councilDepartments; + + private UserAuthentication(JwtPayload payload) { + super(toAuthorities(payload)); + this.userId = payload.userId(); + this.roles = payload.roles(); + this.councilDepartments = payload.councilDepartments(); + setAuthenticated(true); + } + + public static UserAuthentication from(JwtPayload payload) { + return new UserAuthentication(payload); + } + + @Override + public Object getPrincipal() { + return userId; + } + + @Override + public Object getCredentials() { + return null; + } + + private static Collection toAuthorities(JwtPayload payload) { + return Stream.concat( + payload.roles().stream().map(Role::name), + payload.councilDepartments().stream().map(department -> COUNCIL_AUTHORITY_PREFIX + department.name()) + ) + .map(SimpleGrantedAuthority::new) + .toList(); + } +} diff --git a/gateway/auth/src/main/resources/application-gateway-auth.yml b/gateway/auth/src/main/resources/application-gateway-auth.yml new file mode 100644 index 0000000..f209f03 --- /dev/null +++ b/gateway/auth/src/main/resources/application-gateway-auth.yml @@ -0,0 +1,4 @@ +jwt: + secret-key: ${JWT_SECRET_KEY} + issuer: ${JWT_ISSUER} + access-token-expiry: ${JWT_ACCESS_TOKEN_EXPIRY:3600000} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaEntity.java index 5d1f7cb..b7d09d0 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaEntity.java @@ -22,12 +22,16 @@ public class MemberJpaEntity extends BaseSoftDeleteEntity { private String studentNo; private String name; - public MemberJpaEntity(Member member) { + private MemberJpaEntity(Member member) { this.id = member.id(); this.studentNo = member.studentNo(); this.name = member.name(); } + public static MemberJpaEntity from(Member member) { + return new MemberJpaEntity(member); + } + public Member toDomain() { return new Member(id, studentNo, name); } diff --git a/lombok.config b/lombok.config new file mode 100644 index 0000000..4fc7dd1 --- /dev/null +++ b/lombok.config @@ -0,0 +1,5 @@ +# 프로젝트 루트 기준으로 설정 탐색을 멈춘다 +config.stopBubbling = true + +# @RequiredArgsConstructor가 생성한 생성자 파라미터로 복사할 어노테이션 +lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier