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/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/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/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/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..f634429 --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/PublicEndpoints.java @@ -0,0 +1,48 @@ +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 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 Arrays.stream(values()) + .flatMap(endpoints -> endpoints.pathPatterns.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