From 7884416e7514faedf92cf97315aee1c1f6e67e3b Mon Sep 17 00:00:00 2001 From: teddy Date: Wed, 3 Dec 2025 18:47:45 +0900 Subject: [PATCH] =?UTF-8?q?jwt=20=EC=86=8C=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 9 + .../cd/kamcoback/auth/AuthApiController.java | 151 --------------- .../auth/CustomAuthenticationProvider.java | 46 +++++ .../cd/kamcoback/auth/CustomUserDetails.java | 56 ++++++ .../auth/JBCryptPasswordEncoder.java | 21 +++ .../auth/JwtAuthenticationFilter.java | 49 +++++ .../cd/kamcoback/auth/JwtTokenProvider.java | 72 +++++++ .../kamcoback/auth/RefreshTokenService.java | 29 +++ .../kamco/cd/kamcoback/auth/dto/AuthDto.java | 178 ------------------ .../kamcoback/auth/service/AuthService.java | 71 ------- .../dto => common/enums}/RoleType.java | 2 +- .../common/exception/CustomApiException.java | 22 +++ .../config/GlobalExceptionHandler.java | 23 +++ .../cd/kamcoback/config/SecurityConfig.java | 53 ++++++ .../kamcoback/config/api/ApiResponseDto.java | 2 +- .../cd/kamcoback/members/AuthController.java | 171 +++++++++++++++++ .../members/MembersApiController.java | 7 +- .../kamcoback/members/dto/MemberDetails.java | 65 +++++++ .../cd/kamcoback/members/dto/MembersDto.java | 1 + .../members/service/MemberDetailsService.java | 27 +++ .../members/service/MembersService.java | 21 ++- .../postgres/core/AuthCoreService.java | 121 ------------ .../postgres/core/MembersCoreService.java | 14 +- .../postgres/entity/CommonCodeEntity.java | 24 +-- .../kamcoback/postgres/entity/UserEntity.java | 12 -- .../repository/auth/AuthRepository.java | 6 - .../repository/auth/AuthRepositoryCustom.java | 16 -- .../repository/auth/AuthRepositoryImpl.java | 94 --------- .../members/MembersRepositoryCustom.java | 6 +- .../members/MembersRepositoryImpl.java | 18 +- src/main/resources/application-dev.yml | 10 + src/main/resources/application-local.yml | 13 +- src/main/resources/application-prod.yml | 9 + 33 files changed, 738 insertions(+), 681 deletions(-) delete mode 100644 src/main/java/com/kamco/cd/kamcoback/auth/AuthApiController.java create mode 100644 src/main/java/com/kamco/cd/kamcoback/auth/CustomAuthenticationProvider.java create mode 100644 src/main/java/com/kamco/cd/kamcoback/auth/CustomUserDetails.java create mode 100644 src/main/java/com/kamco/cd/kamcoback/auth/JBCryptPasswordEncoder.java create mode 100644 src/main/java/com/kamco/cd/kamcoback/auth/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/kamco/cd/kamcoback/auth/JwtTokenProvider.java create mode 100644 src/main/java/com/kamco/cd/kamcoback/auth/RefreshTokenService.java delete mode 100644 src/main/java/com/kamco/cd/kamcoback/auth/dto/AuthDto.java delete mode 100644 src/main/java/com/kamco/cd/kamcoback/auth/service/AuthService.java rename src/main/java/com/kamco/cd/kamcoback/{members/dto => common/enums}/RoleType.java (90%) create mode 100644 src/main/java/com/kamco/cd/kamcoback/common/exception/CustomApiException.java create mode 100644 src/main/java/com/kamco/cd/kamcoback/config/SecurityConfig.java create mode 100644 src/main/java/com/kamco/cd/kamcoback/members/AuthController.java create mode 100644 src/main/java/com/kamco/cd/kamcoback/members/dto/MemberDetails.java create mode 100644 src/main/java/com/kamco/cd/kamcoback/members/service/MemberDetailsService.java delete mode 100644 src/main/java/com/kamco/cd/kamcoback/postgres/core/AuthCoreService.java delete mode 100644 src/main/java/com/kamco/cd/kamcoback/postgres/repository/auth/AuthRepository.java delete mode 100644 src/main/java/com/kamco/cd/kamcoback/postgres/repository/auth/AuthRepositoryCustom.java delete mode 100644 src/main/java/com/kamco/cd/kamcoback/postgres/repository/auth/AuthRepositoryImpl.java diff --git a/build.gradle b/build.gradle index 78660681..60e3428d 100644 --- a/build.gradle +++ b/build.gradle @@ -64,6 +64,15 @@ dependencies { // crypto implementation 'org.mindrot:jbcrypt:0.4' + // security + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-web' + + // JWT (jjwt 0.12.x) + implementation 'io.jsonwebtoken:jjwt-api:0.12.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5' // JSON (Jackson) + } tasks.named('test') { diff --git a/src/main/java/com/kamco/cd/kamcoback/auth/AuthApiController.java b/src/main/java/com/kamco/cd/kamcoback/auth/AuthApiController.java deleted file mode 100644 index 6ae7033c..00000000 --- a/src/main/java/com/kamco/cd/kamcoback/auth/AuthApiController.java +++ /dev/null @@ -1,151 +0,0 @@ -package com.kamco.cd.kamcoback.auth; - -import com.kamco.cd.kamcoback.auth.dto.AuthDto; -import com.kamco.cd.kamcoback.auth.dto.AuthDto.Basic; -import com.kamco.cd.kamcoback.auth.service.AuthService; -import com.kamco.cd.kamcoback.config.api.ApiResponseDto; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@Tag(name = "관리자 관리", description = "관리자 관리 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/auth") -public class AuthApiController { - - private final AuthService authService; - - @Operation(summary = "관리자 등록", description = "관리자를 등록 합니다.") - @ApiResponses( - value = { - @ApiResponse( - responseCode = "201", - description = "관리자 등록 성공", - content = - @Content( - mediaType = "application/json", - schema = @Schema(implementation = Long.class))), - @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터", content = @Content), - @ApiResponse(responseCode = "404", description = "코드를 찾을 수 없음", content = @Content), - @ApiResponse(responseCode = "500", description = "서버 오류", content = @Content) - }) - @PostMapping("/save") - public ApiResponseDto save( - @io.swagger.v3.oas.annotations.parameters.RequestBody( - description = "관리자 정보", - required = true, - content = - @Content( - mediaType = "application/json", - schema = @Schema(implementation = AuthDto.SaveReq.class))) - @RequestBody - @Valid - AuthDto.SaveReq saveReq) { - return ApiResponseDto.createOK(authService.save(saveReq).getId()); - } - - @Operation(summary = "관리자 정보 수정", description = "관리자 정보를 수정 합니다.") - @ApiResponses( - value = { - @ApiResponse( - responseCode = "201", - description = "관리자 정보 수정 성공", - content = - @Content( - mediaType = "application/json", - schema = @Schema(implementation = Long.class))), - @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터", content = @Content), - @ApiResponse(responseCode = "404", description = "코드를 찾을 수 없음", content = @Content), - @ApiResponse(responseCode = "500", description = "서버 오류", content = @Content) - }) - @PutMapping("/update/{id}") - public ApiResponseDto update(@PathVariable Long id, @RequestBody AuthDto.SaveReq saveReq) { - return ApiResponseDto.createOK(authService.update(id, saveReq).getId()); - } - - @Operation(summary = "관리자 정보 탈퇴처리", description = "관리자 정보를 탈퇴처리 합니다.") - @ApiResponses( - value = { - @ApiResponse( - responseCode = "201", - description = "관리자 탈퇴처리 성공", - content = - @Content( - mediaType = "application/json", - schema = @Schema(implementation = Long.class))), - @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터", content = @Content), - @ApiResponse(responseCode = "404", description = "코드를 찾을 수 없음", content = @Content), - @ApiResponse(responseCode = "500", description = "서버 오류", content = @Content) - }) - @PutMapping("/withdrawal/{id}") - public ApiResponseDto withdrawal(@PathVariable Long id) { - return ApiResponseDto.deleteOk(authService.withdrawal(id).getId()); - } - - @ApiResponses( - value = { - @ApiResponse( - responseCode = "200", - description = "조회 성공", - content = - @Content( - mediaType = "application/json", - schema = @Schema(implementation = AuthDto.Basic.class))), - @ApiResponse(responseCode = "404", description = "코드를 찾을 수 없음", content = @Content), - @ApiResponse(responseCode = "500", description = "서버 오류", content = @Content) - }) - @Operation(summary = "관리자 상세조회", description = "관리자 정보를 조회 합니다.") - @GetMapping("/detail") - public ApiResponseDto getDetail( - @io.swagger.v3.oas.annotations.parameters.RequestBody( - description = "관리자 목록 id", - required = true) - @RequestParam - Long id) { - return ApiResponseDto.ok(authService.getFindUserById(id)); - } - - @Operation(summary = "관리자 목록", description = "관리자 목록 조회") - @ApiResponses( - value = { - @ApiResponse( - responseCode = "200", - description = "검색 성공", - content = - @Content( - mediaType = "application/json", - schema = @Schema(implementation = Page.class))), - @ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content), - @ApiResponse(responseCode = "500", description = "서버 오류", content = @Content) - }) - @GetMapping("/list") - public ApiResponseDto> getUserList( - @Parameter(description = "관리자 이름") @RequestParam(required = false) String userNm, - @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(defaultValue = "0") - int page, - @Parameter(description = "페이지 크기", example = "20") @RequestParam(defaultValue = "20") - int size, - @Parameter(description = "정렬 조건 (형식: 필드명,방향)", example = "name,asc") - @RequestParam(required = false) - String sort) { - AuthDto.SearchReq searchReq = new AuthDto.SearchReq(userNm, page, size, sort); - Page userList = authService.getUserList(searchReq); - return ApiResponseDto.ok(userList); - } -} diff --git a/src/main/java/com/kamco/cd/kamcoback/auth/CustomAuthenticationProvider.java b/src/main/java/com/kamco/cd/kamcoback/auth/CustomAuthenticationProvider.java new file mode 100644 index 00000000..33b440ad --- /dev/null +++ b/src/main/java/com/kamco/cd/kamcoback/auth/CustomAuthenticationProvider.java @@ -0,0 +1,46 @@ +package com.kamco.cd.kamcoback.auth; + +import com.kamco.cd.kamcoback.postgres.entity.MemberEntity; +import com.kamco.cd.kamcoback.postgres.repository.members.MembersRepository; +import lombok.RequiredArgsConstructor; +import org.mindrot.jbcrypt.BCrypt; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CustomAuthenticationProvider implements AuthenticationProvider { + + private final MembersRepository membersRepository; + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + String username = authentication.getName(); + String rawPassword = authentication.getCredentials().toString(); + + // 1. 유저 조회 + MemberEntity member = + membersRepository + .findByEmployeeNo(username) + .orElseThrow(() -> new BadCredentialsException("ID 또는 비밀번호가 일치하지 않습니다.")); + + // 2. jBCrypt + 커스텀 salt 로 저장된 패스워드 비교 + if (!BCrypt.checkpw(rawPassword, member.getPassword())) { + throw new BadCredentialsException("ID 또는 비밀번호가 일치하지 않습니다."); + } + + // 3. 인증 성공 → UserDetails 생성 + CustomUserDetails userDetails = new CustomUserDetails(member); + + return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + } + + @Override + public boolean supports(Class authentication) { + return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication); + } +} diff --git a/src/main/java/com/kamco/cd/kamcoback/auth/CustomUserDetails.java b/src/main/java/com/kamco/cd/kamcoback/auth/CustomUserDetails.java new file mode 100644 index 00000000..deac82c7 --- /dev/null +++ b/src/main/java/com/kamco/cd/kamcoback/auth/CustomUserDetails.java @@ -0,0 +1,56 @@ +package com.kamco.cd.kamcoback.auth; + +import com.kamco.cd.kamcoback.postgres.entity.MemberEntity; +import java.util.Collection; +import java.util.Collections; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +public class CustomUserDetails implements UserDetails { + + private final MemberEntity member; + + public CustomUserDetails(MemberEntity member) { + this.member = member; + } + + @Override + public Collection getAuthorities() { + // 권한을 Member에서 가져오는 경우 바꾸면 됩니다 — 일단 기본값 + return Collections.emptyList(); + } + + @Override + public String getPassword() { + return member.getPassword(); + } + + @Override + public String getUsername() { + return String.valueOf(member.getUuid()); + } + + @Override + public boolean isAccountNonExpired() { + return true; // 추후 상태 필드에 따라 수정 가능 + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return member.getStatus().equalsIgnoreCase("ACTIVE"); + } + + public MemberEntity getMember() { + return member; + } +} diff --git a/src/main/java/com/kamco/cd/kamcoback/auth/JBCryptPasswordEncoder.java b/src/main/java/com/kamco/cd/kamcoback/auth/JBCryptPasswordEncoder.java new file mode 100644 index 00000000..d3413004 --- /dev/null +++ b/src/main/java/com/kamco/cd/kamcoback/auth/JBCryptPasswordEncoder.java @@ -0,0 +1,21 @@ +package com.kamco.cd.kamcoback.auth; + +import org.mindrot.jbcrypt.BCrypt; +import org.springframework.security.crypto.password.PasswordEncoder; + +public class JBCryptPasswordEncoder implements PasswordEncoder { + + @Override + public String encode(CharSequence rawPassword) { + throw new UnsupportedOperationException("custom salt 사용"); + } + + @Override + public boolean matches(CharSequence rawPassword, String encodedPassword) { + if (encodedPassword == null || encodedPassword.isBlank()) { + return false; + } + + return BCrypt.checkpw(rawPassword.toString(), encodedPassword); + } +} diff --git a/src/main/java/com/kamco/cd/kamcoback/auth/JwtAuthenticationFilter.java b/src/main/java/com/kamco/cd/kamcoback/auth/JwtAuthenticationFilter.java new file mode 100644 index 00000000..42d87d9d --- /dev/null +++ b/src/main/java/com/kamco/cd/kamcoback/auth/JwtAuthenticationFilter.java @@ -0,0 +1,49 @@ +package com.kamco.cd.kamcoback.auth; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + private final UserDetailsService userDetailsService; + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String token = resolveToken(request); + + if (token != null && jwtTokenProvider.isValidToken(token)) { + String username = jwtTokenProvider.getSubject(token); + + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String bearer = request.getHeader("Authorization"); + if (bearer != null && bearer.startsWith("Bearer ")) { + return bearer.substring(7); + } + return null; + } +} diff --git a/src/main/java/com/kamco/cd/kamcoback/auth/JwtTokenProvider.java b/src/main/java/com/kamco/cd/kamcoback/auth/JwtTokenProvider.java new file mode 100644 index 00000000..3c1b87df --- /dev/null +++ b/src/main/java/com/kamco/cd/kamcoback/auth/JwtTokenProvider.java @@ -0,0 +1,72 @@ +package com.kamco.cd.kamcoback.auth; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import javax.crypto.SecretKey; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class JwtTokenProvider { + + @Value("${jwt.secret}") + private String secret; + + @Value("${jwt.access-token-validity-in-ms}") + private long accessTokenValidityInMs; + + @Value("${jwt.refresh-token-validity-in-ms}") + private long refreshTokenValidityInMs; + + private SecretKey key; + + @PostConstruct + public void init() { + // HS256용 SecretKey + this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + public String createAccessToken(String subject) { + return createToken(subject, accessTokenValidityInMs); + } + + public String createRefreshToken(String subject) { + return createToken(subject, refreshTokenValidityInMs); + } + + private String createToken(String subject, long validityInMs) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + validityInMs); + return Jwts.builder().subject(subject).issuedAt(now).expiration(expiry).signWith(key).compact(); + } + + public String getSubject(String token) { + var claims = parseClaims(token).getPayload(); + return claims.getSubject(); + } + + public boolean isValidToken(String token) { + try { + Jws claims = parseClaims(token); + return !claims.getPayload().getExpiration().before(new Date()); + } catch (Exception e) { + return false; + } + } + + private Jws parseClaims(String token) { + return Jwts.parser() + .verifyWith(key) // SecretKey 타입 + .build() + .parseSignedClaims(token); + } + + public long getRefreshTokenValidityInMs() { + return refreshTokenValidityInMs; + } +} diff --git a/src/main/java/com/kamco/cd/kamcoback/auth/RefreshTokenService.java b/src/main/java/com/kamco/cd/kamcoback/auth/RefreshTokenService.java new file mode 100644 index 00000000..79e02ce6 --- /dev/null +++ b/src/main/java/com/kamco/cd/kamcoback/auth/RefreshTokenService.java @@ -0,0 +1,29 @@ +package com.kamco.cd.kamcoback.auth; + +import java.time.Duration; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RefreshTokenService { + + private final StringRedisTemplate redisTemplate; + private static final String PREFIX = "RT:"; + + public void save(String username, String refreshToken, long ttlMillis) { + ValueOperations ops = redisTemplate.opsForValue(); + ops.set(PREFIX + username, refreshToken, Duration.ofMillis(ttlMillis)); + } + + public boolean validate(String username, String refreshToken) { + String stored = redisTemplate.opsForValue().get(PREFIX + username); + return stored != null && stored.equals(refreshToken); + } + + public void delete(String username) { + redisTemplate.delete(PREFIX + username); + } +} diff --git a/src/main/java/com/kamco/cd/kamcoback/auth/dto/AuthDto.java b/src/main/java/com/kamco/cd/kamcoback/auth/dto/AuthDto.java deleted file mode 100644 index 939ff27b..00000000 --- a/src/main/java/com/kamco/cd/kamcoback/auth/dto/AuthDto.java +++ /dev/null @@ -1,178 +0,0 @@ -package com.kamco.cd.kamcoback.auth.dto; - -import com.kamco.cd.kamcoback.common.utils.interfaces.JsonFormatDttm; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; -import java.time.ZonedDateTime; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.RequiredArgsConstructor; -import lombok.Setter; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; - -@RequiredArgsConstructor -public class AuthDto { - - @Getter - @Setter - public static class Basic { - - private Long id; - private String userAuth; - private String userNm; - private String userId; - private String empId; - private String userEmail; - @JsonFormatDttm private ZonedDateTime createdDttm; - - public Basic( - Long id, - String userAuth, - String userNm, - String userId, - String empId, - String userEmail, - ZonedDateTime createdDttm) { - this.id = id; - this.userAuth = userAuth; - this.userNm = userNm; - this.userId = userId; - this.empId = empId; - this.userEmail = userEmail; - this.createdDttm = createdDttm; - } - } - - @Schema(name = "save request", description = "사용자 등록 정보") - @Getter - @Setter - public static class SaveReq { - - @Schema(description = "구분", example = "관리자/라벨러/검수자 중 하나") - @NotBlank - private String userAuth; - - @NotBlank - @Schema(description = "이름", example = "홍길동") - private String userNm; - - @Schema(description = "ID", example = "gildong") - @NotBlank - private String userId; - - @Schema(description = "PW", example = "password") - @NotBlank - private String userPw; - - @Schema(description = "사번", example = "사번") - @NotBlank - private String empId; - - @Schema(description = "이메일", example = "gildong@naver.com") - @NotBlank - private String userEmail; - - public SaveReq( - String userAuth, - String userNm, - String userId, - String userPw, - String empId, - String userEmail) { - this.userAuth = userAuth; - this.userNm = userNm; - this.userId = userId; - this.userPw = userPw; - this.empId = empId; - this.userEmail = userEmail; - } - } - - @Schema(name = "update request", description = "사용자 수정 정보") - @Getter - @Setter - public static class UpdateReq { - - @Schema(description = "id", example = "1") - @NotBlank - private Long id; - - @Schema(description = "구분", example = "관리자/라벨러/검수자 중 하나") - @NotBlank - private String userAuth; - - @NotBlank - @Schema(description = "이름", example = "홍길동") - private String userNm; - - @Schema(description = "ID", example = "gildong") - @NotBlank - private String userId; - - @Schema(description = "PW", example = "password") - @NotBlank - private String userPw; - - @Schema(description = "사번", example = "사번") - @NotBlank - private String empId; - - @Schema(description = "이메일", example = "gildong@naver.com") - @NotBlank - private String userEmail; - - public UpdateReq( - Long id, - String userAuth, - String userNm, - String userId, - String userPw, - String empId, - String userEmail) { - this.id = id; - this.userAuth = userAuth; - this.userNm = userNm; - this.userId = userId; - this.userPw = userPw; - this.empId = empId; - this.userEmail = userEmail; - } - } - - @Getter - public static class User { - - String userId; - String userPw; - } - - @Schema(name = "UserSearchReq", description = "관리자 목록 요청 정보") - @Getter - @Setter - @NoArgsConstructor - @AllArgsConstructor - public static class SearchReq { - - // 검색 조건 - private String userNm; - - // 페이징 파라미터 - private int page = 0; - private int size = 20; - private String sort; - - public Pageable toPageable() { - if (sort != null && !sort.isEmpty()) { - String[] sortParams = sort.split(","); - String property = sortParams[0]; - Sort.Direction direction = - sortParams.length > 1 ? Sort.Direction.fromString(sortParams[1]) : Sort.Direction.ASC; - return PageRequest.of(page, size, Sort.by(direction, property)); - } - return PageRequest.of(page, size); - } - } -} diff --git a/src/main/java/com/kamco/cd/kamcoback/auth/service/AuthService.java b/src/main/java/com/kamco/cd/kamcoback/auth/service/AuthService.java deleted file mode 100644 index 232df4bd..00000000 --- a/src/main/java/com/kamco/cd/kamcoback/auth/service/AuthService.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.kamco.cd.kamcoback.auth.service; - -import com.kamco.cd.kamcoback.auth.dto.AuthDto; -import com.kamco.cd.kamcoback.auth.dto.AuthDto.Basic; -import com.kamco.cd.kamcoback.postgres.core.AuthCoreService; -import com.kamco.cd.kamcoback.postgres.entity.UserEntity; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@Transactional(readOnly = true) -@RequiredArgsConstructor -public class AuthService { - - private final AuthCoreService authCoreService; - - /** - * 관리자 등록 - * - * @param saveReq - * @return - */ - @Transactional - public UserEntity save(AuthDto.SaveReq saveReq) { - return authCoreService.save(saveReq); - } - - /** - * 관리자 정보 수정 - * - * @param id - * @param saveReq - * @return - */ - public UserEntity update(Long id, AuthDto.SaveReq saveReq) { - if (saveReq.getUserPw() != null) {} - return authCoreService.update(id, saveReq); - } - - /** - * 관리자 삭제 - * - * @param id - * @return - */ - public UserEntity withdrawal(Long id) { - return authCoreService.withdrawal(id); - } - - /** - * 시퀀스 id로 관리자 조회 - * - * @param id - * @return - */ - public AuthDto.Basic getFindUserById(Long id) { - return authCoreService.findUserById(id); - } - - /** - * 관리자 목록 조회 - * - * @param searchReq - * @return - */ - public Page getUserList(AuthDto.SearchReq searchReq) { - return authCoreService.getUserList(searchReq); - } -} diff --git a/src/main/java/com/kamco/cd/kamcoback/members/dto/RoleType.java b/src/main/java/com/kamco/cd/kamcoback/common/enums/RoleType.java similarity index 90% rename from src/main/java/com/kamco/cd/kamcoback/members/dto/RoleType.java rename to src/main/java/com/kamco/cd/kamcoback/common/enums/RoleType.java index 59535f39..3da213ea 100644 --- a/src/main/java/com/kamco/cd/kamcoback/members/dto/RoleType.java +++ b/src/main/java/com/kamco/cd/kamcoback/common/enums/RoleType.java @@ -1,4 +1,4 @@ -package com.kamco.cd.kamcoback.members.dto; +package com.kamco.cd.kamcoback.common.enums; import com.kamco.cd.kamcoback.config.enums.EnumType; import lombok.AllArgsConstructor; diff --git a/src/main/java/com/kamco/cd/kamcoback/common/exception/CustomApiException.java b/src/main/java/com/kamco/cd/kamcoback/common/exception/CustomApiException.java new file mode 100644 index 00000000..482d5f6c --- /dev/null +++ b/src/main/java/com/kamco/cd/kamcoback/common/exception/CustomApiException.java @@ -0,0 +1,22 @@ +package com.kamco.cd.kamcoback.common.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class CustomApiException extends RuntimeException { + + private final String codeName; // ApiResponseCode enum name과 맞추는 용도 (예: "UNPROCESSABLE_ENTITY") + private final HttpStatus status; // 응답으로 내려줄 HttpStatus + + public CustomApiException(String codeName, HttpStatus status, String message) { + super(message); + this.codeName = codeName; + this.status = status; + } + + public CustomApiException(String codeName, HttpStatus status) { + this.codeName = codeName; + this.status = status; + } +} diff --git a/src/main/java/com/kamco/cd/kamcoback/config/GlobalExceptionHandler.java b/src/main/java/com/kamco/cd/kamcoback/config/GlobalExceptionHandler.java index 76e349c1..7c3900b1 100644 --- a/src/main/java/com/kamco/cd/kamcoback/config/GlobalExceptionHandler.java +++ b/src/main/java/com/kamco/cd/kamcoback/config/GlobalExceptionHandler.java @@ -1,5 +1,6 @@ package com.kamco.cd.kamcoback.config; +import com.kamco.cd.kamcoback.common.exception.CustomApiException; import com.kamco.cd.kamcoback.config.api.ApiLogFunction; import com.kamco.cd.kamcoback.config.api.ApiResponseDto; import com.kamco.cd.kamcoback.config.api.ApiResponseDto.ApiResponseCode; @@ -19,6 +20,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.core.annotation.Order; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -366,4 +368,25 @@ public class GlobalExceptionHandler { return errorLogRepository.save(errorLogEntity); } + + @ExceptionHandler(CustomApiException.class) + public ResponseEntity> handleCustomApiException( + CustomApiException e, HttpServletRequest request) { + log.warn("[CustomApiException] resource : {}", e.getMessage()); + + String codeName = e.getCodeName(); + HttpStatus status = e.getStatus(); + String message = e.getMessage() == null ? ApiResponseCode.getMessage(codeName) : e.getMessage(); + + ApiResponseCode apiCode = ApiResponseCode.getCode(codeName); + + ErrorLogEntity errorLog = + saveErrerLogData( + request, apiCode, status, ErrorLogDto.LogErrorLevel.WARNING, e.getStackTrace()); + + ApiResponseDto body = + ApiResponseDto.createException(apiCode, message, status, errorLog.getId()); + + return new ResponseEntity<>(body, status); + } } diff --git a/src/main/java/com/kamco/cd/kamcoback/config/SecurityConfig.java b/src/main/java/com/kamco/cd/kamcoback/config/SecurityConfig.java new file mode 100644 index 00000000..3bb3f240 --- /dev/null +++ b/src/main/java/com/kamco/cd/kamcoback/config/SecurityConfig.java @@ -0,0 +1,53 @@ +package com.kamco.cd.kamcoback.config; + +import com.kamco.cd.kamcoback.auth.CustomAuthenticationProvider; +import com.kamco.cd.kamcoback.auth.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CustomAuthenticationProvider customAuthenticationProvider; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.csrf(csrf -> csrf.disable()) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .formLogin(form -> form.disable()) + .httpBasic(basic -> basic.disable()) + .logout(logout -> logout.disable()) + // 🔥 여기서 우리가 만든 CustomAuthenticationProvider 하나만 등록 + .authenticationProvider(customAuthenticationProvider) + .authorizeHttpRequests( + auth -> + auth.requestMatchers( + "/api/auth/signin", + "/api/auth/refresh", + "/swagger-ui/**", + "/v3/api-docs/**") + .permitAll() + .anyRequest() + .authenticated()) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) + throws Exception { + return configuration.getAuthenticationManager(); + } +} diff --git a/src/main/java/com/kamco/cd/kamcoback/config/api/ApiResponseDto.java b/src/main/java/com/kamco/cd/kamcoback/config/api/ApiResponseDto.java index c27d3cb2..8c40b5eb 100644 --- a/src/main/java/com/kamco/cd/kamcoback/config/api/ApiResponseDto.java +++ b/src/main/java/com/kamco/cd/kamcoback/config/api/ApiResponseDto.java @@ -134,7 +134,7 @@ public class ApiResponseDto { FAIL_VERIFICATION("인증에 실패하였습니다."), INVALID_EMAIL("잘못된 형식의 이메일입니다."), REQUIRED_EMAIL("이메일은 필수 항목입니다."), - WRONG_PASSWORD("잘못된 패스워드입니다.."), + WRONG_PASSWORD("잘못된 패스워드입니다."), DUPLICATE_EMAIL("이미 가입된 이메일입니다."), DUPLICATE_DATA("이미 등록되어 있습니다."), DATA_INTEGRITY_ERROR("데이터 무결성이 위반되어 요청을 처리할수 없습니다."), diff --git a/src/main/java/com/kamco/cd/kamcoback/members/AuthController.java b/src/main/java/com/kamco/cd/kamcoback/members/AuthController.java new file mode 100644 index 00000000..55f99170 --- /dev/null +++ b/src/main/java/com/kamco/cd/kamcoback/members/AuthController.java @@ -0,0 +1,171 @@ +package com.kamco.cd.kamcoback.members; + +import com.kamco.cd.kamcoback.auth.JwtTokenProvider; +import com.kamco.cd.kamcoback.auth.RefreshTokenService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import java.time.Duration; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.web.ErrorResponse; +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; + +@Tag(name = "인증(Auth)", description = "로그인, 토큰 재발급, 로그아웃 API") +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthenticationManager authenticationManager; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenService refreshTokenService; + + @Value("${token.refresh-cookie-name}") + private String refreshCookieName; + + @Value("${token.refresh-cookie-secure:true}") + private boolean refreshCookieSecure; + + @PostMapping("/signin") + @Operation(summary = "로그인", description = "사번 또는 이메일과 비밀번호로 로그인하여 액세스/리프레시 토큰을 발급합니다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "로그인 성공", + content = @Content(schema = @Schema(implementation = TokenResponse.class))), + @ApiResponse( + responseCode = "401", + description = "ID 또는 비밀번호 불일치", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public ResponseEntity signin( + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "로그인 요청 정보", + required = true) + @RequestBody + SignInRequest request, + HttpServletResponse response) { + Authentication authentication = + authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.username(), request.password())); + + String username = authentication.getName(); // UserDetailsService 에서 사용한 username + + String accessToken = jwtTokenProvider.createAccessToken(username); + String refreshToken = jwtTokenProvider.createRefreshToken(username); + + // Redis에 RefreshToken 저장 (TTL = 7일) + refreshTokenService.save( + username, refreshToken, jwtTokenProvider.getRefreshTokenValidityInMs()); + + // HttpOnly + Secure 쿠키에 RefreshToken 저장 + ResponseCookie cookie = + ResponseCookie.from(refreshCookieName, refreshToken) + .httpOnly(true) + .secure(refreshCookieSecure) // 로컬 개발에서 http만 쓰면 false 로 바꿔야 할 수도 있음 + .path("/") + .maxAge(Duration.ofMillis(jwtTokenProvider.getRefreshTokenValidityInMs())) + .sameSite("Strict") + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + + return ResponseEntity.ok(new TokenResponse(accessToken)); + } + + @PostMapping("/refresh") + @Operation(summary = "토큰 재발급", description = "리프레시 토큰으로 새로운 액세스/리프레시 토큰을 재발급합니다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "재발급 성공", + content = @Content(schema = @Schema(implementation = TokenResponse.class))), + @ApiResponse( + responseCode = "401", + description = "만료되었거나 유효하지 않은 리프레시 토큰", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public ResponseEntity refresh(String refreshToken, HttpServletResponse response) { + if (refreshToken == null || !jwtTokenProvider.isValidToken(refreshToken)) { + return ResponseEntity.status(401).build(); + } + + String username = jwtTokenProvider.getSubject(refreshToken); + + // Redis에 저장된 RefreshToken과 일치하는지 확인 + if (!refreshTokenService.validate(username, refreshToken)) { + return ResponseEntity.status(401).build(); + } + + // 새 토큰 발급 + String newAccessToken = jwtTokenProvider.createAccessToken(username); + String newRefreshToken = jwtTokenProvider.createRefreshToken(username); + + // Redis 갱신 + refreshTokenService.save( + username, newRefreshToken, jwtTokenProvider.getRefreshTokenValidityInMs()); + + // 쿠키 갱신 + ResponseCookie cookie = + ResponseCookie.from(refreshCookieName, newRefreshToken) + .httpOnly(true) + .secure(refreshCookieSecure) + .path("/") + .maxAge(Duration.ofMillis(jwtTokenProvider.getRefreshTokenValidityInMs())) + .sameSite("Strict") + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + + return ResponseEntity.ok(new TokenResponse(newAccessToken)); + } + + @PostMapping("/logout") + @Operation(summary = "로그아웃", description = "현재 사용자의 토큰을 무효화(블랙리스트 처리 또는 리프레시 토큰 삭제)합니다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "로그아웃 성공", + content = @Content(schema = @Schema(implementation = Void.class))) + }) + public ResponseEntity logout(Authentication authentication, HttpServletResponse response) { + if (authentication != null) { + String username = authentication.getName(); + // Redis에서 RefreshToken 삭제 + refreshTokenService.delete(username); + } + + // 쿠키 삭제 (Max-Age=0) + ResponseCookie cookie = + ResponseCookie.from(refreshCookieName, "") + .httpOnly(true) + .secure(refreshCookieSecure) + .path("/") + .maxAge(0) + .sameSite("Strict") + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + + return ResponseEntity.noContent().build(); + } + + @Schema(description = "로그인 요청 DTO") + public record SignInRequest( + @Schema(description = "사번", example = "11111") String username, + @Schema(description = "비밀번호", example = "kamco1234!") String password) {} + + public record TokenResponse(String accessToken) {} +} diff --git a/src/main/java/com/kamco/cd/kamcoback/members/MembersApiController.java b/src/main/java/com/kamco/cd/kamcoback/members/MembersApiController.java index 6d4287b4..78eeb10f 100644 --- a/src/main/java/com/kamco/cd/kamcoback/members/MembersApiController.java +++ b/src/main/java/com/kamco/cd/kamcoback/members/MembersApiController.java @@ -12,7 +12,9 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import java.util.UUID; import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.data.domain.Page; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -40,8 +42,9 @@ public class MembersApiController { @ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content), @ApiResponse(responseCode = "500", description = "서버 오류", content = @Content) }) - @PostMapping("/list") - public ApiResponseDto> getMemberList(@RequestBody MembersDto.SearchReq searchReq) { + @GetMapping + public ApiResponseDto> getMemberList( + @ParameterObject MembersDto.SearchReq searchReq) { return ApiResponseDto.ok(membersService.findByMembers(searchReq)); } diff --git a/src/main/java/com/kamco/cd/kamcoback/members/dto/MemberDetails.java b/src/main/java/com/kamco/cd/kamcoback/members/dto/MemberDetails.java new file mode 100644 index 00000000..ad945dc9 --- /dev/null +++ b/src/main/java/com/kamco/cd/kamcoback/members/dto/MemberDetails.java @@ -0,0 +1,65 @@ +package com.kamco.cd.kamcoback.members.dto; + +import com.kamco.cd.kamcoback.postgres.entity.MemberEntity; +import java.util.Collection; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +@RequiredArgsConstructor +public class MemberDetails implements UserDetails { + + private final MemberEntity member; + + @Override + public Collection getAuthorities() { + // TODO: tb_member_role 에서 역할 꺼내서 권한으로 변환하고 싶으면 여기 구현 + // 예시 (나중에 MemberRoleEntity 보고 수정): + // return member.getTbMemberRoles().stream() + // .map(role -> new SimpleGrantedAuthority("ROLE_" + role.getRoleName())) + // .toList(); + + return List.of(); // 일단 빈 권한 리스트 + } + + @Override + public String getPassword() { + return member.getPassword(); // 암호화된 비밀번호 + } + + @Override + public String getUsername() { + // 로그인 ID 로 무엇을 쓸지 선택 + // 1) 이메일 로그인: + return member.getEmail(); + + // 2) 사번으로 로그인하고 싶으면: + // return member.getEmployeeNo(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + // status 가 ACTIVE 일 때만 로그인 허용 + return "ACTIVE".equalsIgnoreCase(member.getStatus()); + } + + public MemberEntity getMember() { + return member; + } +} diff --git a/src/main/java/com/kamco/cd/kamcoback/members/dto/MembersDto.java b/src/main/java/com/kamco/cd/kamcoback/members/dto/MembersDto.java index 063f4172..2137cb59 100644 --- a/src/main/java/com/kamco/cd/kamcoback/members/dto/MembersDto.java +++ b/src/main/java/com/kamco/cd/kamcoback/members/dto/MembersDto.java @@ -1,5 +1,6 @@ package com.kamco.cd.kamcoback.members.dto; +import com.kamco.cd.kamcoback.common.enums.RoleType; import com.kamco.cd.kamcoback.common.utils.interfaces.EnumValid; import com.kamco.cd.kamcoback.common.utils.interfaces.JsonFormatDttm; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/com/kamco/cd/kamcoback/members/service/MemberDetailsService.java b/src/main/java/com/kamco/cd/kamcoback/members/service/MemberDetailsService.java new file mode 100644 index 00000000..b0bc258d --- /dev/null +++ b/src/main/java/com/kamco/cd/kamcoback/members/service/MemberDetailsService.java @@ -0,0 +1,27 @@ +package com.kamco.cd.kamcoback.members.service; + +import com.kamco.cd.kamcoback.auth.CustomUserDetails; +import com.kamco.cd.kamcoback.postgres.entity.MemberEntity; +import com.kamco.cd.kamcoback.postgres.repository.members.MembersRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MemberDetailsService implements UserDetailsService { + + private final MembersRepository membersRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + MemberEntity member = + membersRepository + .findByEmployeeNo(username) + .orElseThrow(() -> new UsernameNotFoundException("USER NOT FOUND")); + + return new CustomUserDetails(member); + } +} diff --git a/src/main/java/com/kamco/cd/kamcoback/members/service/MembersService.java b/src/main/java/com/kamco/cd/kamcoback/members/service/MembersService.java index 78587f18..943f331a 100644 --- a/src/main/java/com/kamco/cd/kamcoback/members/service/MembersService.java +++ b/src/main/java/com/kamco/cd/kamcoback/members/service/MembersService.java @@ -1,15 +1,17 @@ package com.kamco.cd.kamcoback.members.service; +import com.kamco.cd.kamcoback.common.exception.CustomApiException; import com.kamco.cd.kamcoback.config.BCryptSaltGenerator; import com.kamco.cd.kamcoback.members.dto.MembersDto; import com.kamco.cd.kamcoback.members.dto.MembersDto.Basic; import com.kamco.cd.kamcoback.postgres.core.MembersCoreService; import java.util.UUID; +import java.util.regex.Pattern; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.mindrot.jbcrypt.BCrypt; import org.springframework.data.domain.Page; -import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -40,8 +42,12 @@ public class MembersService { if (StringUtils.isNotBlank(updateReq.getPassword())) { + if (!this.isValidPassword(updateReq.getPassword())) { + throw new CustomApiException("WRONG_PASSWORD", HttpStatus.BAD_REQUEST); + } + if (StringUtils.isBlank(updateReq.getEmployeeNo())) { - throw new HttpMessageNotReadableException("패스워드 변경시 사번은 필수 값입니다."); + throw new CustomApiException("BAD_REQUEST", HttpStatus.BAD_REQUEST); } // salt 생성, 사번이 salt @@ -55,4 +61,15 @@ public class MembersService { membersCoreService.updateMembers(uuid, updateReq); } + + /** + * 대문자 1개 이상 소문자 1개 이상 숫자 1개 이상 특수문자(!@#$) 1개 이상 + * + * @param password + * @return + */ + private boolean isValidPassword(String password) { + String regex = "^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d)(?=.*[!@#$]).{8,20}$"; + return Pattern.matches(regex, password); + } } diff --git a/src/main/java/com/kamco/cd/kamcoback/postgres/core/AuthCoreService.java b/src/main/java/com/kamco/cd/kamcoback/postgres/core/AuthCoreService.java deleted file mode 100644 index 72c55c89..00000000 --- a/src/main/java/com/kamco/cd/kamcoback/postgres/core/AuthCoreService.java +++ /dev/null @@ -1,121 +0,0 @@ -package com.kamco.cd.kamcoback.postgres.core; - -import com.kamco.cd.kamcoback.auth.dto.AuthDto; -import com.kamco.cd.kamcoback.auth.dto.AuthDto.SaveReq; -import com.kamco.cd.kamcoback.postgres.entity.UserEntity; -import com.kamco.cd.kamcoback.postgres.repository.auth.AuthRepository; -import jakarta.persistence.EntityNotFoundException; -import java.time.ZonedDateTime; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class AuthCoreService { - - private final AuthRepository authRepository; - - /** - * 관리자 등록 - * - * @param saveReq - * @return - */ - public UserEntity save(SaveReq saveReq) { - if (authRepository.findByUserId(saveReq.getUserId()).isPresent()) { - new EntityNotFoundException("중복된 아이디가 있습니다. " + saveReq.getUserId()); - } - - UserEntity userEntity = - new UserEntity( - null, - saveReq.getUserAuth(), - saveReq.getUserNm(), - saveReq.getUserId(), - saveReq.getEmpId(), - saveReq.getUserEmail(), - saveReq.getUserPw()); - - return authRepository.save(userEntity); - } - - /** - * 관리자 정보 수정 - * - * @param id - * @param saveReq - * @return - */ - public UserEntity update(Long id, AuthDto.SaveReq saveReq) { - UserEntity userEntity = - authRepository.findById(id).orElseThrow(() -> new RuntimeException("유저가 존재하지 않습니다.")); - - if (saveReq.getUserAuth() != null) { - userEntity.setUserAuth(saveReq.getUserAuth()); - } - - if (saveReq.getUserNm() != null) { - userEntity.setUserNm(saveReq.getUserNm()); - } - - if (saveReq.getUserId() != null) { - userEntity.setUserId(saveReq.getUserId()); - } - - if (saveReq.getEmpId() != null) { - userEntity.setEmpId(saveReq.getEmpId()); - } - - if (saveReq.getUserEmail() != null) { - userEntity.setUserEmail(saveReq.getUserEmail()); - } - - if (saveReq.getUserPw() != null) { - userEntity.setUserPw(saveReq.getUserPw()); - } - - return authRepository.save(userEntity); - } - - /** - * 관리자 삭제 - * - * @param id - * @return - */ - public UserEntity withdrawal(Long id) { - UserEntity userEntity = - authRepository.findById(id).orElseThrow(() -> new RuntimeException("유저가 존재하지 않습니다.")); - - userEntity.setId(id); - userEntity.setDateWithdrawal(ZonedDateTime.now()); - userEntity.setState("WITHDRAWAL"); - - return authRepository.save(userEntity); - } - - /** - * 시퀀스 id로 관리자 조회 - * - * @param id - * @return - */ - public AuthDto.Basic findUserById(Long id) { - UserEntity entity = - authRepository - .findUserById(id) - .orElseThrow(() -> new EntityNotFoundException("관리자를 찾을 수 없습니다. " + id)); - return entity.toDto(); - } - - /** - * 관리자 목록 조회 - * - * @param searchReq - * @return - */ - public Page getUserList(AuthDto.SearchReq searchReq) { - return authRepository.getUserList(searchReq); - } -} diff --git a/src/main/java/com/kamco/cd/kamcoback/postgres/core/MembersCoreService.java b/src/main/java/com/kamco/cd/kamcoback/postgres/core/MembersCoreService.java index bf565060..17ab76fe 100644 --- a/src/main/java/com/kamco/cd/kamcoback/postgres/core/MembersCoreService.java +++ b/src/main/java/com/kamco/cd/kamcoback/postgres/core/MembersCoreService.java @@ -14,6 +14,7 @@ import com.kamco.cd.kamcoback.postgres.repository.members.MembersRoleRepository; import java.time.ZonedDateTime; import java.util.UUID; import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; import org.mindrot.jbcrypt.BCrypt; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; @@ -37,12 +38,12 @@ public class MembersCoreService { * @return */ public Long saveMembers(MembersDto.AddReq addReq) { - if (membersRepository.findByEmployeeNo(addReq.getEmployeeNo())) { + if (membersRepository.existsByEmployeeNo(addReq.getEmployeeNo())) { throw new MemberException.DuplicateMemberException( MemberException.DuplicateMemberException.Field.EMPLOYEE_NO, addReq.getEmployeeNo()); } - if (membersRepository.findByEmail(addReq.getEmail())) { + if (membersRepository.existsByEmail(addReq.getEmail())) { throw new MemberException.DuplicateMemberException( MemberException.DuplicateMemberException.Field.EMAIL, addReq.getEmail()); } @@ -66,16 +67,17 @@ public class MembersCoreService { MemberEntity memberEntity = membersRepository.findByUUID(uuid).orElseThrow(() -> new MemberNotFoundException()); - if (updateReq.getEmployeeNo() != null && !memberEntity.getEmployeeNo().isEmpty()) { + if (StringUtils.isNotBlank(memberEntity.getEmployeeNo())) { memberEntity.setEmployeeNo(updateReq.getEmployeeNo()); } - if (updateReq.getName() != null && !updateReq.getName().isEmpty()) { + + if (StringUtils.isNotBlank(updateReq.getName())) { memberEntity.setName(updateReq.getName()); } - if (updateReq.getPassword() != null && !updateReq.getPassword().isEmpty()) { + if (StringUtils.isNotBlank(updateReq.getPassword())) { memberEntity.setPassword(updateReq.getPassword()); } - if (updateReq.getEmail() != null && !updateReq.getEmail().isEmpty()) { + if (StringUtils.isNotBlank(updateReq.getEmail())) { memberEntity.setEmail(updateReq.getEmail()); } diff --git a/src/main/java/com/kamco/cd/kamcoback/postgres/entity/CommonCodeEntity.java b/src/main/java/com/kamco/cd/kamcoback/postgres/entity/CommonCodeEntity.java index 3971d8fd..1922ad64 100644 --- a/src/main/java/com/kamco/cd/kamcoback/postgres/entity/CommonCodeEntity.java +++ b/src/main/java/com/kamco/cd/kamcoback/postgres/entity/CommonCodeEntity.java @@ -62,7 +62,7 @@ public class CommonCodeEntity extends CommonDateEntity { private List children = new ArrayList<>(); public CommonCodeEntity( - String code, String name, String description, Integer order, Boolean used) { + String code, String name, String description, Integer order, Boolean used) { this.code = code; this.name = name; this.description = description; @@ -71,7 +71,7 @@ public class CommonCodeEntity extends CommonDateEntity { } public CommonCodeEntity( - Long id, String name, String description, Integer order, Boolean used, Boolean deleted) { + Long id, String name, String description, Integer order, Boolean used, Boolean deleted) { this.id = id; this.name = name; this.description = description; @@ -82,16 +82,16 @@ public class CommonCodeEntity extends CommonDateEntity { public CommonCodeDto.Basic toDto() { return new CommonCodeDto.Basic( - this.id, - this.code, - this.description, - this.name, - this.order, - this.used, - this.deleted, - this.children.stream().map(CommonCodeEntity::toDto).toList(), - super.getCreatedDate(), - super.getModifiedDate()); + this.id, + this.code, + this.description, + this.name, + this.order, + this.used, + this.deleted, + this.children.stream().map(CommonCodeEntity::toDto).toList(), + super.getCreatedDate(), + super.getModifiedDate()); } public void addParent(CommonCodeEntity parent) { diff --git a/src/main/java/com/kamco/cd/kamcoback/postgres/entity/UserEntity.java b/src/main/java/com/kamco/cd/kamcoback/postgres/entity/UserEntity.java index 1e09d023..7d2aa524 100644 --- a/src/main/java/com/kamco/cd/kamcoback/postgres/entity/UserEntity.java +++ b/src/main/java/com/kamco/cd/kamcoback/postgres/entity/UserEntity.java @@ -1,6 +1,5 @@ package com.kamco.cd.kamcoback.postgres.entity; -import com.kamco.cd.kamcoback.auth.dto.AuthDto; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -104,15 +103,4 @@ public class UserEntity { this.userEmail = userEmail; this.userPw = userPw; } - - public AuthDto.Basic toDto() { - return new AuthDto.Basic( - this.id, - this.userAuth, - this.userNm, - this.userId, - this.empId, - this.userEmail, - this.createdDttm); - } } diff --git a/src/main/java/com/kamco/cd/kamcoback/postgres/repository/auth/AuthRepository.java b/src/main/java/com/kamco/cd/kamcoback/postgres/repository/auth/AuthRepository.java deleted file mode 100644 index 14efe43e..00000000 --- a/src/main/java/com/kamco/cd/kamcoback/postgres/repository/auth/AuthRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.kamco.cd.kamcoback.postgres.repository.auth; - -import com.kamco.cd.kamcoback.postgres.entity.UserEntity; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface AuthRepository extends JpaRepository, AuthRepositoryCustom {} diff --git a/src/main/java/com/kamco/cd/kamcoback/postgres/repository/auth/AuthRepositoryCustom.java b/src/main/java/com/kamco/cd/kamcoback/postgres/repository/auth/AuthRepositoryCustom.java deleted file mode 100644 index 1ec82999..00000000 --- a/src/main/java/com/kamco/cd/kamcoback/postgres/repository/auth/AuthRepositoryCustom.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.kamco.cd.kamcoback.postgres.repository.auth; - -import com.kamco.cd.kamcoback.auth.dto.AuthDto; -import com.kamco.cd.kamcoback.auth.dto.AuthDto.Basic; -import com.kamco.cd.kamcoback.postgres.entity.UserEntity; -import java.util.Optional; -import org.springframework.data.domain.Page; - -public interface AuthRepositoryCustom { - - Optional findByUserId(String userId); - - Optional findUserById(Long id); - - Page getUserList(AuthDto.SearchReq searchReq); -} diff --git a/src/main/java/com/kamco/cd/kamcoback/postgres/repository/auth/AuthRepositoryImpl.java b/src/main/java/com/kamco/cd/kamcoback/postgres/repository/auth/AuthRepositoryImpl.java deleted file mode 100644 index c1a9ca90..00000000 --- a/src/main/java/com/kamco/cd/kamcoback/postgres/repository/auth/AuthRepositoryImpl.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.kamco.cd.kamcoback.postgres.repository.auth; - -import com.kamco.cd.kamcoback.auth.dto.AuthDto; -import com.kamco.cd.kamcoback.auth.dto.AuthDto.Basic; -import com.kamco.cd.kamcoback.postgres.entity.QUserEntity; -import com.kamco.cd.kamcoback.postgres.entity.UserEntity; -import com.querydsl.core.BooleanBuilder; -import com.querydsl.core.types.Projections; -import com.querydsl.core.types.dsl.BooleanExpression; -import com.querydsl.jpa.impl.JPAQueryFactory; -import java.util.List; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Repository; - -@Repository -@RequiredArgsConstructor -public class AuthRepositoryImpl implements AuthRepositoryCustom { - - private final JPAQueryFactory queryFactory; - private final QUserEntity userEntity = QUserEntity.userEntity; - - /** - * 유저 아이디로 조회 - * - * @param userId - * @return - */ - @Override - public Optional findByUserId(String userId) { - return Optional.ofNullable( - queryFactory.selectFrom(userEntity).where(userEntity.userId.eq(userId)).fetchOne()); - } - - /** - * 유저 시퀀스 id로 조회 - * - * @param id - * @return - */ - @Override - public Optional findUserById(Long id) { - return Optional.ofNullable( - queryFactory.selectFrom(userEntity).where(userEntity.id.eq(id)).fetchOne()); - } - - /** - * 관리자 목록 조회 - * - * @param searchReq - * @return - */ - @Override - public Page getUserList(AuthDto.SearchReq searchReq) { - Pageable pageable = searchReq.toPageable(); - BooleanBuilder builder = new BooleanBuilder(); - if (searchReq.getUserNm() != null && !searchReq.getUserNm().isEmpty()) { - builder.and(likeName(userEntity, searchReq.getUserNm())); - } - - List content = - queryFactory - .select( - Projections.constructor( - AuthDto.Basic.class, - userEntity.id, - userEntity.userAuth, - userEntity.userNm, - userEntity.userId, - userEntity.empId, - userEntity.userEmail, - userEntity.createdDttm)) - .from(userEntity) - .where(builder) - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) - .orderBy(userEntity.userId.asc()) - .fetch(); - - long total = queryFactory.select(userEntity.id).from(userEntity).where(builder).fetchCount(); - return new PageImpl<>(content, pageable, total); - } - - private BooleanExpression likeName(QUserEntity entity, String nameStr) { - if (nameStr == null || nameStr.isEmpty()) { - return null; - } - // ex) WHERE LOWER(name) LIKE LOWER('%입력값%') - return entity.userNm.containsIgnoreCase(nameStr.trim()); - } -} diff --git a/src/main/java/com/kamco/cd/kamcoback/postgres/repository/members/MembersRepositoryCustom.java b/src/main/java/com/kamco/cd/kamcoback/postgres/repository/members/MembersRepositoryCustom.java index 3d4152ab..6b6cecc8 100644 --- a/src/main/java/com/kamco/cd/kamcoback/postgres/repository/members/MembersRepositoryCustom.java +++ b/src/main/java/com/kamco/cd/kamcoback/postgres/repository/members/MembersRepositoryCustom.java @@ -9,11 +9,13 @@ import org.springframework.data.domain.Page; public interface MembersRepositoryCustom { - boolean findByEmployeeNo(String employeeNo); + boolean existsByEmployeeNo(String employeeNo); - boolean findByEmail(String email); + boolean existsByEmail(String email); Page findByMembers(MembersDto.SearchReq searchReq); Optional findByUUID(UUID uuid); + + Optional findByEmployeeNo(String employeeNo); } diff --git a/src/main/java/com/kamco/cd/kamcoback/postgres/repository/members/MembersRepositoryImpl.java b/src/main/java/com/kamco/cd/kamcoback/postgres/repository/members/MembersRepositoryImpl.java index 9fe15eff..8ab7e713 100644 --- a/src/main/java/com/kamco/cd/kamcoback/postgres/repository/members/MembersRepositoryImpl.java +++ b/src/main/java/com/kamco/cd/kamcoback/postgres/repository/members/MembersRepositoryImpl.java @@ -1,8 +1,8 @@ package com.kamco.cd.kamcoback.postgres.repository.members; +import com.kamco.cd.kamcoback.common.enums.RoleType; import com.kamco.cd.kamcoback.members.dto.MembersDto; import com.kamco.cd.kamcoback.members.dto.MembersDto.Basic; -import com.kamco.cd.kamcoback.members.dto.RoleType; import com.kamco.cd.kamcoback.postgres.entity.MemberEntity; import com.kamco.cd.kamcoback.postgres.entity.QMemberEntity; import com.kamco.cd.kamcoback.postgres.entity.QMemberRoleEntity; @@ -14,6 +14,7 @@ import java.util.List; import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; @@ -34,7 +35,7 @@ public class MembersRepositoryImpl implements MembersRepositoryCustom { * @return */ @Override - public boolean findByEmployeeNo(String employeeNo) { + public boolean existsByEmployeeNo(String employeeNo) { return queryFactory .selectOne() .from(memberEntity) @@ -50,7 +51,7 @@ public class MembersRepositoryImpl implements MembersRepositoryCustom { * @return */ @Override - public boolean findByEmail(String email) { + public boolean existsByEmail(String email) { return queryFactory .selectOne() .from(memberEntity) @@ -71,7 +72,7 @@ public class MembersRepositoryImpl implements MembersRepositoryCustom { BooleanBuilder builder = new BooleanBuilder(); BooleanBuilder leftBuilder = new BooleanBuilder(); - if (searchReq.getField() != null && !searchReq.getField().isEmpty()) { + if (StringUtils.isNotBlank(searchReq.getField())) { switch (searchReq.getField()) { case "name" -> builder.and(memberEntity.name.containsIgnoreCase(searchReq.getKeyword().trim())); @@ -142,4 +143,13 @@ public class MembersRepositoryImpl implements MembersRepositoryCustom { return Optional.ofNullable( queryFactory.selectFrom(memberEntity).where(memberEntity.uuid.eq(uuid)).fetchOne()); } + + @Override + public Optional findByEmployeeNo(String employeeNo) { + return Optional.ofNullable( + queryFactory + .selectFrom(memberEntity) + .where(memberEntity.employeeNo.eq(employeeNo)) + .fetchOne()); + } } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index bef17c2d..4126fe32 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -35,5 +35,15 @@ spring: port: 6379 password: kamco + +jwt: + secret: "kamco_token_9b71e778-19a3-4c1d-97bf-2d687de17d5b" + access-token-validity-in-ms: 86400000 # 1일 + refresh-token-validity-in-ms: 604800000 # 7일 + +token: + refresh-cookie-name: kamco-dev # 개발용 쿠키 이름 + refresh-cookie-secure: false # 로컬 http 테스트면 false + member: init_password: kamco1234! diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index cbf7507c..e8c5785b 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -25,9 +25,18 @@ spring: data: redis: - host: 192.168.2.109 + host: localhost port: 6379 - password: kamco + password: 1234 + +jwt: + secret: "kamco_token_9b71e778-19a3-4c1d-97bf-2d687de17d5b" + access-token-validity-in-ms: 86400000 # 1일 + refresh-token-validity-in-ms: 604800000 # 7일 + +token: + refresh-cookie-name: kamco-local # 개발용 쿠키 이름 + refresh-cookie-secure: false # 로컬 http 테스트면 false member: init_password: kamco1234! diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 7ff30470..d2655044 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -21,6 +21,15 @@ spring: minimum-idle: 10 maximum-pool-size: 20 +jwt: + secret: "kamco_token_9b71e778-19a3-4c1d-97bf-2d687de17d5b" + access-token-validity-in-ms: 86400000 # 1일 + refresh-token-validity-in-ms: 604800000 # 7일 + +token: + refresh-cookie-name: kamco # 개발용 쿠키 이름 + refresh-cookie-secure: true # 로컬 http 테스트면 false + member: init_password: kamco1234!