로그인 기능 추가

This commit is contained in:
2025-12-04 16:51:24 +09:00
parent a7d03a0086
commit f41e82e3ca
10 changed files with 335 additions and 307 deletions

View File

@@ -26,30 +26,32 @@ public class SecurityConfig {
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// http.csrf(csrf -> csrf.disable()) // CSRF 보안 기능 비활성화 /*
// .sessionManagement( http.csrf(csrf -> csrf.disable()) // CSRF 보안 기능 비활성화
// sm -> .sessionManagement(
// sm.sessionCreationPolicy( sm ->
// SessionCreationPolicy.STATELESS)) // 서버 세션 만들지 않음 요청은 JWT 인증 sm.sessionCreationPolicy(
// .formLogin(form -> form.disable()) // react에서 로그인 요청 관리 SessionCreationPolicy.STATELESS)) // 서버 세션 만들지 않음, 요청은 JWT 인증
// .httpBasic(basic -> basic.disable()) // 기본 basic 인증 비활성화 JWT 인증사용 .formLogin(form -> form.disable()) // react에서 로그인 요청 관리
// .logout(logout -> logout.disable()) // 기본 로그아웃 비활성화 JWT는 서버 상태가 없으므로 로그아웃 처리 필요 없음 .httpBasic(basic -> basic.disable()) // 기본 basic 인증 비활성화 JWT 인증사용
// .authenticationProvider( .logout(logout -> logout.disable()) // 기본 로그아웃 비활성화 JWT는 서버 상태가 없으므로 로그아웃 처리 필요 없음
// customAuthenticationProvider) // 로그인 패스워드 비교방식 스프링 기본 Provider 사용안함 커스텀 사용 .authenticationProvider(
// .authorizeHttpRequests( customAuthenticationProvider) // 로그인 패스워드 비교방식 스프링 기본 Provider 사용안함 커스텀 사용
// auth -> .authorizeHttpRequests(
// auth.requestMatchers( auth ->
// "/api/auth/signin", auth.requestMatchers(
// "/api/auth/refresh", "/api/auth/signin",
// "/swagger-ui/**", "/api/auth/refresh",
// "/v3/api-docs/**") "/swagger-ui/**",
// .permitAll() "/v3/api-docs/**")
// .anyRequest() .permitAll()
// .authenticated()) .anyRequest()
// .addFilterBefore( .authenticated())
// jwtAuthenticationFilter, .addFilterBefore(
// UsernamePasswordAuthenticationFilter jwtAuthenticationFilter,
// .class) // 요청 들어오면 먼저 JWT 토큰 검사 후 security context 에 사용자 정보 저장. UsernamePasswordAuthenticationFilter
.class) // 요청 들어오면 먼저 JWT 토큰 검사 후 security context 에 사용자 정보 저장.
*/
http.csrf(csrf -> csrf.disable()) http.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(form -> form.disable()) .formLogin(form -> form.disable())
@@ -70,6 +72,11 @@ public class SecurityConfig {
return configuration.getAuthenticationManager(); return configuration.getAuthenticationManager();
} }
/**
* CORS 설정
*
* @return
*/
@Bean @Bean
public CorsConfigurationSource corsConfigurationSource() { public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration(); // CORS 객체 생성 CorsConfiguration config = new CorsConfiguration(); // CORS 객체 생성

View File

@@ -7,4 +7,6 @@ package com.kamco.cd.kamcoback.config;
// scheme = "bearer", // scheme = "bearer",
// bearerFormat = "JWT" // bearerFormat = "JWT"
//) //)
public class SwaggerConfig {} public class SwaggerConfig {
}

View File

@@ -35,7 +35,6 @@ public class ApiLogFunction {
// 사용자 ID 추출 예시 (Spring Security 기준) // 사용자 ID 추출 예시 (Spring Security 기준)
public static String getUserId(HttpServletRequest request) { public static String getUserId(HttpServletRequest request) {
try { try {
Object userId = request.getUserPrincipal();
return request.getUserPrincipal() != null ? request.getUserPrincipal().getName() : null; return request.getUserPrincipal() != null ? request.getUserPrincipal().getName() : null;
} catch (Exception e) { } catch (Exception e) {
return null; return null;

View File

@@ -1,15 +1,16 @@
package com.kamco.cd.kamcoback.config.api; package com.kamco.cd.kamcoback.config.api;
import com.kamco.cd.kamcoback.auth.CustomUserDetails;
import com.kamco.cd.kamcoback.postgres.entity.AuditLogEntity; import com.kamco.cd.kamcoback.postgres.entity.AuditLogEntity;
import com.kamco.cd.kamcoback.postgres.repository.log.AuditLogRepository; import com.kamco.cd.kamcoback.postgres.repository.log.AuditLogRepository;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import java.util.Optional;
import org.springframework.core.MethodParameter; import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest; import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse; import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest; import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import org.springframework.web.util.ContentCachingRequestWrapper; import org.springframework.web.util.ContentCachingRequestWrapper;
@@ -52,10 +53,23 @@ public class ApiResponseAdvice implements ResponseBodyAdvice<Object> {
response.setStatusCode(apiResponse.getHttpStatus()); response.setStatusCode(apiResponse.getHttpStatus());
String ip = ApiLogFunction.getClientIp(servletRequest); String ip = ApiLogFunction.getClientIp(servletRequest);
// TODO : userid 가 계정명인지, uid 인지 확인 후 로직 수정 필요함
Long userid =
Long.valueOf(Optional.ofNullable(ApiLogFunction.getUserId(servletRequest)).orElse("1"));
Long userid = null;
/**
* servletRequest.getUserPrincipal() instanceof UsernamePasswordAuthenticationToken auth
* 이 요청이 JWT 인증을 통과한 요청인가? 그리고 Spring Security Authentication 객체가 UsernamePasswordAuthenticationToken 타입인가? 체크
*/
/**
* auth.getPrincipal() instanceof CustomUserDetails customUserDetails
* principal 안에 들어있는 객체가 내가 만든 CustomUserDetails 타입인가? 체크
*/
if (servletRequest.getUserPrincipal() instanceof UsernamePasswordAuthenticationToken auth
&& auth.getPrincipal() instanceof CustomUserDetails customUserDetails) {
// audit 에는 long 타입 user_id가 들어가지만 토큰 sub은 uuid여서 user_id 가져오기
userid = customUserDetails.getMember().getId();
}
// TODO: menuUid 를 동적으로 가져오게끔 해야함 // TODO: menuUid 를 동적으로 가져오게끔 해야함
AuditLogEntity log = AuditLogEntity log =
new AuditLogEntity( new AuditLogEntity(

View File

@@ -12,6 +12,8 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.util.UUID; import java.util.UUID;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
@@ -100,7 +102,7 @@ public class AdminApiController {
@ApiResponse(responseCode = "404", description = "코드를 찾을 수 없음", content = @Content), @ApiResponse(responseCode = "404", description = "코드를 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content) @ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
}) })
@PostMapping("/roles/rm") @DeleteMapping("/roles/rm")
public ApiResponseDto<UUID> deleteRoles( public ApiResponseDto<UUID> deleteRoles(
@io.swagger.v3.oas.annotations.parameters.RequestBody( @io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "역할 삭제", description = "역할 삭제",
@@ -130,7 +132,7 @@ public class AdminApiController {
@ApiResponse(responseCode = "404", description = "코드를 찾을 수 없음", content = @Content), @ApiResponse(responseCode = "404", description = "코드를 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content) @ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
}) })
@PostMapping("/status/update") @PatchMapping("{uuid}/status")
public ApiResponseDto<UUID> updateStatus( public ApiResponseDto<UUID> updateStatus(
@io.swagger.v3.oas.annotations.parameters.RequestBody( @io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "상태 수정", description = "상태 수정",
@@ -139,11 +141,12 @@ public class AdminApiController {
@Content( @Content(
mediaType = "application/json", mediaType = "application/json",
schema = @Schema(implementation = MembersDto.StatusDto.class))) schema = @Schema(implementation = MembersDto.StatusDto.class)))
@PathVariable UUID uuid,
@RequestBody @RequestBody
@Valid @Valid
MembersDto.StatusDto statusDto) { MembersDto.StatusDto statusDto) {
adminService.updateStatus(statusDto); adminService.updateStatus(uuid, statusDto);
return ApiResponseDto.createOK(statusDto.getUuid()); return ApiResponseDto.createOK(uuid);
} }
@Operation(summary = "회원 탈퇴", description = "회원 탈퇴") @Operation(summary = "회원 탈퇴", description = "회원 탈퇴")
@@ -160,18 +163,18 @@ public class AdminApiController {
@ApiResponse(responseCode = "404", description = "코드를 찾을 수 없음", content = @Content), @ApiResponse(responseCode = "404", description = "코드를 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content) @ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
}) })
@PostMapping("/delete-account") @DeleteMapping("/delete/{uuid}")
public ApiResponseDto<UUID> deleteAccount(MembersDto.StatusDto statusDto) { public ApiResponseDto<UUID> deleteAccount(@PathVariable UUID uuid) {
adminService.deleteAccount(statusDto); adminService.deleteAccount(uuid);
return ApiResponseDto.createOK(statusDto.getUuid()); return ApiResponseDto.createOK(uuid);
} }
@Operation(summary = "패스워드 초기화", description = "패스워드 초기화") @Operation(summary = "비밀번호 초기화", description = "비밀번호 초기화")
@ApiResponses( @ApiResponses(
value = { value = {
@ApiResponse( @ApiResponse(
responseCode = "201", responseCode = "201",
description = "패스워드 초기화", description = "비밀번호 초기화",
content = content =
@Content( @Content(
mediaType = "application/json", mediaType = "application/json",
@@ -180,7 +183,7 @@ public class AdminApiController {
@ApiResponse(responseCode = "404", description = "코드를 찾을 수 없음", content = @Content), @ApiResponse(responseCode = "404", description = "코드를 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content) @ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
}) })
@PostMapping("/{memberId}/password") @PatchMapping("/{memberId}/password")
public ApiResponseDto<Long> resetPassword(@PathVariable Long memberId) { public ApiResponseDto<Long> resetPassword(@PathVariable Long memberId) {
adminService.resetPassword(memberId); adminService.resetPassword(memberId);
return ApiResponseDto.createOK(memberId); return ApiResponseDto.createOK(memberId);

View File

@@ -41,7 +41,7 @@ public class AuthController {
private boolean refreshCookieSecure; private boolean refreshCookieSecure;
@PostMapping("/signin") @PostMapping("/signin")
@Operation(summary = "로그인", description = "사번 또는 이메일과 비밀번호로 로그인하여 액세스/리프레시 토큰을 발급합니다.") @Operation(summary = "로그인", description = "사번로 로그인하여 액세스/리프레시 토큰을 발급.")
@ApiResponses({ @ApiResponses({
@ApiResponse( @ApiResponse(
responseCode = "200", responseCode = "200",
@@ -76,7 +76,7 @@ public class AuthController {
ResponseCookie cookie = ResponseCookie cookie =
ResponseCookie.from(refreshCookieName, refreshToken) ResponseCookie.from(refreshCookieName, refreshToken)
.httpOnly(true) .httpOnly(true)
.secure(refreshCookieSecure) // 로컬 개발에서 http만 쓰면 false 로 바꿔야 할 수도 있음 .secure(refreshCookieSecure)
.path("/") .path("/")
.maxAge(Duration.ofMillis(jwtTokenProvider.getRefreshTokenValidityInMs())) .maxAge(Duration.ofMillis(jwtTokenProvider.getRefreshTokenValidityInMs()))
.sameSite("Strict") .sameSite("Strict")
@@ -134,7 +134,7 @@ public class AuthController {
} }
@PostMapping("/logout") @PostMapping("/logout")
@Operation(summary = "로그아웃", description = "현재 사용자의 토큰을 무효화(블랙리스트 처리 또는 리프레시 토큰 삭제)합니다.") @Operation(summary = "로그아웃", description = "현재 사용자의 토큰을 무효화(리프레시 토큰 삭제)합니다.")
@ApiResponses({ @ApiResponses({
@ApiResponse( @ApiResponse(
responseCode = "200", responseCode = "200",
@@ -165,7 +165,11 @@ public class AuthController {
@Schema(description = "로그인 요청 DTO") @Schema(description = "로그인 요청 DTO")
public record SignInRequest( public record SignInRequest(
@Schema(description = "사번", example = "11111") String username, @Schema(description = "사번", example = "11111") String username,
@Schema(description = "비밀번호", example = "kamco1234!") String password) {} @Schema(description = "비밀번호", example = "kamco1234!") String password) {
public record TokenResponse(String accessToken) {} }
public record TokenResponse(String accessToken) {
}
} }

View File

@@ -16,7 +16,7 @@ import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; 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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -62,7 +62,7 @@ public class MembersApiController {
@ApiResponse(responseCode = "404", description = "코드를 찾을 수 없음", content = @Content), @ApiResponse(responseCode = "404", description = "코드를 찾을 수 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content) @ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
}) })
@PostMapping("/{uuid}") @PutMapping("/{uuid}")
public ApiResponseDto<UUID> updateMember( public ApiResponseDto<UUID> updateMember(
@PathVariable UUID uuid, @RequestBody MembersDto.UpdateReq updateReq) { @PathVariable UUID uuid, @RequestBody MembersDto.UpdateReq updateReq) {
membersService.updateMember(uuid, updateReq); membersService.updateMember(uuid, updateReq);

View File

@@ -28,8 +28,10 @@ public class MembersDto {
private String email; private String email;
private String status; private String status;
private String roleName; private String roleName;
@JsonFormatDttm private ZonedDateTime createdDttm; @JsonFormatDttm
@JsonFormatDttm private ZonedDateTime updatedDttm; private ZonedDateTime createdDttm;
@JsonFormatDttm
private ZonedDateTime updatedDttm;
public Basic( public Basic(
Long id, Long id,
@@ -166,10 +168,6 @@ public class MembersDto {
@AllArgsConstructor @AllArgsConstructor
public static class StatusDto { public static class StatusDto {
@Schema(description = "UUID", example = "4e89e487-c828-4a34-a7fc-0d5b0e3b53b5")
@NotBlank
private UUID uuid;
@Schema(description = "변경할 상태값 ACTIVE, INACTIVE, ARCHIVED", example = "ACTIVE") @Schema(description = "변경할 상태값 ACTIVE, INACTIVE, ARCHIVED", example = "ACTIVE")
@NotBlank @NotBlank
private String status; private String status;

View File

@@ -3,6 +3,7 @@ package com.kamco.cd.kamcoback.members.service;
import com.kamco.cd.kamcoback.auth.BCryptSaltGenerator; import com.kamco.cd.kamcoback.auth.BCryptSaltGenerator;
import com.kamco.cd.kamcoback.members.dto.MembersDto; import com.kamco.cd.kamcoback.members.dto.MembersDto;
import com.kamco.cd.kamcoback.postgres.core.MembersCoreService; import com.kamco.cd.kamcoback.postgres.core.MembersCoreService;
import java.util.UUID;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.mindrot.jbcrypt.BCrypt; import org.mindrot.jbcrypt.BCrypt;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -60,17 +61,17 @@ public class AdminService {
* *
* @param statusDto * @param statusDto
*/ */
public void updateStatus(MembersDto.StatusDto statusDto) { public void updateStatus(UUID uuid, MembersDto.StatusDto statusDto) {
membersCoreService.updateStatus(statusDto); membersCoreService.updateStatus(uuid, statusDto);
} }
/** /**
* 회원 탈퇴 * 회원 탈퇴
* *
* @param statusDto * @param uuid
*/ */
public void deleteAccount(MembersDto.StatusDto statusDto) { public void deleteAccount(UUID uuid) {
membersCoreService.deleteAccount(statusDto); membersCoreService.deleteAccount(uuid);
} }
/** /**

View File

@@ -140,10 +140,10 @@ public class MembersCoreService {
* *
* @param statusDto * @param statusDto
*/ */
public void updateStatus(MembersDto.StatusDto statusDto) { public void updateStatus(UUID uuid, MembersDto.StatusDto statusDto) {
MemberEntity memberEntity = MemberEntity memberEntity =
membersRepository membersRepository
.findByUUID(statusDto.getUuid()) .findByUUID(uuid)
.orElseThrow(() -> new MemberNotFoundException()); .orElseThrow(() -> new MemberNotFoundException());
memberEntity.setStatus(statusDto.getStatus()); memberEntity.setStatus(statusDto.getStatus());
@@ -154,12 +154,12 @@ public class MembersCoreService {
/** /**
* 회원 탈퇴 * 회원 탈퇴
* *
* @param statusDto * @param uuid
*/ */
public void deleteAccount(MembersDto.StatusDto statusDto) { public void deleteAccount(UUID uuid) {
MemberEntity memberEntity = MemberEntity memberEntity =
membersRepository membersRepository
.findByUUID(statusDto.getUuid()) .findByUUID(uuid)
.orElseThrow(() -> new MemberNotFoundException()); .orElseThrow(() -> new MemberNotFoundException());
MemberArchivedEntityId memberArchivedEntityId = new MemberArchivedEntityId(); MemberArchivedEntityId memberArchivedEntityId = new MemberArchivedEntityId();