8 Commits

Author SHA1 Message Date
499fbc6afb Update jpa-curd-0.0.1.vsix 2025-08-29 16:29:27 +09:00
e78e98ad37 Merge branch 'main' of https://demo.stam.kr/leejisun9/bio_backend 2025-08-29 16:29:16 +09:00
f10b028e04 [JWT 개선] JwtTokenValidationFilter에서 클라이언트 IP 검증 로직을 HttpUtils를 사용하도록 변경하고, Access/Refresh Token 생성 메서드를 개선하여 코드 가독성을 향상시킴. MemberDto에 Refresh Token 및 로그인 IP 설정 추가. 2025-08-29 16:07:30 +09:00
cc2a34403d jpa create 2025-08-29 14:51:05 +09:00
e8a785a20d [JWT 개선] MemberController에서 로그아웃 시 모든 토큰 쿠키 삭제 기능 추가 및 JwtUtils에 Access/Refresh Token 쿠키 설정 및 삭제 메서드 추가. JwtTokenIssuanceFilter와 JwtTokenValidationFilter에서 Access Token을 헤더 대신 쿠키에 저장하도록 변경. 2025-08-28 16:46:53 +09:00
afef6dfa80 [회원 서비스 개선] MemberService와 MemberServiceImpl에서 updateMember 및 deleteRefreshToken 메서드의 반환 타입을 void로 변경하고, 메서드 순서를 정리하여 코드 가독성을 향상시킴. 2025-08-28 16:21:48 +09:00
a0ffeb236e [회원 로그인 응답 개선] LoginResponseDto에 name 필드 추가 및 MemberMapper에 toLoginResponseDto 메서드 추가. JwtTokenIssuanceFilter에서 로그인 성공 시 LoginResponseDto 변환 로직 수정. 2025-08-28 16:00:54 +09:00
fa1df19f64 [코드 정리] CreateMemberResponseDto에서 MemberRole 필드 제거 및 JwtTokenValidationFilter에서 역할 관련 코드 삭제 2025-08-27 16:33:18 +09:00
13 changed files with 95 additions and 53 deletions

BIN
jpa-curd-0.0.1.vsix Normal file

Binary file not shown.

View File

@@ -22,6 +22,8 @@ import io.swagger.v3.oas.annotations.media.Schema;
import com.bio.bio_backend.global.constants.ApiResponseCode;
import com.bio.bio_backend.global.annotation.LogExecution;
import com.bio.bio_backend.global.utils.SecurityUtils;
import com.bio.bio_backend.global.utils.JwtUtils;
import jakarta.servlet.http.HttpServletResponse;
@Tag(name = "Member", description = "회원 관련 API")
@@ -33,6 +35,7 @@ public class MemberController {
private final MemberService memberService;
private final MemberMapper memberMapper;
private final JwtUtils jwtUtils;
@LogExecution("회원 등록")
@Operation(summary = "회원 등록", description = "새로운 회원을 등록합니다.")
@@ -57,10 +60,14 @@ public class MemberController {
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content(schema = @Schema(implementation = ApiResponseDto.class)))
})
@PostMapping("/logout")
public ResponseEntity<ApiResponseDto<Void>> logout() {
public ResponseEntity<ApiResponseDto<Void>> logout(HttpServletResponse response) {
try {
String userId = SecurityUtils.getCurrentUserId();
memberService.deleteRefreshToken(userId);
// 모든 토큰 쿠키 삭제
jwtUtils.deleteAllTokenCookies(response);
log.info("사용자 로그아웃 완료: {}", userId);
return ResponseEntity.ok(ApiResponseDto.success(ApiResponseCode.COMMON_SUCCESS));

View File

@@ -1,6 +1,5 @@
package com.bio.bio_backend.domain.base.member.dto;
import com.bio.bio_backend.domain.base.member.enums.MemberRole;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@@ -16,7 +15,6 @@ public class CreateMemberResponseDto {
private Long oid;
private String userId;
private MemberRole role;
private Boolean useFlag;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;

View File

@@ -14,5 +14,6 @@ import java.time.LocalDateTime;
public class LoginResponseDto {
private String userId;
private String name;
private LocalDateTime lastLoginAt;
}

View File

@@ -2,13 +2,13 @@ package com.bio.bio_backend.domain.base.member.mapper;
import com.bio.bio_backend.domain.base.member.dto.CreateMemberRequestDto;
import com.bio.bio_backend.domain.base.member.dto.CreateMemberResponseDto;
import com.bio.bio_backend.domain.base.member.dto.LoginResponseDto;
import com.bio.bio_backend.domain.base.member.dto.MemberDto;
import com.bio.bio_backend.domain.base.member.entity.Member;
import com.bio.bio_backend.global.annotation.IgnoreBaseEntityMapping;
import com.bio.bio_backend.global.config.GlobalMapperConfig;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.factory.Mappers;
import java.util.List;
@Mapper(config = GlobalMapperConfig.class)
@@ -52,4 +52,9 @@ public interface MemberMapper {
*/
@IgnoreBaseEntityMapping
void updateMemberFromDto(MemberDto memberDto, @org.mapstruct.MappingTarget Member member);
/**
* MemberDto를 LoginResponseDto로 변환
*/
LoginResponseDto toLoginResponseDto(MemberDto memberDto);
}

View File

@@ -1,13 +1,13 @@
package com.bio.bio_backend.domain.base.member.service;
import java.util.List;
import java.util.Map;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import com.bio.bio_backend.domain.base.member.dto.MemberDto;
import java.util.List;
import java.util.Map;
public interface MemberService extends UserDetailsService {
UserDetails loadUserByUsername(String id);
@@ -16,9 +16,9 @@ public interface MemberService extends UserDetailsService {
String getRefreshToken(String id);
int deleteRefreshToken(String id);
void deleteRefreshToken(String id);
void updateMember(MemberDto member);
List<MemberDto> selectMemberList(Map<String, String> params);
int updateMember(MemberDto member);
}

View File

@@ -63,13 +63,12 @@ public class MemberServiceImpl implements MemberService {
@Override
@Transactional
public int updateMember(MemberDto memberDto) {
public void updateMember(MemberDto memberDto) {
Member member = memberRepository.findActiveMemberByUserId(memberDto.getUserId())
.orElseThrow(() -> new ApiException(ApiResponseCode.USER_NOT_FOUND));
memberMapper.updateMemberFromDto(memberDto, member);
memberRepository.save(member);
return 1;
}
@Override
@@ -82,13 +81,12 @@ public class MemberServiceImpl implements MemberService {
@Override
@Transactional
public int deleteRefreshToken(String id) {
public void deleteRefreshToken(String id) {
Member member = memberRepository.findActiveMemberByUserId(id)
.orElseThrow(() -> new ApiException(ApiResponseCode.USER_NOT_FOUND));
member.setRefreshToken(null);
memberRepository.save(member);
return 1;
}
@Override

View File

@@ -16,8 +16,6 @@ public class CorsConfig {
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.setAllowCredentials(true);
config.addExposedHeader("Authorization");
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);

View File

@@ -9,6 +9,7 @@ import com.bio.bio_backend.global.dto.ApiResponseDto;
import com.bio.bio_backend.domain.base.member.dto.LoginRequestDto;
import com.bio.bio_backend.domain.base.member.dto.LoginResponseDto;
import com.bio.bio_backend.domain.base.member.dto.MemberDto;
import com.bio.bio_backend.domain.base.member.mapper.MemberMapper;
import com.bio.bio_backend.domain.base.member.service.MemberService;
import com.bio.bio_backend.global.constants.ApiResponseCode;
import com.bio.bio_backend.global.utils.JwtUtils;
@@ -40,6 +41,7 @@ public class JwtTokenIssuanceFilter extends UsernamePasswordAuthenticationFilter
private final ObjectMapper objectMapper;
private final MemberService memberService;
private final HttpUtils httpUtils;
private final MemberMapper memberMapper;
// 사용자 login 인증 처리
@Override
@@ -75,24 +77,22 @@ public class JwtTokenIssuanceFilter extends UsernamePasswordAuthenticationFilter
// Refresh 토큰 쿠키 저장
jwtUtils.setRefreshTokenCookie(response, refreshToken);
// Access 토큰 전달
response.setHeader("Authorization", "Bearer " + accessToken);
// Access 토큰 쿠키 저장
jwtUtils.setAccessTokenCookie(response, accessToken);
SecurityContextHolderStrategy contextHolder = SecurityContextHolder.getContextHolderStrategy();
SecurityContext context = contextHolder.createEmptyContext();
context.setAuthentication(authResult);
contextHolder.setContext(context);
LoginResponseDto memberData = new LoginResponseDto();
memberData.setUserId(member.getUserId());
memberData.setLastLoginAt(member.getLastLoginAt());
LoginResponseDto loginResponseDto = memberMapper.toLoginResponseDto(member);
// login 성공 메시지 전송
response.setStatus(HttpStatus.OK.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8");
objectMapper.writeValue(
response.getWriter(),
ApiResponseDto.success(ApiResponseCode.LOGIN_SUCCESSFUL, memberData)
ApiResponseDto.success(ApiResponseCode.LOGIN_SUCCESSFUL, loginResponseDto)
);
}
}

View File

@@ -1,8 +1,10 @@
package com.bio.bio_backend.global.filter;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Objects;
import com.bio.bio_backend.domain.base.member.dto.MemberDto;
import com.bio.bio_backend.global.utils.HttpUtils;
import org.springframework.core.env.Environment;
import org.springframework.http.MediaType;
@@ -79,7 +81,7 @@ public class JwtTokenValidationFilter extends OncePerRequestFilter {
}
// 2. IP 주소 검증
if (!jwtUtils.isValidClientIp(refreshToken, request.getRemoteAddr())) {
if (!jwtUtils.isValidClientIp(refreshToken, httpUtils.getClientIp())) {
log.warn("클라이언트 IP 주소가 일치하지 않습니다. URI: {}, IP: {}",
request.getRequestURI(), request.getRemoteAddr());
sendJsonResponse(response, ApiResponseDto.fail(ApiResponseCode.INVALID_CLIENT_IP));
@@ -88,27 +90,29 @@ public class JwtTokenValidationFilter extends OncePerRequestFilter {
// 모든 검증을 통과한 경우 토큰 갱신 진행
String username = jwtUtils.extractUsername(refreshToken);
String role = jwtUtils.extractRole(refreshToken);
UserDetails userDetails = memberService.loadUserByUsername(username);
// 새로운 Access Token 생성
String newAccessToken = jwtUtils.generateToken(username, role,
Long.parseLong(Objects.requireNonNull(env.getProperty("token.expiration_time_access"))));
String newAccessToken = jwtUtils.createAccessToken(username);
// 새로운 Access Token을 응답 헤더에 설정
response.setHeader("Authorization", "Bearer " + newAccessToken);
// 새로운 Access Token을 쿠키에 설정
jwtUtils.setAccessTokenCookie(response, newAccessToken);
// Refresh Token 갱신
String newRefreshToken = jwtUtils.createRefreshToken(username, role, httpUtils.getClientIp());
String newRefreshToken = jwtUtils.createRefreshToken(username, httpUtils.getClientIp());
jwtUtils.setRefreshTokenCookie(response, newRefreshToken);
MemberDto member = (MemberDto) userDetails;
member.setRefreshToken(newRefreshToken);
member.setLoginIp(httpUtils.getClientIp());
memberService.updateMember(member);
// 인증 정보 설정
UserDetails userDetails = memberService.loadUserByUsername(username);
if (userDetails != null) {
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("토큰 자동 갱신 성공: {}", username);
filterChain.doFilter(request, response);

View File

@@ -19,6 +19,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.bio.bio_backend.domain.base.member.service.MemberService;
import com.bio.bio_backend.domain.base.member.mapper.MemberMapper;
import com.bio.bio_backend.global.exception.CustomAuthenticationFailureHandler;
import com.bio.bio_backend.global.utils.JwtUtils;
import com.bio.bio_backend.global.utils.HttpUtils;
@@ -37,9 +38,10 @@ public class WebSecurity {
private final Environment env;
private final SecurityPathConfig securityPathConfig;
private final HttpUtils httpUtils;
private final MemberMapper memberMapper;
private JwtTokenIssuanceFilter getJwtTokenIssuanceFilter(AuthenticationManager authenticationManager) throws Exception {
JwtTokenIssuanceFilter filter = new JwtTokenIssuanceFilter(authenticationManager, jwtUtils, objectMapper, memberService, httpUtils);
JwtTokenIssuanceFilter filter = new JwtTokenIssuanceFilter(authenticationManager, jwtUtils, objectMapper, memberService, httpUtils, memberMapper);
filter.setFilterProcessesUrl("/login");
filter.setAuthenticationFailureHandler(new CustomAuthenticationFailureHandler(objectMapper));
return filter;

View File

@@ -13,7 +13,6 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.crypto.SecretKey;
import java.util.Date;
@@ -49,7 +48,7 @@ public class JwtUtils {
public String generateToken(String username, String clientIp, long expirationTime) {
return Jwts.builder()
.subject(username)
.claim("ip", clientIp) // IP 정보 추가
.claim("ip", clientIp)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expirationTime))
.signWith(getSigningKey())
@@ -123,8 +122,6 @@ public class JwtUtils {
public String extractUsername(String token) {
return extractAllClaims(token).getSubject();
}
public Claims extractAllClaims(String token) {
return Jwts.parser()
@@ -133,14 +130,19 @@ public class JwtUtils {
.parseSignedClaims(token).getPayload();
}
// Access Token을 쿠키에서 추출
public String extractAccessJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7); // "Bearer " 제거
if (request.getCookies() != null) {
for (Cookie cookie : request.getCookies()) {
if ("AccessToken".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return bearerToken;
return null;
}
// Refresh Token을 쿠키에서 추출
public String extractRefreshJwtFromCookie(HttpServletRequest request) {
if (request.getCookies() != null) {
for (Cookie cookie : request.getCookies()) {
@@ -154,12 +156,39 @@ public class JwtUtils {
// Refresh Token 쿠키 설정
public void setRefreshTokenCookie(HttpServletResponse response, String refreshToken) {
Cookie refreshTokenCookie = new Cookie("RefreshToken", refreshToken);
refreshTokenCookie.setHttpOnly(true);
refreshTokenCookie.setSecure(false);
refreshTokenCookie.setPath("/");
refreshTokenCookie.setMaxAge(Integer.parseInt(env.getProperty("token.expiration_time_refresh")));
response.addCookie(refreshTokenCookie);
setCookie(response, "RefreshToken", refreshToken,
Integer.parseInt(env.getProperty("token.expiration_time_refresh")) / 1000);
}
// Access Token 쿠키 설정
public void setAccessTokenCookie(HttpServletResponse response, String accessToken) {
setCookie(response, "AccessToken", accessToken,
Integer.parseInt(env.getProperty("token.expiration_time_access")) / 1000);
}
// Access Token 쿠키 삭제
public void deleteAccessTokenCookie(HttpServletResponse response) {
setCookie(response, "AccessToken", "", 0);
}
// Refresh Token 쿠키 삭제
public void deleteRefreshTokenCookie(HttpServletResponse response) {
setCookie(response, "RefreshToken", "", 0);
}
// 모든 토큰 쿠키 삭제
public void deleteAllTokenCookies(HttpServletResponse response) {
deleteAccessTokenCookie(response);
deleteRefreshTokenCookie(response);
}
// 쿠키 설정 헬퍼 메서드
private void setCookie(HttpServletResponse response, String name, String value, int maxAge) {
Cookie cookie = new Cookie(name, value);
cookie.setHttpOnly(true);
cookie.setSecure(false);
cookie.setPath("/");
cookie.setMaxAge(maxAge);
response.addCookie(cookie);
}
}

View File

@@ -113,7 +113,7 @@ springdoc.default-consumes-media-type=application/json
# ========================================
# 보안 설정 - 허용할 경로
security.permit-all-paths=/login,/members/register,/swagger-ui/**,/swagger-ui.html,/swagger-ui/index.html,/api-docs,/api-docs/**,/v3/api-docs,/v3/api-docs/**,/ws/**,/actuator/**,/actuator/health/**,/actuator/info
security.permit-all-paths=/login,/logout,/members/register,/swagger-ui/**,/swagger-ui.html,/swagger-ui/index.html,/api-docs,/api-docs/**,/v3/api-docs,/v3/api-docs/**,/ws/**,/actuator/**,/actuator/health/**,/actuator/info
# 파일 업로드 설정
# ========================================