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/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/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/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/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/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/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/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/auth/jwt/JwtAuthenticationFilter.java b/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..d1ffbf4 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,60 @@ +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 io.jsonwebtoken.Claims; +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 + @SuppressWarnings("unchecked") + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String token = resolveToken(request); + + 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(); + + 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..1b1ffa8 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtProvider.java @@ -0,0 +1,62 @@ +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 Claims getClaimsIfValid(String token) { + try { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (JwtException | IllegalArgumentException e) { + log.error("JWT 검증 실패: {}", e.getMessage()); + return null; + } + } + + public long getExpirationSeconds() { + return expirationSeconds; + } +} 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/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/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/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/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/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/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/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/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/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..f124cc1 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/user/repository/UserRoleRepository.java @@ -0,0 +1,17 @@ +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); + + boolean existsByUserIdAndRoleCode(Long userId, String roleCode); +} 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); + } +} 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(); + } +} 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/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); + } +} 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( 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} 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 +);