From f146752faaa8614ca1c1a737e03e3b79b7a1785a Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Tue, 14 Jul 2026 19:10:49 +0900 Subject: [PATCH 1/7] =?UTF-8?q?chore:=20seed=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=20=EB=B0=8F=20=EC=BD=94=EB=93=9C=20=EC=8A=A4=ED=83=80=EC=9D=BC?= =?UTF-8?q?=20=EA=B7=9C=EC=B9=99=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - roles(USER/ADMIN/DOCUMENT_MANAGER), departments(개발/인사/재무), 최초 ADMIN 계정 seed 추가 - @Operation description 필수화 및 서비스 log.error 규칙 추가 Co-Authored-By: Claude Sonnet 4.6 --- .claude/rules/code_style.md | 27 ++++++++++++++++++- .../V28__seed_roles_departments_admin.sql | 25 +++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/db/migration/V28__seed_roles_departments_admin.sql diff --git a/.claude/rules/code_style.md b/.claude/rules/code_style.md index e5bfdb0..760d82a 100644 --- a/.claude/rules/code_style.md +++ b/.claude/rules/code_style.md @@ -26,6 +26,22 @@ Entity entity = repository.findById(id) - `ErrorCode`에 HTTP 상태코드와 메시지 함께 정의 - `GlobalExceptionHandler`에서 일괄 처리 +## 로깅 규칙 +- 단순 Not Found는 로그 생략 (GlobalExceptionHandler에서 처리됨) +- 중요한 비즈니스 로직 실패, 시스템 설정 오류, 외부 연동 실패는 예외 던지기 전에 `log.error()` 필수 + +```java +// 중요한 예외 — log.error 추가 +if (activeModels.isEmpty()) { + log.error("사용 가능한 임베딩 모델이 설정되지 않았습니다."); + throw new DocGridException(ErrorCode.EMBEDDING_MODEL_NOT_CONFIGURED); +} + +// 단순 Not Found — log 생략 +User user = userRepository.findById(id) + .orElseThrow(() -> new DocGridException(ErrorCode.USER_NOT_FOUND)); +``` + ## Service 패턴 ```java @Transactional(readOnly = true) @@ -70,9 +86,18 @@ public class XxxController { ## Swagger 컨벤션 - 클래스: `@Tag(name = "도메인명", description = "한 줄 설명")` -- 메서드: `@Operation(summary = "짧은 요약")` — description이 summary와 같으면 생략 +- 메서드: `@Operation(summary = "짧은 요약", description = "상세 설명")` — description은 항상 작성 + - summary: 한 줄 동사+목적어 (예: "부서 목록 조회") + - description: 호출 시점, 파라미터 조건, 특이사항, 주의사항 등 프론트가 알아야 할 내용 작성 - Response DTO: `@Schema(description = "필드 설명")` +```java +@Operation( + summary = "약 등록", + description = "약을 1개 이상 등록합니다. 단건이면 리스트에 1개, 여러 개면 여러 개 담아서 보내세요. 하나라도 실패하면 전체 롤백됩니다." +) +``` + ## Converter 패턴 - 위치: `domain/{도메인}/converter/XxxConverter.java` - `@Component`로 Spring Bean 등록, 서비스에서 주입 diff --git a/src/main/resources/db/migration/V28__seed_roles_departments_admin.sql b/src/main/resources/db/migration/V28__seed_roles_departments_admin.sql new file mode 100644 index 0000000..0192d0c --- /dev/null +++ b/src/main/resources/db/migration/V28__seed_roles_departments_admin.sql @@ -0,0 +1,25 @@ +-- roles seed +INSERT INTO roles (name, code, description, created_at, updated_at) VALUES + ('일반 사용자', 'USER', '기본 사용자 권한', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('관리자', 'ADMIN', '시스템 전체 관리 권한', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('문서 관리자', 'DOCUMENT_MANAGER', '문서 권한 관리 가능', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + +-- departments seed +INSERT INTO departments (name, code, description, status, created_at, updated_at) VALUES + ('개발팀', 'DEV', '개발 부서', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('인사팀', 'HR', '인사 부서', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('재무팀', 'FINANCE', '재무 부서', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + +-- 최초 ADMIN 계정 (비밀번호: admin1234) +INSERT INTO users (email, password_hash, name, status, created_at, updated_at) VALUES + ('kcw130502@gmail.com', '$2b$10$U60IG4W3G8pvipjwIww6iO9J1vuJnEpXLsAyaSJzlQOBCXAL9nnoS', '강철웅', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + +-- ADMIN role 부여 +INSERT INTO user_roles (user_id, role_id, assigned_by, assigned_at, created_at, updated_at) VALUES ( + (SELECT id FROM users WHERE email = 'kcw130502@gmail.com'), + (SELECT id FROM roles WHERE code = 'ADMIN'), + (SELECT id FROM users WHERE email = 'kcw130502@gmail.com'), + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +); From fe6433f2ea278ad69f665d86fc377c6bb2515843 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Tue, 14 Jul 2026 19:11:00 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=EB=B6=80=EC=84=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80=20(GE?= =?UTF-8?q?T=20/departments)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 활성 부서 목록 반환, 인증 불필요 - 회원가입 화면에서 부서 선택 드롭다운용 Co-Authored-By: Claude Sonnet 4.6 --- .../user/controller/DepartmentController.java | 32 +++++++++++++++++++ .../user/dto/response/DepartmentResponse.java | 15 +++++++++ .../user/repository/DepartmentRepository.java | 13 ++++++++ .../service/query/DepartmentQueryService.java | 27 ++++++++++++++++ 4 files changed, 87 insertions(+) create mode 100644 src/main/java/com/opensource/docgrid/domain/user/controller/DepartmentController.java create mode 100644 src/main/java/com/opensource/docgrid/domain/user/dto/response/DepartmentResponse.java create mode 100644 src/main/java/com/opensource/docgrid/domain/user/repository/DepartmentRepository.java create mode 100644 src/main/java/com/opensource/docgrid/domain/user/service/query/DepartmentQueryService.java diff --git a/src/main/java/com/opensource/docgrid/domain/user/controller/DepartmentController.java b/src/main/java/com/opensource/docgrid/domain/user/controller/DepartmentController.java new file mode 100644 index 0000000..4f449f5 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/user/controller/DepartmentController.java @@ -0,0 +1,32 @@ +package com.opensource.docgrid.domain.user.controller; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.opensource.docgrid.domain.user.dto.response.DepartmentResponse; +import com.opensource.docgrid.domain.user.service.query.DepartmentQueryService; +import com.opensource.docgrid.global.common.response.ApiResponse; +import com.opensource.docgrid.global.common.response.ResponseUtils; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Department", description = "부서 관련 API") +@RestController +@RequestMapping("/departments") +@RequiredArgsConstructor +public class DepartmentController { + + private final DepartmentQueryService departmentQueryService; + + @Operation(summary = "부서 목록 조회", description = "회원가입 화면 드롭다운에 표시할 ACTIVE 상태 부서 목록을 반환합니다. 인증 없이 호출 가능합니다.") + @GetMapping + public ResponseEntity>> getDepartments() { + return ResponseUtils.ok(departmentQueryService.getActiveDepartments()); + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/user/dto/response/DepartmentResponse.java b/src/main/java/com/opensource/docgrid/domain/user/dto/response/DepartmentResponse.java new file mode 100644 index 0000000..a35b927 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/user/dto/response/DepartmentResponse.java @@ -0,0 +1,15 @@ +package com.opensource.docgrid.domain.user.dto.response; + +import com.opensource.docgrid.domain.user.entity.Department; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record DepartmentResponse( + @Schema(description = "부서 ID") Long id, + @Schema(description = "부서명") String name, + @Schema(description = "부서 코드") String code +) { + public static DepartmentResponse from(Department department) { + return new DepartmentResponse(department.getId(), department.getName(), department.getCode()); + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/user/repository/DepartmentRepository.java b/src/main/java/com/opensource/docgrid/domain/user/repository/DepartmentRepository.java new file mode 100644 index 0000000..19af7fc --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/user/repository/DepartmentRepository.java @@ -0,0 +1,13 @@ +package com.opensource.docgrid.domain.user.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.opensource.docgrid.domain.user.entity.Department; +import com.opensource.docgrid.domain.user.enums.CommonStatus; + +public interface DepartmentRepository extends JpaRepository { + + List findAllByStatus(CommonStatus status); +} diff --git a/src/main/java/com/opensource/docgrid/domain/user/service/query/DepartmentQueryService.java b/src/main/java/com/opensource/docgrid/domain/user/service/query/DepartmentQueryService.java new file mode 100644 index 0000000..ddbf5aa --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/user/service/query/DepartmentQueryService.java @@ -0,0 +1,27 @@ +package com.opensource.docgrid.domain.user.service.query; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.opensource.docgrid.domain.user.dto.response.DepartmentResponse; +import com.opensource.docgrid.domain.user.enums.CommonStatus; +import com.opensource.docgrid.domain.user.repository.DepartmentRepository; + +import lombok.RequiredArgsConstructor; + +@Transactional(readOnly = true) +@Service +@RequiredArgsConstructor +public class DepartmentQueryService { + + private final DepartmentRepository departmentRepository; + + public List getActiveDepartments() { + return departmentRepository.findAllByStatus(CommonStatus.ACTIVE) + .stream() + .map(DepartmentResponse::from) + .toList(); + } +} From 7615b469413cfbb10fd0fb09fae003cde3814031 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Tue, 14 Jul 2026 19:11:10 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20API=20=EC=B6=94=EA=B0=80=20(POST=20/auth/signup)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이메일 중복 확인, 부서 활성 확인, BCrypt 해시 후 저장 - 가입 시 USER role 자동 부여 - 인증 관련 ErrorCode 추가, SecurityConfig PasswordEncoder 빈 등록 Co-Authored-By: Claude Sonnet 4.6 --- .../auth/dto/request/SignupRequest.java | 14 ++++++++++ .../auth/dto/response/SignupResponse.java | 28 +++++++++++++++++++ .../user/repository/RoleRepository.java | 12 ++++++++ .../user/repository/UserRepository.java | 14 ++++++++++ .../user/repository/UserRoleRepository.java | 18 ++++++++++++ .../docgrid/global/config/SecurityConfig.java | 20 ++++++++++++- .../docgrid/global/exception/ErrorCode.java | 11 ++++++++ 7 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/opensource/docgrid/domain/auth/dto/request/SignupRequest.java create mode 100644 src/main/java/com/opensource/docgrid/domain/auth/dto/response/SignupResponse.java create mode 100644 src/main/java/com/opensource/docgrid/domain/user/repository/RoleRepository.java create mode 100644 src/main/java/com/opensource/docgrid/domain/user/repository/UserRepository.java create mode 100644 src/main/java/com/opensource/docgrid/domain/user/repository/UserRoleRepository.java diff --git a/src/main/java/com/opensource/docgrid/domain/auth/dto/request/SignupRequest.java b/src/main/java/com/opensource/docgrid/domain/auth/dto/request/SignupRequest.java new file mode 100644 index 0000000..a2ec11f --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/auth/dto/request/SignupRequest.java @@ -0,0 +1,14 @@ +package com.opensource.docgrid.domain.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record SignupRequest( + @Schema(description = "이메일 (로그인 ID)") @NotBlank @Email String email, + @Schema(description = "비밀번호") @NotBlank String password, + @Schema(description = "이름") @NotBlank String name, + @Schema(description = "부서 ID") @NotNull Long departmentId +) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/auth/dto/response/SignupResponse.java b/src/main/java/com/opensource/docgrid/domain/auth/dto/response/SignupResponse.java new file mode 100644 index 0000000..012a674 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/auth/dto/response/SignupResponse.java @@ -0,0 +1,28 @@ +package com.opensource.docgrid.domain.auth.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +import com.opensource.docgrid.domain.user.entity.User; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record SignupResponse( + @Schema(description = "사용자 ID") Long userId, + @Schema(description = "이메일") String email, + @Schema(description = "이름") String name, + @Schema(description = "부서 ID") Long departmentId, + @Schema(description = "부여된 역할 목록") List roles, + @Schema(description = "가입 시각") LocalDateTime createdAt +) { + public static SignupResponse of(User user, List roles) { + return new SignupResponse( + user.getId(), + user.getEmail(), + user.getName(), + user.getDepartment() != null ? user.getDepartment().getId() : null, + roles, + user.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/user/repository/RoleRepository.java b/src/main/java/com/opensource/docgrid/domain/user/repository/RoleRepository.java new file mode 100644 index 0000000..4569b69 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/user/repository/RoleRepository.java @@ -0,0 +1,12 @@ +package com.opensource.docgrid.domain.user.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.opensource.docgrid.domain.user.entity.Role; + +public interface RoleRepository extends JpaRepository { + + Optional findByCode(String code); +} diff --git a/src/main/java/com/opensource/docgrid/domain/user/repository/UserRepository.java b/src/main/java/com/opensource/docgrid/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..142d986 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/user/repository/UserRepository.java @@ -0,0 +1,14 @@ +package com.opensource.docgrid.domain.user.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.opensource.docgrid.domain.user.entity.User; + +public interface UserRepository extends JpaRepository { + + boolean existsByEmail(String email); + + Optional findByEmail(String email); +} diff --git a/src/main/java/com/opensource/docgrid/domain/user/repository/UserRoleRepository.java b/src/main/java/com/opensource/docgrid/domain/user/repository/UserRoleRepository.java new file mode 100644 index 0000000..b049fdd --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/user/repository/UserRoleRepository.java @@ -0,0 +1,18 @@ +package com.opensource.docgrid.domain.user.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.opensource.docgrid.domain.user.entity.UserRole; + +public interface UserRoleRepository extends JpaRepository { + + @Query("SELECT ur FROM UserRole ur JOIN FETCH ur.role WHERE ur.user.id = :userId") + List findAllWithRoleByUserId(@Param("userId") Long userId); + + @Query("SELECT COUNT(ur) > 0 FROM UserRole ur WHERE ur.user.id = :userId AND ur.role.code = :roleCode") + boolean existsByUserIdAndRoleCode(@Param("userId") Long userId, @Param("roleCode") String roleCode); +} diff --git a/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java b/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java index e6dd555..56dbe81 100644 --- a/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java +++ b/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java @@ -5,9 +5,16 @@ 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.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfigurationSource; +import com.opensource.docgrid.domain.auth.jwt.JwtAuthenticationFilter; +import com.opensource.docgrid.domain.auth.jwt.JwtProvider; + import lombok.RequiredArgsConstructor; @Configuration @@ -16,17 +23,28 @@ public class SecurityConfig { private final CorsConfigurationSource corsConfigurationSource; + private final JwtProvider jwtProvider; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .cors(cors -> cors.configurationSource(corsConfigurationSource)) .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/test/**").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() + .requestMatchers("/departments").permitAll() + .requestMatchers("/auth/**").permitAll() + .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() - ); + ) + .addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class); return http.build(); } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } } diff --git a/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java b/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java index a9f8d36..de919dc 100644 --- a/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java +++ b/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java @@ -21,6 +21,17 @@ public enum ErrorCode { // USER USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER-001", "사용자를 찾을 수 없습니다."), + EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "USER-002", "이미 사용 중인 이메일입니다."), + ACCOUNT_INACTIVE(HttpStatus.FORBIDDEN, "USER-003", "비활성화된 계정입니다."), + INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "USER-004", "이메일 또는 비밀번호가 올바르지 않습니다."), + + // DEPARTMENT + DEPARTMENT_NOT_FOUND(HttpStatus.BAD_REQUEST, "DEPT-001", "존재하지 않는 부서입니다."), + + // ROLE + ROLE_NOT_FOUND(HttpStatus.BAD_REQUEST, "ROLE-001", "존재하지 않는 역할입니다."), + PERMISSION_DENIED(HttpStatus.FORBIDDEN, "ROLE-002", "관리자만 접근할 수 있습니다."), + ROLE_ALREADY_ASSIGNED(HttpStatus.CONFLICT, "ROLE-003", "이미 부여된 역할입니다."), // EMBEDDING MODEL EMBEDDING_MODEL_NOT_CONFIGURED( From 0516b1e1ff40d52c2f5d5291677326fa76071ced Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Tue, 14 Jul 2026 19:11:19 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=B0=8F=20JWT=20=EC=9D=B8=EC=A6=9D=20=EC=B6=94=EA=B0=80=20(PO?= =?UTF-8?q?ST=20/auth/login)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - jjwt 0.12.6 의존성 추가, JWT secret/expiration 환경변수화 - JwtProvider (HS256 토큰 생성/검증), JwtAuthenticationFilter 구현 - 로그인 시 accessToken 발급, SecurityConfig stateless 세션으로 전환 Co-Authored-By: Claude Sonnet 4.6 --- build.gradle | 3 + .../auth/controller/AuthController.java | 52 +++++++++ .../domain/auth/dto/request/LoginRequest.java | 11 ++ .../auth/dto/response/LoginResponse.java | 18 ++++ .../auth/jwt/JwtAuthenticationFilter.java | 55 ++++++++++ .../docgrid/domain/auth/jwt/JwtProvider.java | 80 ++++++++++++++ .../service/command/AuthCommandService.java | 100 ++++++++++++++++++ src/main/resources/application.yml | 4 + 8 files changed, 323 insertions(+) create mode 100644 src/main/java/com/opensource/docgrid/domain/auth/controller/AuthController.java create mode 100644 src/main/java/com/opensource/docgrid/domain/auth/dto/request/LoginRequest.java create mode 100644 src/main/java/com/opensource/docgrid/domain/auth/dto/response/LoginResponse.java create mode 100644 src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtProvider.java create mode 100644 src/main/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandService.java diff --git a/build.gradle b/build.gradle index 148c6e4..93e3740 100644 --- a/build.gradle +++ b/build.gradle @@ -26,6 +26,9 @@ dependencies { implementation 'io.minio:minio:8.5.17' implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-database-postgresql' + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'org.postgresql:postgresql' diff --git a/src/main/java/com/opensource/docgrid/domain/auth/controller/AuthController.java b/src/main/java/com/opensource/docgrid/domain/auth/controller/AuthController.java new file mode 100644 index 0000000..6ce2e5a --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/auth/controller/AuthController.java @@ -0,0 +1,52 @@ +package com.opensource.docgrid.domain.auth.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.opensource.docgrid.domain.auth.annotation.CurrentUser; +import com.opensource.docgrid.domain.auth.dto.request.LoginRequest; +import com.opensource.docgrid.domain.auth.dto.request.SignupRequest; +import com.opensource.docgrid.domain.auth.dto.response.LoginResponse; +import com.opensource.docgrid.domain.auth.dto.response.MeResponse; +import com.opensource.docgrid.domain.auth.dto.response.SignupResponse; +import com.opensource.docgrid.domain.auth.service.command.AuthCommandService; +import com.opensource.docgrid.domain.auth.service.query.AuthQueryService; +import com.opensource.docgrid.global.common.response.ApiResponse; +import com.opensource.docgrid.global.common.response.ResponseUtils; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Auth", description = "인증 관련 API") +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthCommandService authCommandService; + private final AuthQueryService authQueryService; + + @Operation(summary = "회원가입", description = "이메일, 비밀번호, 이름, 부서 ID로 신규 계정을 생성합니다. 가입 시 USER 역할이 자동으로 부여됩니다.") + @PostMapping("/signup") + public ResponseEntity> signup(@RequestBody @Valid SignupRequest request) { + return ResponseUtils.created(authCommandService.signup(request)); + } + + @Operation(summary = "로그인", description = "이메일과 비밀번호로 로그인합니다. 성공 시 Bearer 액세스 토큰을 반환합니다. 이후 인증이 필요한 API는 Authorization: Bearer {token} 헤더에 토큰을 포함하세요.") + @PostMapping("/login") + public ResponseEntity> login(@RequestBody @Valid LoginRequest request) { + return ResponseUtils.ok(authCommandService.login(request)); + } + + @Operation(summary = "내 정보 조회", description = "현재 로그인한 사용자의 정보를 반환합니다. Authorization: Bearer {token} 헤더가 필요합니다.") + @GetMapping("/me") + public ResponseEntity> getMe(@CurrentUser Long userId) { + return ResponseUtils.ok(authQueryService.getMe(userId)); + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/auth/dto/request/LoginRequest.java b/src/main/java/com/opensource/docgrid/domain/auth/dto/request/LoginRequest.java new file mode 100644 index 0000000..9b7b9fc --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/auth/dto/request/LoginRequest.java @@ -0,0 +1,11 @@ +package com.opensource.docgrid.domain.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @Schema(description = "이메일") @NotBlank @Email String email, + @Schema(description = "비밀번호") @NotBlank String password +) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/auth/dto/response/LoginResponse.java b/src/main/java/com/opensource/docgrid/domain/auth/dto/response/LoginResponse.java new file mode 100644 index 0000000..eeb295f --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/auth/dto/response/LoginResponse.java @@ -0,0 +1,18 @@ +package com.opensource.docgrid.domain.auth.dto.response; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record LoginResponse( + @Schema(description = "액세스 토큰") String accessToken, + @Schema(description = "토큰 타입") String tokenType, + @Schema(description = "만료 시간 (초)") long expiresIn, + @Schema(description = "사용자 ID") Long userId, + @Schema(description = "이메일") String email, + @Schema(description = "역할 목록") List roles +) { + public static LoginResponse of(String accessToken, long expiresIn, Long userId, String email, List roles) { + return new LoginResponse(accessToken, "Bearer", expiresIn, userId, email, roles); + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilter.java b/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..c3d6c2b --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,55 @@ +package com.opensource.docgrid.domain.auth.jwt; + +import java.io.IOException; +import java.util.List; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String token = resolveToken(request); + + if (StringUtils.hasText(token) && jwtProvider.validateToken(token)) { + Long userId = jwtProvider.getUserId(token); + String email = jwtProvider.getEmail(token); + List roles = jwtProvider.getRoles(token); + + List authorities = roles.stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .toList(); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(email, null, authorities); + authentication.setDetails(userId); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String bearer = request.getHeader("Authorization"); + if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) { + return bearer.substring(7); + } + return null; + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtProvider.java b/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtProvider.java new file mode 100644 index 0000000..cc9baff --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtProvider.java @@ -0,0 +1,80 @@ +package com.opensource.docgrid.domain.auth.jwt; + +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.List; + +import javax.crypto.SecretKey; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class JwtProvider { + + private final SecretKey secretKey; + private final long expirationSeconds; + + public JwtProvider( + @Value("${jwt.secret}") String secret, + @Value("${jwt.expiration}") long expirationSeconds) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.expirationSeconds = expirationSeconds; + } + + public String generateToken(Long userId, String email, List roles) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + expirationSeconds * 1000); + + return Jwts.builder() + .subject(email) + .claim("userId", userId) + .claim("roles", roles) + .issuedAt(now) + .expiration(expiry) + .signWith(secretKey) + .compact(); + } + + public boolean validateToken(String token) { + try { + Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + log.error("JWT 검증 실패: {}", e.getMessage()); + return false; + } + } + + public Claims getClaims(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + public String getEmail(String token) { + return getClaims(token).getSubject(); + } + + public Long getUserId(String token) { + return getClaims(token).get("userId", Long.class); + } + + @SuppressWarnings("unchecked") + public List getRoles(String token) { + return getClaims(token).get("roles", List.class); + } + + public long getExpirationSeconds() { + return expirationSeconds; + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandService.java b/src/main/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandService.java new file mode 100644 index 0000000..cbb994c --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandService.java @@ -0,0 +1,100 @@ +package com.opensource.docgrid.domain.auth.service.command; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.opensource.docgrid.domain.auth.dto.request.LoginRequest; +import com.opensource.docgrid.domain.auth.dto.request.SignupRequest; +import com.opensource.docgrid.domain.auth.dto.response.LoginResponse; +import com.opensource.docgrid.domain.auth.dto.response.SignupResponse; +import com.opensource.docgrid.domain.auth.jwt.JwtProvider; +import com.opensource.docgrid.domain.user.entity.Department; +import com.opensource.docgrid.domain.user.entity.Role; +import com.opensource.docgrid.domain.user.entity.User; +import com.opensource.docgrid.domain.user.entity.UserRole; +import com.opensource.docgrid.domain.user.enums.CommonStatus; +import com.opensource.docgrid.domain.user.enums.UserStatus; +import com.opensource.docgrid.domain.user.repository.DepartmentRepository; +import com.opensource.docgrid.domain.user.repository.RoleRepository; +import com.opensource.docgrid.domain.user.repository.UserRepository; +import com.opensource.docgrid.domain.user.repository.UserRoleRepository; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Transactional +@Service +@RequiredArgsConstructor +public class AuthCommandService { + + private final UserRepository userRepository; + private final DepartmentRepository departmentRepository; + private final RoleRepository roleRepository; + private final UserRoleRepository userRoleRepository; + private final PasswordEncoder passwordEncoder; + private final JwtProvider jwtProvider; + + public SignupResponse signup(SignupRequest request) { + if (userRepository.existsByEmail(request.email())) { + log.error("회원가입 실패 - 이메일 중복: {}", request.email()); + throw new DocGridException(ErrorCode.EMAIL_ALREADY_EXISTS); + } + + Department department = departmentRepository.findById(request.departmentId()) + .filter(d -> d.getStatus() == CommonStatus.ACTIVE) + .orElseThrow(() -> new DocGridException(ErrorCode.DEPARTMENT_NOT_FOUND)); + + Role userRole = roleRepository.findByCode("USER") + .orElseThrow(() -> { + log.error("USER role이 존재하지 않습니다. Seed 데이터를 확인하세요."); + return new DocGridException(ErrorCode.ROLE_NOT_FOUND); + }); + + User user = User.builder() + .email(request.email()) + .passwordHash(passwordEncoder.encode(request.password())) + .name(request.name()) + .department(department) + .status(UserStatus.ACTIVE) + .build(); + userRepository.save(user); + + UserRole userRoleEntity = UserRole.builder() + .user(user) + .role(userRole) + .assignedBy(user) + .assignedAt(LocalDateTime.now()) + .build(); + userRoleRepository.save(userRoleEntity); + + return SignupResponse.of(user, List.of(userRole.getCode())); + } + + public LoginResponse login(LoginRequest request) { + User user = userRepository.findByEmail(request.email()) + .orElseThrow(() -> new DocGridException(ErrorCode.INVALID_CREDENTIALS)); + + if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) { + throw new DocGridException(ErrorCode.INVALID_CREDENTIALS); + } + + if (user.getStatus() != UserStatus.ACTIVE) { + throw new DocGridException(ErrorCode.ACCOUNT_INACTIVE); + } + + List roles = userRoleRepository.findAllWithRoleByUserId(user.getId()).stream() + .map(ur -> ur.getRole().getCode()) + .toList(); + + String token = jwtProvider.generateToken(user.getId(), user.getEmail(), roles); + + return LoginResponse.of(token, jwtProvider.getExpirationSeconds(), user.getId(), user.getEmail(), roles); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 59486d8..87c52e0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,3 +8,7 @@ spring: server: port: 8080 + +jwt: + secret: ${JWT_SECRET:local-dev-secret-key-must-be-at-least-32-characters-long} + expiration: ${JWT_EXPIRATION:3600} From 357a0646f4d0ee5afa8ade035c4069d74de64b57 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Tue, 14 Jul 2026 19:11:31 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80=20(GET=20/auth/me?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @CurrentUser 어노테이션 및 CurrentUserArgumentResolver 구현 - role 승격 즉시 반영을 위해 JWT payload 미사용, DB 직접 조회 Co-Authored-By: Claude Sonnet 4.6 --- .../domain/auth/annotation/CurrentUser.java | 11 ++++++ .../domain/auth/dto/response/MeResponse.java | 38 +++++++++++++++++++ .../resolver/CurrentUserArgumentResolver.java | 35 +++++++++++++++++ .../auth/service/query/AuthQueryService.java | 35 +++++++++++++++++ .../docgrid/global/config/WebMvcConfig.java | 23 +++++++++++ 5 files changed, 142 insertions(+) create mode 100644 src/main/java/com/opensource/docgrid/domain/auth/annotation/CurrentUser.java create mode 100644 src/main/java/com/opensource/docgrid/domain/auth/dto/response/MeResponse.java create mode 100644 src/main/java/com/opensource/docgrid/domain/auth/resolver/CurrentUserArgumentResolver.java create mode 100644 src/main/java/com/opensource/docgrid/domain/auth/service/query/AuthQueryService.java create mode 100644 src/main/java/com/opensource/docgrid/global/config/WebMvcConfig.java diff --git a/src/main/java/com/opensource/docgrid/domain/auth/annotation/CurrentUser.java b/src/main/java/com/opensource/docgrid/domain/auth/annotation/CurrentUser.java new file mode 100644 index 0000000..036f43f --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/auth/annotation/CurrentUser.java @@ -0,0 +1,11 @@ +package com.opensource.docgrid.domain.auth.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface CurrentUser { +} diff --git a/src/main/java/com/opensource/docgrid/domain/auth/dto/response/MeResponse.java b/src/main/java/com/opensource/docgrid/domain/auth/dto/response/MeResponse.java new file mode 100644 index 0000000..3551a69 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/auth/dto/response/MeResponse.java @@ -0,0 +1,38 @@ +package com.opensource.docgrid.domain.auth.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +import com.opensource.docgrid.domain.user.entity.User; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record MeResponse( + @Schema(description = "사용자 ID") Long userId, + @Schema(description = "이메일") String email, + @Schema(description = "이름") String name, + @Schema(description = "닉네임") String nickname, + @Schema(description = "프로필 이미지 URL") String profileImageUrl, + @Schema(description = "부서 ID") Long departmentId, + @Schema(description = "부서명") String departmentName, + @Schema(description = "역할 목록") List roles, + @Schema(description = "계정 상태") String status, + @Schema(description = "가입 시각") LocalDateTime createdAt, + @Schema(description = "마지막 로그인 시각") LocalDateTime lastLoginAt +) { + public static MeResponse of(User user, List roles) { + return new MeResponse( + user.getId(), + user.getEmail(), + user.getName(), + user.getNickname(), + user.getProfileImageUrl(), + user.getDepartment() != null ? user.getDepartment().getId() : null, + user.getDepartment() != null ? user.getDepartment().getName() : null, + roles, + user.getStatus().name(), + user.getCreatedAt(), + user.getLastLoginAt() + ); + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/auth/resolver/CurrentUserArgumentResolver.java b/src/main/java/com/opensource/docgrid/domain/auth/resolver/CurrentUserArgumentResolver.java new file mode 100644 index 0000000..f8a51d6 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/auth/resolver/CurrentUserArgumentResolver.java @@ -0,0 +1,35 @@ +package com.opensource.docgrid.domain.auth.resolver; + +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import com.opensource.docgrid.domain.auth.annotation.CurrentUser; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +@Component +public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUser.class) + && parameter.getParameterType().equals(Long.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated() + || !(authentication.getDetails() instanceof Long)) { + throw new DocGridException(ErrorCode.UNAUTHORIZED); + } + return (Long) authentication.getDetails(); + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/auth/service/query/AuthQueryService.java b/src/main/java/com/opensource/docgrid/domain/auth/service/query/AuthQueryService.java new file mode 100644 index 0000000..d83a098 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/auth/service/query/AuthQueryService.java @@ -0,0 +1,35 @@ +package com.opensource.docgrid.domain.auth.service.query; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.opensource.docgrid.domain.auth.dto.response.MeResponse; +import com.opensource.docgrid.domain.user.entity.User; +import com.opensource.docgrid.domain.user.repository.UserRepository; +import com.opensource.docgrid.domain.user.repository.UserRoleRepository; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +import lombok.RequiredArgsConstructor; + +@Transactional(readOnly = true) +@Service +@RequiredArgsConstructor +public class AuthQueryService { + + private final UserRepository userRepository; + private final UserRoleRepository userRoleRepository; + + public MeResponse getMe(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new DocGridException(ErrorCode.USER_NOT_FOUND)); + + List roles = userRoleRepository.findAllWithRoleByUserId(userId).stream() + .map(ur -> ur.getRole().getCode()) + .toList(); + + return MeResponse.of(user, roles); + } +} diff --git a/src/main/java/com/opensource/docgrid/global/config/WebMvcConfig.java b/src/main/java/com/opensource/docgrid/global/config/WebMvcConfig.java new file mode 100644 index 0000000..1f00b31 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/global/config/WebMvcConfig.java @@ -0,0 +1,23 @@ +package com.opensource.docgrid.global.config; + +import java.util.List; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import com.opensource.docgrid.domain.auth.resolver.CurrentUserArgumentResolver; + +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final CurrentUserArgumentResolver currentUserArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(currentUserArgumentResolver); + } +} From 203f9a7cc64cff6915ff0b105ac8ee65f4f347d7 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Tue, 14 Jul 2026 19:11:38 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=EC=97=AD=ED=95=A0=20=EB=B6=80?= =?UTF-8?q?=EC=97=AC=20API=20=EC=B6=94=EA=B0=80=20(POST=20/admin/users/{us?= =?UTF-8?q?erId}/roles)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADMIN만 접근 가능, SecurityConfig에서 /admin/** URL 레벨 인가 처리 - 중복 부여 방지, assignedBy에 ADMIN 기록 Co-Authored-By: Claude Sonnet 4.6 --- .../user/controller/AdminUserController.java | 38 ++++++++++++ .../user/dto/request/AssignRoleRequest.java | 9 +++ .../user/dto/response/UserRoleResponse.java | 18 ++++++ .../command/UserRoleCommandService.java | 60 +++++++++++++++++++ 4 files changed, 125 insertions(+) create mode 100644 src/main/java/com/opensource/docgrid/domain/user/controller/AdminUserController.java create mode 100644 src/main/java/com/opensource/docgrid/domain/user/dto/request/AssignRoleRequest.java create mode 100644 src/main/java/com/opensource/docgrid/domain/user/dto/response/UserRoleResponse.java create mode 100644 src/main/java/com/opensource/docgrid/domain/user/service/command/UserRoleCommandService.java diff --git a/src/main/java/com/opensource/docgrid/domain/user/controller/AdminUserController.java b/src/main/java/com/opensource/docgrid/domain/user/controller/AdminUserController.java new file mode 100644 index 0000000..459c449 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/user/controller/AdminUserController.java @@ -0,0 +1,38 @@ +package com.opensource.docgrid.domain.user.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.opensource.docgrid.domain.auth.annotation.CurrentUser; +import com.opensource.docgrid.domain.user.dto.request.AssignRoleRequest; +import com.opensource.docgrid.domain.user.dto.response.UserRoleResponse; +import com.opensource.docgrid.domain.user.service.command.UserRoleCommandService; +import com.opensource.docgrid.global.common.response.ApiResponse; +import com.opensource.docgrid.global.common.response.ResponseUtils; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Admin - User", description = "관리자 전용 사용자 관리 API") +@RestController +@RequestMapping("/admin/users") +@RequiredArgsConstructor +public class AdminUserController { + + private final UserRoleCommandService userRoleCommandService; + + @Operation(summary = "역할 부여", description = "특정 사용자에게 역할을 부여합니다. ADMIN 권한이 필요합니다. 이미 부여된 역할이면 409를 반환합니다.") + @PostMapping("/{userId}/roles") + public ResponseEntity> assignRole( + @PathVariable Long userId, + @CurrentUser Long adminUserId, + @RequestBody @Valid AssignRoleRequest request) { + return ResponseUtils.ok(userRoleCommandService.assignRole(userId, adminUserId, request)); + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/user/dto/request/AssignRoleRequest.java b/src/main/java/com/opensource/docgrid/domain/user/dto/request/AssignRoleRequest.java new file mode 100644 index 0000000..97169d5 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/user/dto/request/AssignRoleRequest.java @@ -0,0 +1,9 @@ +package com.opensource.docgrid.domain.user.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record AssignRoleRequest( + @Schema(description = "부여할 역할 코드 (USER / ADMIN / DOCUMENT_MANAGER)") @NotBlank String roleCode +) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/user/dto/response/UserRoleResponse.java b/src/main/java/com/opensource/docgrid/domain/user/dto/response/UserRoleResponse.java new file mode 100644 index 0000000..26aa45a --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/user/dto/response/UserRoleResponse.java @@ -0,0 +1,18 @@ +package com.opensource.docgrid.domain.user.dto.response; + +import java.util.List; + +import com.opensource.docgrid.domain.user.entity.User; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record UserRoleResponse( + @Schema(description = "사용자 ID") Long userId, + @Schema(description = "이메일") String email, + @Schema(description = "이름") String name, + @Schema(description = "역할 목록") List roles +) { + public static UserRoleResponse of(User user, List roles) { + return new UserRoleResponse(user.getId(), user.getEmail(), user.getName(), roles); + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/user/service/command/UserRoleCommandService.java b/src/main/java/com/opensource/docgrid/domain/user/service/command/UserRoleCommandService.java new file mode 100644 index 0000000..6ccbe11 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/user/service/command/UserRoleCommandService.java @@ -0,0 +1,60 @@ +package com.opensource.docgrid.domain.user.service.command; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.opensource.docgrid.domain.user.dto.request.AssignRoleRequest; +import com.opensource.docgrid.domain.user.dto.response.UserRoleResponse; +import com.opensource.docgrid.domain.user.entity.Role; +import com.opensource.docgrid.domain.user.entity.User; +import com.opensource.docgrid.domain.user.entity.UserRole; +import com.opensource.docgrid.domain.user.repository.RoleRepository; +import com.opensource.docgrid.domain.user.repository.UserRepository; +import com.opensource.docgrid.domain.user.repository.UserRoleRepository; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +import lombok.RequiredArgsConstructor; + +@Transactional +@Service +@RequiredArgsConstructor +public class UserRoleCommandService { + + private final UserRepository userRepository; + private final RoleRepository roleRepository; + private final UserRoleRepository userRoleRepository; + + // 관리자가 다른 사용자에게 역할을 부여 + public UserRoleResponse assignRole(Long targetUserId, Long adminUserId, AssignRoleRequest request) { + // 타겟 사용자 조회 + User targetUser = userRepository.findById(targetUserId) + .orElseThrow(() -> new DocGridException(ErrorCode.USER_NOT_FOUND)); + + Role role = roleRepository.findByCode(request.roleCode()) + .orElseThrow(() -> new DocGridException(ErrorCode.ROLE_NOT_FOUND)); + + if (userRoleRepository.existsByUserIdAndRoleCode(targetUserId, request.roleCode())) { + throw new DocGridException(ErrorCode.ROLE_ALREADY_ASSIGNED); + } + + User admin = userRepository.getReferenceById(adminUserId); + + UserRole userRole = UserRole.builder() + .user(targetUser) + .role(role) + .assignedBy(admin) // 누가 역할을 부여했는지 기록 + .assignedAt(LocalDateTime.now()) + .build(); + userRoleRepository.save(userRole); + + List roles = userRoleRepository.findAllWithRoleByUserId(targetUserId).stream() + .map(ur -> ur.getRole().getCode()) + .toList(); + + return UserRoleResponse.of(targetUser, roles); + } +} From c0f5b4f77ab84b615d16946386fbb58bc989c486 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Tue, 14 Jul 2026 19:32:00 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor:=20coderabbit=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JwtProvider.getClaimsIfValid()로 토큰 파싱 1회로 통합 (기존 4회 → 1회) - UserRoleRepository.existsByUserIdAndRoleCode @Query 제거, JPA 파생 쿼리로 대체 (DB LIMIT 1 최적화) Co-Authored-By: Claude Sonnet 4.6 --- .../auth/jwt/JwtAuthenticationFilter.java | 27 +++++++++------- .../docgrid/domain/auth/jwt/JwtProvider.java | 32 ++++--------------- .../user/repository/UserRoleRepository.java | 3 +- 3 files changed, 24 insertions(+), 38 deletions(-) diff --git a/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilter.java b/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilter.java index c3d6c2b..d1ffbf4 100644 --- a/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilter.java @@ -9,6 +9,7 @@ import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; +import io.jsonwebtoken.Claims; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -21,25 +22,29 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtProvider jwtProvider; @Override + @SuppressWarnings("unchecked") protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = resolveToken(request); - if (StringUtils.hasText(token) && jwtProvider.validateToken(token)) { - Long userId = jwtProvider.getUserId(token); - String email = jwtProvider.getEmail(token); - List roles = jwtProvider.getRoles(token); + if (StringUtils.hasText(token)) { + Claims claims = jwtProvider.getClaimsIfValid(token); + if (claims != null) { + Long userId = claims.get("userId", Long.class); + String email = claims.getSubject(); + List roles = (List) claims.get("roles"); - List authorities = roles.stream() - .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) - .toList(); + List authorities = roles.stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .toList(); - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(email, null, authorities); - authentication.setDetails(userId); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(email, null, authorities); + authentication.setDetails(userId); - SecurityContextHolder.getContext().setAuthentication(authentication); + SecurityContextHolder.getContext().setAuthentication(authentication); + } } filterChain.doFilter(request, response); diff --git a/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtProvider.java b/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtProvider.java index cc9baff..1b1ffa8 100644 --- a/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtProvider.java +++ b/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtProvider.java @@ -43,37 +43,19 @@ public String generateToken(Long userId, String email, List roles) { .compact(); } - public boolean validateToken(String token) { + public Claims getClaimsIfValid(String token) { try { - Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token); - return true; + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); } catch (JwtException | IllegalArgumentException e) { log.error("JWT 검증 실패: {}", e.getMessage()); - return false; + return null; } } - public Claims getClaims(String token) { - return Jwts.parser() - .verifyWith(secretKey) - .build() - .parseSignedClaims(token) - .getPayload(); - } - - public String getEmail(String token) { - return getClaims(token).getSubject(); - } - - public Long getUserId(String token) { - return getClaims(token).get("userId", Long.class); - } - - @SuppressWarnings("unchecked") - public List getRoles(String token) { - return getClaims(token).get("roles", List.class); - } - public long getExpirationSeconds() { return expirationSeconds; } diff --git a/src/main/java/com/opensource/docgrid/domain/user/repository/UserRoleRepository.java b/src/main/java/com/opensource/docgrid/domain/user/repository/UserRoleRepository.java index b049fdd..f124cc1 100644 --- a/src/main/java/com/opensource/docgrid/domain/user/repository/UserRoleRepository.java +++ b/src/main/java/com/opensource/docgrid/domain/user/repository/UserRoleRepository.java @@ -13,6 +13,5 @@ public interface UserRoleRepository extends JpaRepository { @Query("SELECT ur FROM UserRole ur JOIN FETCH ur.role WHERE ur.user.id = :userId") List findAllWithRoleByUserId(@Param("userId") Long userId); - @Query("SELECT COUNT(ur) > 0 FROM UserRole ur WHERE ur.user.id = :userId AND ur.role.code = :roleCode") - boolean existsByUserIdAndRoleCode(@Param("userId") Long userId, @Param("roleCode") String roleCode); + boolean existsByUserIdAndRoleCode(Long userId, String roleCode); }