Merge pull request 'feat/dev_251201' (#47) from feat/dev_251201 into develop

Reviewed-on: https://kamco.gitea.gs.dabeeo.com/dabeeo/kamco-dabeeo-backoffice/pulls/47
This commit is contained in:
2025-12-11 15:40:04 +09:00
11 changed files with 140 additions and 81 deletions

View File

@@ -1,10 +1,10 @@
package com.kamco.cd.kamcoback.auth;
import com.kamco.cd.kamcoback.common.enums.StatusType;
import com.kamco.cd.kamcoback.common.enums.error.AuthErrorCode;
import com.kamco.cd.kamcoback.common.exception.CustomApiException;
import com.kamco.cd.kamcoback.postgres.entity.MemberEntity;
import com.kamco.cd.kamcoback.postgres.repository.members.MembersRepository;
import java.time.ZonedDateTime;
import lombok.RequiredArgsConstructor;
import org.mindrot.jbcrypt.BCrypt;
import org.springframework.security.authentication.AuthenticationProvider;
@@ -26,38 +26,35 @@ public class CustomAuthenticationProvider implements AuthenticationProvider {
String username = authentication.getName();
String rawPassword = authentication.getCredentials().toString();
// 1. 유저 조회
// 유저 조회
MemberEntity member =
membersRepository
.findByUserId(username)
.orElseThrow(() -> new CustomApiException(AuthErrorCode.LOGIN_ID_NOT_FOUND));
// 2. jBCrypt + 커스텀 salt 로 저장된 패스워드 비교
// jBCrypt + 커스텀 salt 로 저장된 패스워드 비교
if (!BCrypt.checkpw(rawPassword, member.getPassword())) {
// 실패 카운트 저장
int cnt = member.getLoginFailCount() + 1;
if (cnt >= 5) {
member.setStatus("INACTIVE");
member.setStatus(StatusType.INACTIVE.getId());
}
member.setLoginFailCount(cnt);
membersRepository.save(member);
throw new CustomApiException(AuthErrorCode.LOGIN_PASSWORD_MISMATCH);
}
// 3. 패스워드 실패 횟수 체크
// 삭제 상태
if (member.getStatus().equals(StatusType.DELETED.getId())) {
throw new CustomApiException(AuthErrorCode.LOGIN_ID_NOT_FOUND);
}
// 패스워드 실패 횟수 체크
if (member.getLoginFailCount() >= 5) {
throw new CustomApiException(AuthErrorCode.LOGIN_PASSWORD_EXCEEDED);
}
// 4. 인증 성공 로그인 시간 저장
if (member.getFirstLoginDttm() == null) {
member.setFirstLoginDttm(ZonedDateTime.now());
}
member.setLastLoginDttm(ZonedDateTime.now());
member.setLoginFailCount(0);
membersRepository.save(member);
// 5. 인증 성공 → UserDetails 생성
// 인증 성공 → UserDetails 생성
CustomUserDetails userDetails = new CustomUserDetails(member);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());

View File

@@ -9,7 +9,7 @@ import lombok.Getter;
public enum StatusType implements EnumType {
ACTIVE("활성"),
INACTIVE("비활성"),
ARCHIVED("탈퇴");
DELETED("삭제");
private final String desc;

View File

@@ -1,10 +1,9 @@
package com.kamco.cd.kamcoback.common.utils;
import com.kamco.cd.kamcoback.auth.CustomUserDetails;
import com.kamco.cd.kamcoback.auth.JwtTokenProvider;
import com.kamco.cd.kamcoback.members.dto.MembersDto;
import com.kamco.cd.kamcoback.postgres.entity.MemberEntity;
import java.util.Optional;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
@@ -13,42 +12,30 @@ import org.springframework.stereotype.Component;
@RequiredArgsConstructor
public class UserUtil {
private final JwtTokenProvider jwtTokenProvider;
/**
* 현재 SecurityContext의 Authentication 에서 사용자 ID(Long) 가져오기 - 로그인 된 상태(AccessToken 인증 이후)에서만 사용 가능
* - 인증 정보 없으면 null 리턴
*/
public Long getCurrentUserId() {
public MembersDto.Member getCurrentUser() {
return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.filter(auth -> auth.getPrincipal() instanceof CustomUserDetails)
.map(auth -> ((CustomUserDetails) auth.getPrincipal()).getMember().getId())
.map(
auth -> {
CustomUserDetails user = (CustomUserDetails) auth.getPrincipal();
MemberEntity m = user.getMember();
return new MembersDto.Member(m.getId(), m.getName(), m.getEmployeeNo());
})
.orElse(null);
}
/** 현재 SecurityContext의 Authentication 에서 사용자 UUID 가져오기 - 인증 정보 없으면 null 리턴 */
public UUID getCurrentUserUuid() {
return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.filter(auth -> auth.getPrincipal() instanceof CustomUserDetails)
.map(auth -> ((CustomUserDetails) auth.getPrincipal()).getMember().getUuid())
.orElse(null);
public Long getId() {
MembersDto.Member user = getCurrentUser();
return user != null ? user.getId() : null;
}
/** 현재 로그인한 사용자의 MemberEntity 통째로 가져오기 (Optional) */
public Optional<MemberEntity> getCurrentMember() {
return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.filter(auth -> auth.getPrincipal() instanceof CustomUserDetails)
.map(auth -> ((CustomUserDetails) auth.getPrincipal()).getMember());
public String getName() {
MembersDto.Member user = getCurrentUser();
return user != null ? user.getName() : null;
}
/** 현재 로그인한 사용자의 MemberEntity 가져오기 (없으면 예외) */
public MemberEntity getCurrentMemberOrThrow() {
return getCurrentMember().orElseThrow(() -> new IllegalStateException("인증된 사용자를 찾을 수 없습니다."));
// 필요하면 여기서 CustomApiException 으로 바꿔도 됨
}
/** AccessToken / RefreshToken 에서 sub(subject, uuid) 추출 - 토큰 문자열만 있을 때 uuid 가져올 때 사용 */
public String getSubjectFromToken(String token) {
return jwtTokenProvider.getSubject(token);
public String getEmployeeNo() {
MembersDto.Member user = getCurrentUser();
return user != null ? user.getEmployeeNo() : null;
}
}

View File

@@ -1,9 +1,12 @@
package com.kamco.cd.kamcoback.members;
import com.kamco.cd.kamcoback.auth.CustomUserDetails;
import com.kamco.cd.kamcoback.auth.JwtTokenProvider;
import com.kamco.cd.kamcoback.auth.RefreshTokenService;
import com.kamco.cd.kamcoback.config.api.ApiResponseDto;
import com.kamco.cd.kamcoback.members.dto.MembersDto;
import com.kamco.cd.kamcoback.members.dto.SignInRequest;
import com.kamco.cd.kamcoback.members.dto.TokenResponse;
import com.kamco.cd.kamcoback.members.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
@@ -15,6 +18,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import java.nio.file.AccessDeniedException;
import java.time.Duration;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
@@ -65,31 +69,31 @@ public class AuthController {
description = "존재하지 않는 아이디",
value =
"""
{
"code": "LOGIN_ID_NOT_FOUND",
"message": "아이디를 잘못 입력하셨습니다."
}
"""),
{
"code": "LOGIN_ID_NOT_FOUND",
"message": "아이디를 잘못 입력하셨습니다."
}
"""),
@ExampleObject(
name = "비밀번호 입력 오류 (4회 이하)",
description = "아이디는 정상, 비밀번호를 여러 번 틀린 경우",
value =
"""
{
"code": "LOGIN_PASSWORD_MISMATCH",
"message": "비밀번호를 잘못 입력하셨습니다."
}
"""),
{
"code": "LOGIN_PASSWORD_MISMATCH",
"message": "비밀번호를 잘못 입력하셨습니다."
}
"""),
@ExampleObject(
name = "비밀번호 오류 횟수 초과",
description = "비밀번호 5회 이상 오류로 계정 잠김",
value =
"""
{
"code": "LOGIN_PASSWORD_EXCEEDED",
"message": "비밀번호 오류 횟수를 초과하여 이용하실 수 없습니다. 로그인 오류에 대해 관리자에게 문의하시기 바랍니다."
}
""")
{
"code": "LOGIN_PASSWORD_EXCEEDED",
"message": "비밀번호 오류 횟수를 초과하여 이용하실 수 없습니다. 로그인 오류에 대해 관리자에게 문의하시기 바랍니다."
}
""")
}))
})
public ApiResponseDto<TokenResponse> signin(
@@ -106,9 +110,11 @@ public class AuthController {
String status = authService.getUserStatus(request);
// INACTIVE 비활성 상태(새로운 패스워드 입력 해야함), ARCHIVED 탈퇴
MembersDto.Member member = new MembersDto.Member();
// INACTIVE 비활성 상태(새로운 패스워드 입력 해야함), DELETED 탈퇴
if (!"ACTIVE".equals(status)) {
return ApiResponseDto.ok(new TokenResponse(status, null, null));
return ApiResponseDto.ok(new TokenResponse(status, null, null, member));
}
String username = authentication.getName(); // UserDetailsService 에서 사용한 username
@@ -132,7 +138,15 @@ public class AuthController {
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
return ApiResponseDto.ok(new TokenResponse(status, accessToken, refreshToken));
CustomUserDetails user = (CustomUserDetails) authentication.getPrincipal();
member.setId(user.getMember().getId());
member.setName(user.getMember().getName());
member.setEmployeeNo(user.getMember().getEmployeeNo());
// 인증 성공 로그인 시간 저장
authService.saveLogin(UUID.fromString(username));
return ApiResponseDto.ok(new TokenResponse(status, accessToken, refreshToken, member));
}
@PostMapping("/refresh")
@@ -179,7 +193,8 @@ public class AuthController {
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
return ResponseEntity.ok(new TokenResponse("ACTIVE", newAccessToken, newRefreshToken));
MembersDto.Member member = new MembersDto.Member();
return ResponseEntity.ok(new TokenResponse("ACTIVE", newAccessToken, newRefreshToken, member));
}
@PostMapping("/logout")
@@ -211,6 +226,4 @@ public class AuthController {
return ApiResponseDto.createOK(ResponseEntity.noContent().build());
}
public record TokenResponse(String status, String accessToken, String refreshToken) {}
}

View File

@@ -174,4 +174,15 @@ public class MembersDto {
@NotBlank
private String tempPassword;
}
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public static class Member {
private Long id;
private String name;
private String employeeNo;
}
}

View File

@@ -0,0 +1,16 @@
package com.kamco.cd.kamcoback.members.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@AllArgsConstructor
public class TokenResponse {
private String status;
private String accessToken;
private String refreshToken;
private MembersDto.Member member;
}

View File

@@ -2,6 +2,7 @@ package com.kamco.cd.kamcoback.members.service;
import com.kamco.cd.kamcoback.members.dto.SignInRequest;
import com.kamco.cd.kamcoback.postgres.core.MembersCoreService;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -13,6 +14,16 @@ public class AuthService {
private final MembersCoreService membersCoreService;
/**
* 로그인 일시 저장
*
* @param uuid
*/
@Transactional
public void saveLogin(UUID uuid) {
membersCoreService.saveLogin(uuid);
}
/**
* 사용자 상태 조회
*

View File

@@ -44,7 +44,7 @@ public class MembersService {
}
/**
* 대문자 1개 이상 소문자 1개 이상 숫자 1개 이상 특수문자(!@#$) 1개 이상
* 영문, 숫자, 특수문자를 모두 포함하여 8~20자 이내의 비밀번호
*
* @param password
* @return

View File

@@ -1,6 +1,8 @@
package com.kamco.cd.kamcoback.postgres.core;
import com.kamco.cd.kamcoback.auth.BCryptSaltGenerator;
import com.kamco.cd.kamcoback.common.enums.StatusType;
import com.kamco.cd.kamcoback.common.utils.UserUtil;
import com.kamco.cd.kamcoback.members.dto.MembersDto;
import com.kamco.cd.kamcoback.members.dto.MembersDto.AddReq;
import com.kamco.cd.kamcoback.members.dto.MembersDto.Basic;
@@ -23,6 +25,7 @@ import org.springframework.stereotype.Service;
public class MembersCoreService {
private final MembersRepository membersRepository;
private final UserUtil userUtil;
/**
* 관리자 계정 등록
@@ -47,6 +50,7 @@ public class MembersCoreService {
memberEntity.setPassword(hashedPassword);
memberEntity.setName(addReq.getName());
memberEntity.setEmployeeNo(addReq.getEmployeeNo());
memberEntity.setRgstrUidl(userUtil.getId());
return membersRepository.save(memberEntity).getId();
}
@@ -74,11 +78,13 @@ public class MembersCoreService {
memberEntity.setEmployeeNo(updateReq.getEmployeeNo());
}
memberEntity.setUpdtrUid(userUtil.getId());
membersRepository.save(memberEntity);
}
/**
* 관리자 계정 미사용 처리
* 관리자 계정 삭제 처리
*
* @param uuid
*/
@@ -86,8 +92,9 @@ public class MembersCoreService {
MemberEntity memberEntity =
membersRepository.findByUUID(uuid).orElseThrow(() -> new MemberNotFoundException());
memberEntity.setStatus("INACTIVE");
memberEntity.setStatus(StatusType.DELETED.getId());
memberEntity.setUpdatedDttm(ZonedDateTime.now());
memberEntity.setUpdtrUid(userUtil.getId());
membersRepository.save(memberEntity);
}
@@ -108,6 +115,7 @@ public class MembersCoreService {
memberEntity.setPassword(hashedPassword);
memberEntity.setStatus("ACTIVE");
memberEntity.setUpdatedDttm(ZonedDateTime.now());
memberEntity.setUpdtrUid(memberEntity.getId());
membersRepository.save(memberEntity);
}
@@ -136,4 +144,21 @@ public class MembersCoreService {
.orElseThrow(MemberNotFoundException::new);
return memberEntity.getStatus();
}
/**
* 최초 로그인 저장 마지막 로그인 저장
*
* @param uuid
*/
public void saveLogin(UUID uuid) {
MemberEntity memberEntity =
membersRepository.findByUUID(uuid).orElseThrow(() -> new MemberNotFoundException());
if (memberEntity.getFirstLoginDttm() == null) {
memberEntity.setFirstLoginDttm(ZonedDateTime.now());
}
memberEntity.setLastLoginDttm(ZonedDateTime.now());
memberEntity.setLoginFailCount(0);
membersRepository.save(memberEntity);
}
}

View File

@@ -2,17 +2,7 @@ package com.kamco.cd.kamcoback.postgres.entity;
import com.kamco.cd.kamcoback.code.dto.CommonCodeDto;
import com.kamco.cd.kamcoback.postgres.CommonDateEntity;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.ZonedDateTime;
@@ -21,6 +11,7 @@ import java.util.List;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.Where;
@Entity
@Getter
@@ -60,6 +51,7 @@ public class CommonCodeEntity extends CommonDateEntity {
private CommonCodeEntity parent;
@OneToMany(mappedBy = "parent", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@Where(clause = "deleted = false or deleted is null")
private List<CommonCodeEntity> children = new ArrayList<>();
@Size(max = 255)

View File

@@ -1,5 +1,6 @@
package com.kamco.cd.kamcoback.postgres.entity;
import com.kamco.cd.kamcoback.common.enums.StatusType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
@@ -63,7 +64,7 @@ public class MemberEntity {
@Size(max = 20)
@ColumnDefault("'INACTIVE'")
@Column(name = "status", length = 20)
private String status = "INACTIVE";
private String status = StatusType.INACTIVE.getId();
@NotNull
@ColumnDefault("now()")
@@ -83,4 +84,10 @@ public class MemberEntity {
@Column(name = "login_fail_count")
@ColumnDefault("0")
private Integer loginFailCount = 0;
@Column(name = "rgstr_uid")
private Long rgstrUidl;
@Column(name = "updtr_uid")
private Long updtrUid;
}