[회원 정보 개선] Member 엔티티 및 DTO에 login_ip 필드 추가, JWT 관련 필터에서 클라이언트 IP 처리 로직 개선. Refresh Token 생성 및 검증 시 IP 정보 포함하여 보안 강화.

This commit is contained in:
2025-08-25 11:25:45 +09:00
parent 8cee267f36
commit d8fe8399f7
10 changed files with 47 additions and 8 deletions

View File

@@ -8,6 +8,7 @@
updated_at timestamp(6) not null, updated_at timestamp(6) not null,
updated_oid bigint, updated_oid bigint,
role varchar(40) not null check (role in ('MEMBER','ADMIN','SYSTEM_ADMIN')), role varchar(40) not null check (role in ('MEMBER','ADMIN','SYSTEM_ADMIN')),
login_ip varchar(45),
name varchar(100) not null, name varchar(100) not null,
password varchar(100) not null, password varchar(100) not null,
user_id varchar(100) not null, user_id varchar(100) not null,

View File

@@ -27,6 +27,7 @@ public class MemberDto implements UserDetails {
private MemberRole role; private MemberRole role;
private Boolean useFlag; private Boolean useFlag;
private String refreshToken; private String refreshToken;
private String loginIp;
private LocalDateTime lastLoginAt; private LocalDateTime lastLoginAt;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;

View File

@@ -48,6 +48,9 @@ public class Member extends BaseEntity {
@Column(name = "refresh_token", length = 1024) @Column(name = "refresh_token", length = 1024)
private String refreshToken; private String refreshToken;
@Column(name = "login_ip", length = 45) // IPv6 지원을 위해 45자
private String loginIp;
@Column(name = "last_login_at") @Column(name = "last_login_at")
private LocalDateTime lastLoginAt; private LocalDateTime lastLoginAt;

View File

@@ -22,6 +22,7 @@ public interface MemberMapper {
@Mapping(target = "role", expression = "java(com.bio.bio_backend.domain.base.member.enums.MemberRole.getDefault())") @Mapping(target = "role", expression = "java(com.bio.bio_backend.domain.base.member.enums.MemberRole.getDefault())")
@Mapping(target = "useFlag", constant = "true") @Mapping(target = "useFlag", constant = "true")
@Mapping(target = "refreshToken", ignore = true) @Mapping(target = "refreshToken", ignore = true)
@Mapping(target = "loginIp", ignore = true)
@Mapping(target = "lastLoginAt", ignore = true) @Mapping(target = "lastLoginAt", ignore = true)
@Mapping(target = "createdAt", ignore = true) @Mapping(target = "createdAt", ignore = true)
@Mapping(target = "updatedAt", ignore = true) @Mapping(target = "updatedAt", ignore = true)

View File

@@ -1,6 +1,5 @@
package com.bio.bio_backend.domain.base.member.service; package com.bio.bio_backend.domain.base.member.service;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;

View File

@@ -12,6 +12,7 @@ import com.bio.bio_backend.domain.base.member.dto.MemberDto;
import com.bio.bio_backend.domain.base.member.service.MemberService; import com.bio.bio_backend.domain.base.member.service.MemberService;
import com.bio.bio_backend.global.constants.ApiResponseCode; import com.bio.bio_backend.global.constants.ApiResponseCode;
import com.bio.bio_backend.global.utils.JwtUtils; import com.bio.bio_backend.global.utils.JwtUtils;
import com.bio.bio_backend.global.utils.HttpUtils;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@@ -38,6 +39,7 @@ public class JwtTokenIssuanceFilter extends UsernamePasswordAuthenticationFilter
private final JwtUtils jwtUtils; private final JwtUtils jwtUtils;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final MemberService memberService; private final MemberService memberService;
private final HttpUtils httpUtils;
// 사용자 login 인증 처리 // 사용자 login 인증 처리
@Override @Override
@@ -66,6 +68,7 @@ public class JwtTokenIssuanceFilter extends UsernamePasswordAuthenticationFilter
String refreshToken = jwtUtils.createRefreshToken(member.getUserId(), member.getRole().getValue()); String refreshToken = jwtUtils.createRefreshToken(member.getUserId(), member.getRole().getValue());
member.setRefreshToken(refreshToken); member.setRefreshToken(refreshToken);
member.setLoginIp(httpUtils.getClientIp());
member.setLastLoginAt(LocalDateTime.now()); member.setLastLoginAt(LocalDateTime.now());
memberService.updateMember(member); memberService.updateMember(member);

View File

@@ -69,7 +69,7 @@ public class JwtTokenValidationFilter extends OncePerRequestFilter {
} }
// Access Token이 없거나 만료된 경우, Refresh Token으로 갱신 시도 // Access Token이 없거나 만료된 경우, Refresh Token으로 갱신 시도
if (refreshToken != null && jwtUtils.validateRefreshToken(refreshToken)) { if (refreshToken != null && jwtUtils.validateRefreshToken(refreshToken, request.getRemoteAddr())) {
String username = jwtUtils.extractUsername(refreshToken); String username = jwtUtils.extractUsername(refreshToken);
String role = (String) jwtUtils.extractAllClaims(refreshToken).get("role"); String role = (String) jwtUtils.extractAllClaims(refreshToken).get("role");

View File

@@ -21,6 +21,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.service.MemberService;
import com.bio.bio_backend.global.exception.CustomAuthenticationFailureHandler; import com.bio.bio_backend.global.exception.CustomAuthenticationFailureHandler;
import com.bio.bio_backend.global.utils.JwtUtils; import com.bio.bio_backend.global.utils.JwtUtils;
import com.bio.bio_backend.global.utils.HttpUtils;
import com.bio.bio_backend.global.config.SecurityPathConfig; import com.bio.bio_backend.global.config.SecurityPathConfig;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -35,9 +36,10 @@ public class WebSecurity {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final Environment env; private final Environment env;
private final SecurityPathConfig securityPathConfig; private final SecurityPathConfig securityPathConfig;
private final HttpUtils httpUtils;
private JwtTokenIssuanceFilter getJwtTokenIssuanceFilter(AuthenticationManager authenticationManager) throws Exception { private JwtTokenIssuanceFilter getJwtTokenIssuanceFilter(AuthenticationManager authenticationManager) throws Exception {
JwtTokenIssuanceFilter filter = new JwtTokenIssuanceFilter(authenticationManager, jwtUtils, objectMapper, memberService); JwtTokenIssuanceFilter filter = new JwtTokenIssuanceFilter(authenticationManager, jwtUtils, objectMapper, memberService, httpUtils);
filter.setFilterProcessesUrl("/login"); filter.setFilterProcessesUrl("/login");
filter.setAuthenticationFailureHandler(new CustomAuthenticationFailureHandler(objectMapper)); filter.setAuthenticationFailureHandler(new CustomAuthenticationFailureHandler(objectMapper));
return filter; return filter;

View File

@@ -8,7 +8,6 @@ import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import com.bio.bio_backend.domain.base.member.service.MemberService; import com.bio.bio_backend.domain.base.member.service.MemberService;
import com.bio.bio_backend.domain.base.member.dto.MemberDto;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -53,9 +52,39 @@ public class JwtUtils {
return isTokenExpired(token); return isTokenExpired(token);
} }
public Boolean validateRefreshToken(String token) { // Refresh Token 생성 시 IP 정보 포함
String saveToken = memberService.getRefreshToken(extractUsername(token)); public String createRefreshToken(String username, String role, String clientIp) {
return (saveToken.equals(token) && isTokenExpired(token)); return generateToken(username, role, clientIp,
Long.parseLong(Objects.requireNonNull(env.getProperty("token.expiration_time_refresh"))));
}
// IP 정보를 포함한 토큰 생성
public String generateToken(String username, String role, String clientIp, long expirationTime) {
return Jwts.builder()
.subject(username)
.claim("role", role)
.claim("ip", clientIp) // IP 정보 추가
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expirationTime))
.signWith(getSigningKey())
.compact();
}
// IP 정보 추출
public String extractClientIp(String token) {
Claims claims = extractAllClaims(token);
return claims.get("ip", String.class);
}
// Refresh Token 검증 시 IP도 함께 검증
public Boolean validateRefreshToken(String token, String clientIp) {
String savedToken = memberService.getRefreshToken(extractUsername(token));
String tokenIp = extractClientIp(token);
// 토큰 일치, 만료되지 않음, IP 일치 확인
return (savedToken.equals(token) &&
isTokenExpired(token) &&
Objects.equals(tokenIp, clientIp));
} }
private boolean isTokenExpired(String token) { private boolean isTokenExpired(String token) {

View File

@@ -92,7 +92,7 @@ decorator.datasource.p6spy.log-format=%(sqlSingleLine)
# JWT 설정 # JWT 설정
# ======================================== # ========================================
token.expiration_time_access=900000 token.expiration_time_access=900000
token.expiration_time_refresh=604800000 token.expiration_time_refresh=86400000
token.secret_key=c3RhbV9qd3Rfc2VjcmV0X3Rva2Vuc3RhbV9qd3Rfc2VjcmV0X3RhbV9qd3Rfc2VjcmV0X3RhbV9qd3Rfc2VjcmV0X3Rva2Vu token.secret_key=c3RhbV9qd3Rfc2VjcmV0X3Rva2Vuc3RhbV9qd3Rfc2VjcmV0X3RhbV9qd3Rfc2VjcmV0X3RhbV9qd3Rfc2VjcmV0X3Rva2Vu
# 운영 환경 변수 설정 필요 # 운영 환경 변수 설정 필요
# token.secret_key=${JWT_SECRET_KEY:} # token.secret_key=${JWT_SECRET_KEY:}