This commit is contained in:
2026-02-02 12:29:00 +09:00
parent f8f5cef6e1
commit 495ef7d86c
175 changed files with 45128 additions and 0 deletions

View File

@@ -0,0 +1,241 @@
package com.kamco.cd.training.members;
import com.kamco.cd.training.auth.CustomUserDetails;
import com.kamco.cd.training.auth.JwtTokenProvider;
import com.kamco.cd.training.common.enums.StatusType;
import com.kamco.cd.training.common.exception.CustomApiException;
import com.kamco.cd.training.config.api.ApiResponseDto;
import com.kamco.cd.training.members.dto.MembersDto;
import com.kamco.cd.training.members.dto.SignInRequest;
import com.kamco.cd.training.members.dto.TokenResponse;
import com.kamco.cd.training.members.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
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.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;
import org.springframework.http.HttpStatus;
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 AuthService authService;
@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 = "로그인 실패 (아이디/비밀번호 오류, 계정잠금 등)",
content =
@Content(
schema = @Schema(implementation = ErrorResponse.class),
examples = {
@ExampleObject(
name = "사번 입력 오류",
description = "존재하지 않는 아이디",
value =
"""
{
"code": "LOGIN_ID_NOT_FOUND",
"message": "사번을 잘못 입력하셨습니다."
}
"""),
@ExampleObject(
name = "비밀번호 입력 오류 (4회 이하)",
description = "아이디는 정상, 비밀번호를 여러 번 틀린 경우",
value =
"""
{
"code": "LOGIN_PASSWORD_MISMATCH",
"message": "비밀번호를 잘못 입력하셨습니다."
}
"""),
@ExampleObject(
name = "비밀번호 오류 횟수 초과",
description = "비밀번호 5회 이상 오류로 계정 잠김",
value =
"""
{
"code": "LOGIN_PASSWORD_EXCEEDED",
"message": "비밀번호 오류 횟수를 초과하여 이용하실 수 없습니다. 로그인 오류에 대해 관리자에게 문의하시기 바랍니다."
}
"""),
@ExampleObject(
name = "사용 중지 된 계정의 로그인 시도",
description = "사용 중지 된 계정의 로그인 시도",
value =
"""
{
"code": "INACTIVE_ID",
"message": "사용할 수 없는 계정입니다."
}
""")
}))
})
public ApiResponseDto<TokenResponse> signin(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "로그인 요청 정보",
required = true)
@RequestBody
SignInRequest request,
HttpServletResponse response) {
// 사용자 상태 조회
String status = authService.getUserStatus(request);
if(StatusType.INACTIVE.getId().equals(status)) {
throw new CustomApiException("INACTIVE_ID", HttpStatus.UNAUTHORIZED);
}
Authentication authentication = null;
MembersDto.Member member = new MembersDto.Member();
authentication =
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()));
String username = authentication.getName(); // UserDetailsService 에서 사용한 username
String accessToken = jwtTokenProvider.createAccessToken(username);
String refreshToken = jwtTokenProvider.createRefreshToken(username);
// 토큰 저장
authService.tokenSave(username, refreshToken, jwtTokenProvider.getRefreshTokenValidityInMs());
// HttpOnly + Secure 쿠키에 RefreshToken 저장
ResponseCookie cookie =
ResponseCookie.from(refreshCookieName, refreshToken)
.httpOnly(true)
.secure(refreshCookieSecure)
.path("/")
.maxAge(Duration.ofMillis(jwtTokenProvider.getRefreshTokenValidityInMs()))
.sameSite("Strict")
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
CustomUserDetails user = (CustomUserDetails) authentication.getPrincipal();
member.setId(user.getMember().getId());
member.setName(user.getMember().getName());
member.setEmployeeNo(user.getMember().getEmployeeNo());
// PENDING 비활성 상태(새로운 패스워드 입력 해야함)
if (StatusType.PENDING.getId().equals(status)) {
member.setEmployeeNo(request.getUsername());
return ApiResponseDto.ok(new TokenResponse(status, accessToken, refreshToken, member));
}
// 인증 성공 로그인 시간 저장
authService.saveLogin(UUID.fromString(username));
return ApiResponseDto.ok(new TokenResponse(status, accessToken, refreshToken, member));
}
@PostMapping("/refresh")
@Operation(summary = "토큰 재발급", description = "리프레시 토큰으로 새로운 액세스/리프레시 토큰을 재발급합니다.")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "재발급 성공",
content = @Content(schema = @Schema(implementation = TokenResponse.class))),
@ApiResponse(
responseCode = "403",
description = "만료되었거나 유효하지 않은 리프레시 토큰",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
})
public ResponseEntity<TokenResponse> refresh(String refreshToken, HttpServletResponse response)
throws AccessDeniedException {
if (refreshToken == null || !jwtTokenProvider.isValidToken(refreshToken)) {
throw new AccessDeniedException("만료되었거나 유효하지 않은 리프레시 토큰 입니다.");
}
String username = jwtTokenProvider.getSubject(refreshToken);
// 저장된 RefreshToken과 일치하는지 확인
authService.validateRefreshToken(username, refreshToken);
// 새 토큰 발급
String newAccessToken = jwtTokenProvider.createAccessToken(username);
String newRefreshToken = jwtTokenProvider.createRefreshToken(username);
// 토큰 저장
authService.tokenSave(username, refreshToken, 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());
MembersDto.Member member = new MembersDto.Member();
return ResponseEntity.ok(new TokenResponse("ACTIVE", newAccessToken, newRefreshToken, member));
}
@PostMapping("/logout")
@Operation(summary = "로그아웃", description = "현재 사용자의 토큰을 무효화(리프레시 토큰 삭제)합니다.")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "로그아웃 성공",
content = @Content(schema = @Schema(implementation = Void.class)))
})
public ApiResponseDto<ResponseEntity<Object>> logout(
Authentication authentication, HttpServletResponse response) {
if (authentication != null) {
String username = authentication.getName();
authService.logout(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 ApiResponseDto.createOK(ResponseEntity.noContent().build());
}
}

View File

@@ -0,0 +1,69 @@
package com.kamco.cd.training.members;
import com.kamco.cd.training.config.api.ApiResponseDto;
import com.kamco.cd.training.members.dto.MembersDto;
import com.kamco.cd.training.members.dto.MembersDto.Basic;
import com.kamco.cd.training.members.service.MembersService;
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.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.web.bind.annotation.*;
@Tag(name = "회원정보 관리", description = "회원정보 관리 API")
@RestController
@RequestMapping("/api/members")
@RequiredArgsConstructor
public class MembersApiController {
private final AuthenticationManager authenticationManager;
private final MembersService membersService;
@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)
})
@PostMapping("/search")
public ApiResponseDto<Page<Basic>> getMemberList(
@RequestBody @Valid MembersDto.SearchReq searchReq) {
return ApiResponseDto.ok(membersService.findByMembers(searchReq));
}
@Operation(
summary = "사용자 비밀번호 변경",
description = "로그인 성공후 status가 INACTIVE일때 로그인 id를 memberId로 path 생성필요")
@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)
})
@PatchMapping("/{memberId}/password")
public ApiResponseDto<String> resetPassword(
@PathVariable String memberId, @RequestBody @Valid MembersDto.InitReq initReq) {
membersService.resetPassword(memberId, initReq);
return ApiResponseDto.createOK(memberId);
}
}

View File

@@ -0,0 +1,183 @@
package com.kamco.cd.training.members.dto;
import com.kamco.cd.training.common.enums.RoleType;
import com.kamco.cd.training.common.enums.StatusType;
import com.kamco.cd.training.common.utils.enums.Enums;
import com.kamco.cd.training.common.utils.interfaces.EnumValid;
import com.kamco.cd.training.common.utils.interfaces.JsonFormatDttm;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.ZonedDateTime;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
public class MembersDto {
@Getter
@Setter
public static class Basic {
private Long id;
private UUID uuid;
private String userRole;
private String userRoleName;
private String name;
private String employeeNo;
private String status;
private String statusName;
@JsonFormatDttm private ZonedDateTime createdDttm;
@JsonFormatDttm private ZonedDateTime firstLoginDttm;
@JsonFormatDttm private ZonedDateTime lastLoginDttm;
@JsonFormatDttm private ZonedDateTime statusChgDttm;
public Basic(
Long id,
UUID uuid,
String userRole,
String name,
String employeeNo,
String status,
ZonedDateTime createdDttm,
ZonedDateTime firstLoginDttm,
ZonedDateTime lastLoginDttm,
ZonedDateTime statusChgDttm,
Boolean pwdResetYn) {
this.id = id;
this.uuid = uuid;
this.userRole = userRole;
this.userRoleName = getUserRoleName(userRole);
this.name = name;
this.employeeNo = employeeNo;
this.status = status;
this.statusName = getStatusName(status, pwdResetYn);
this.createdDttm = createdDttm;
this.firstLoginDttm = firstLoginDttm;
this.lastLoginDttm = lastLoginDttm;
this.statusChgDttm = statusChgDttm;
}
private String getUserRoleName(String roleId) {
RoleType type = Enums.fromId(RoleType.class, roleId);
return type.getText();
}
private String getStatusName(String status, Boolean pwdResetYn) {
StatusType type = Enums.fromId(StatusType.class, status);
pwdResetYn = pwdResetYn != null && pwdResetYn;
if (type.equals(StatusType.PENDING) && pwdResetYn) {
type = StatusType.ACTIVE;
}
return type.getText();
}
}
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public static class SearchReq {
@Schema(description = "전체, 관리자(ADMIN), 라벨러(LABELER), 검수자(REVIEWER)", example = "")
private String userRole;
@Schema(description = "키워드", example = "홍길동")
private String keyword;
// 페이징 파라미터
@Schema(description = "페이지 번호 (0부터 시작) ", example = "0")
private int page = 0;
@Schema(description = "페이지 크기", example = "20")
private int size = 20;
public Pageable toPageable() {
return PageRequest.of(page, size);
}
}
@Getter
@Setter
public static class AddReq {
@Schema(description = "관리자 유형", example = "ADMIN")
@NotBlank
@EnumValid(enumClass = RoleType.class, message = "userRole은 ADMIN, LABELER, REVIEWER 만 가능합니다.")
private String userRole;
@Schema(description = "사번", example = "K20251212001")
@Size(max = 50)
private String employeeNo;
@Schema(description = "이름", example = "홍길동")
@NotBlank
@Size(min = 2, max = 100)
private String name;
@NotBlank
@Schema(description = "패스워드", example = "")
@Size(max = 255)
private String password;
public AddReq(String userRole, String employeeNo, String name, String password) {
this.userRole = userRole;
this.employeeNo = employeeNo;
this.name = name;
this.password = password;
}
}
@Getter
@Setter
public static class UpdateReq {
@Schema(description = "이름", example = "홍길동")
@Size(min = 2, max = 100)
private String name;
@Schema(description = "상태", example = "ACTIVE")
@EnumValid(enumClass = StatusType.class, message = "status는 ACTIVE, INACTIVE, DELETED 만 가능합니다.")
private String status;
@Schema(description = "패스워드", example = "")
@Size(max = 255)
private String password;
public UpdateReq(String name, String status, String password) {
this.name = name;
this.status = status;
this.password = password;
}
}
@Getter
@Setter
public static class InitReq {
@Schema(description = "기존 패스워드", example = "")
@Size(max = 255)
@NotBlank
private String oldPassword;
@Schema(description = "신규 패스워드", example = "")
@Size(max = 255)
@NotBlank
private String newPassword;
}
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public static class Member {
private Long id;
private String name;
private String employeeNo;
}
}

View File

@@ -0,0 +1,20 @@
package com.kamco.cd.training.members.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@ToString(exclude = "password")
public class SignInRequest {
@Schema(description = "사용자 ID", example = "1234567")
private String username;
@Schema(description = "비밀번호", example = "Admin2!@#")
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String password;
}

View File

@@ -0,0 +1,16 @@
package com.kamco.cd.training.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

@@ -0,0 +1,50 @@
package com.kamco.cd.training.members.exception;
import lombok.Getter;
@Getter
public class MemberException {
// *** Duplicate Member Exception ***
@Getter
public static class DuplicateMemberException extends RuntimeException {
public enum Field {
USER_ID,
EMPLOYEE_NO,
DEFAULT
}
private final Field field;
private final String value;
public DuplicateMemberException(Field field, String value) {
super(field.name() + " duplicate: " + value);
this.field = field;
this.value = value;
}
}
// *** Member Not Found Exception ***
public static class MemberNotFoundException extends RuntimeException {
public MemberNotFoundException() {
super("Member not found");
}
public MemberNotFoundException(String message) {
super(message);
}
}
public static class PasswordNotFoundException extends RuntimeException {
public PasswordNotFoundException() {
super("Password not found");
}
public PasswordNotFoundException(String message) {
super(message);
}
}
}

View File

@@ -0,0 +1,77 @@
package com.kamco.cd.training.members.service;
import com.kamco.cd.training.common.enums.error.AuthErrorCode;
import com.kamco.cd.training.common.exception.CustomApiException;
import com.kamco.cd.training.members.dto.SignInRequest;
import com.kamco.cd.training.postgres.core.MembersCoreService;
import com.kamco.cd.training.postgres.core.TokenCoreService;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AuthService {
private final MembersCoreService membersCoreService;
private final TokenCoreService tokenCoreService;
/**
* 토큰 저장
*
* @param subject
* @param refreshToken
* @param validityMs
*/
@Transactional
public void tokenSave(String subject, String refreshToken, long validityMs) {
tokenCoreService.save(subject, refreshToken, validityMs);
}
/**
* refreshToken을 DB와 비교 검증
*
* @param subject 사용자 식별(UUID)
* @param requestRefreshToken refresh token
*/
public void validateRefreshToken(String subject, String requestRefreshToken) {
String savedToken = tokenCoreService.getValidTokenOrThrow(subject);
if (!savedToken.equals(requestRefreshToken)) {
throw new CustomApiException(AuthErrorCode.REFRESH_TOKEN_MISMATCH);
}
}
/**
* 로그아웃(토큰폐기)
*
* @param subject 사용자 식별(UUID)
*/
@Transactional
public void logout(String subject) {
// RefreshToken 폐기
tokenCoreService.revokeBySubject(subject);
}
/**
* 로그인 일시 저장
*
* @param uuid
*/
@Transactional
public void saveLogin(UUID uuid) {
membersCoreService.saveLogin(uuid);
}
/**
* 사용자 상태 조회
*
* @param request
* @return
*/
public String getUserStatus(SignInRequest request) {
return membersCoreService.getUserStatus(request);
}
}

View File

@@ -0,0 +1,29 @@
package com.kamco.cd.training.members.service;
import com.kamco.cd.training.auth.CustomUserDetails;
import com.kamco.cd.training.postgres.entity.MemberEntity;
import com.kamco.cd.training.postgres.repository.members.MembersRepository;
import java.util.UUID;
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 {
UUID uuid = UUID.fromString(username);
MemberEntity member =
membersRepository
.findByUUID(uuid)
.orElseThrow(() -> new UsernameNotFoundException("USER NOT FOUND"));
return new CustomUserDetails(member);
}
}

View File

@@ -0,0 +1,45 @@
package com.kamco.cd.training.members.service;
import com.kamco.cd.training.common.exception.CustomApiException;
import com.kamco.cd.training.common.utils.CommonStringUtils;
import com.kamco.cd.training.members.dto.MembersDto;
import com.kamco.cd.training.members.dto.MembersDto.Basic;
import com.kamco.cd.training.postgres.core.MembersCoreService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MembersService {
private final MembersCoreService membersCoreService;
/**
* 회원목록 조회
*
* @param searchReq
* @return
*/
public Page<Basic> findByMembers(MembersDto.SearchReq searchReq) {
return membersCoreService.findByMembers(searchReq);
}
/**
* 패스워드 사용자 변경
*
* @param id
* @param initReq
*/
@Transactional
public void resetPassword(String id, MembersDto.InitReq initReq) {
if (!CommonStringUtils.isValidPassword(initReq.getNewPassword())) {
throw new CustomApiException("WRONG_PASSWORD", HttpStatus.BAD_REQUEST);
}
membersCoreService.resetPassword(id, initReq);
}
}