diff --git a/src/main/java/com/mycom/socket/auth/config/JWTProperties.java b/src/main/java/com/mycom/socket/auth/config/JWTProperties.java index f35a579..ac6a1e3 100644 --- a/src/main/java/com/mycom/socket/auth/config/JWTProperties.java +++ b/src/main/java/com/mycom/socket/auth/config/JWTProperties.java @@ -10,8 +10,10 @@ @ConfigurationProperties(prefix = "jwt") public class JWTProperties { private String secret; - private long accessTokenValidityInSeconds = 1800; - private String cookieName = "Authorization"; + private long accessTokenValidityInSeconds; + private long refreshTokenValidityInSeconds; + private String accessTokenCookieName; + private String refreshTokenCookieName; private String issuer = "go_socket"; private boolean secureCookie = false; } diff --git a/src/main/java/com/mycom/socket/auth/controller/RefreshController.java b/src/main/java/com/mycom/socket/auth/controller/RefreshController.java new file mode 100644 index 0000000..cf0c70a --- /dev/null +++ b/src/main/java/com/mycom/socket/auth/controller/RefreshController.java @@ -0,0 +1,61 @@ +package com.mycom.socket.auth.controller; + +import com.mycom.socket.auth.config.JWTProperties; +import com.mycom.socket.auth.dto.response.TokenResponse; +import com.mycom.socket.auth.jwt.JWTUtil; +import com.mycom.socket.auth.security.CookieUtil; +import com.mycom.socket.global.exception.BadRequestException; +import io.jsonwebtoken.JwtException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Arrays; +import java.util.Optional; + +@RestController +@RequiredArgsConstructor +public class RefreshController { + + private final JWTUtil jwtUtil; + private final CookieUtil cookieUtil; + private final JWTProperties jwtProperties; + + + @PostMapping("/refresh") + public TokenResponse refreshAccessToken(HttpServletRequest request, HttpServletResponse response) { + Optional refreshTokenOpt = extractRefreshToken(request); + if (refreshTokenOpt.isEmpty()) { + return TokenResponse.of("리프레시 토큰이 없습니다. 다시 로그인해주세요."); + } + + String refreshToken = refreshTokenOpt.get(); + if (!jwtUtil.validateToken(refreshToken, "REFRESH_TOKEN")) { + response.addCookie(cookieUtil.createExpiredCookie(jwtProperties.getRefreshTokenCookieName())); + return TokenResponse.of("유효하지 않은 리프레시 토큰입니다. 다시 로그인해주세요."); + } + + String email = jwtUtil.getEmail(refreshToken); + String newAccessToken = jwtUtil.createToken(email, jwtProperties.getAccessTokenValidityInSeconds(), "ACCESS_TOKEN"); + String newRefreshToken = jwtUtil.createToken(email, jwtProperties.getRefreshTokenValidityInSeconds(), "REFRESH_TOKEN"); + + response.addCookie(cookieUtil.createAuthCookie(newAccessToken)); + response.addCookie(cookieUtil.createRefreshCookie(newRefreshToken)); + + return TokenResponse.of(newAccessToken); + } + + private Optional extractRefreshToken(HttpServletRequest request) { + if (request.getCookies() == null) { + return Optional.empty(); + } + + return Arrays.stream(request.getCookies()) + .filter(cookie -> jwtProperties.getRefreshTokenCookieName().equals(cookie.getName())) + .map(Cookie::getValue) + .findFirst(); + } +} \ No newline at end of file diff --git a/src/main/java/com/mycom/socket/auth/dto/response/TokenResponse.java b/src/main/java/com/mycom/socket/auth/dto/response/TokenResponse.java new file mode 100644 index 0000000..971ac9a --- /dev/null +++ b/src/main/java/com/mycom/socket/auth/dto/response/TokenResponse.java @@ -0,0 +1,11 @@ +package com.mycom.socket.auth.dto.response; + +public record TokenResponse( + String accessToken, + String message, + boolean success +) { + public static TokenResponse of(String message) { + return new TokenResponse(null, message, false); + } +} diff --git a/src/main/java/com/mycom/socket/auth/jwt/JWTFilter.java b/src/main/java/com/mycom/socket/auth/jwt/JWTFilter.java index 97f27d9..d235d3d 100644 --- a/src/main/java/com/mycom/socket/auth/jwt/JWTFilter.java +++ b/src/main/java/com/mycom/socket/auth/jwt/JWTFilter.java @@ -31,7 +31,7 @@ protected void doFilterInternal(HttpServletRequest request, FilterChain filterChain) throws ServletException, IOException { try { String token = resolveTokenFromCookie(request); - if (StringUtils.hasText(token) && jwtUtil.validateToken(token)) { + if (StringUtils.hasText(token) && jwtUtil.validateToken(token, "ACCESS_TOKEN")) { setAuthentication(token); } } catch (Exception e) { @@ -46,7 +46,7 @@ private String resolveTokenFromCookie(HttpServletRequest request) { Cookie[] cookies = request.getCookies(); if (cookies != null) { for (Cookie cookie : cookies) { - if (jwtProperties.getCookieName().equals(cookie.getName())) { + if (jwtProperties.getAccessTokenCookieName().equals(cookie.getName())) { return cookie.getValue(); } } diff --git a/src/main/java/com/mycom/socket/auth/jwt/JWTUtil.java b/src/main/java/com/mycom/socket/auth/jwt/JWTUtil.java index a8b963b..a3739f1 100644 --- a/src/main/java/com/mycom/socket/auth/jwt/JWTUtil.java +++ b/src/main/java/com/mycom/socket/auth/jwt/JWTUtil.java @@ -1,6 +1,9 @@ package com.mycom.socket.auth.jwt; import com.mycom.socket.auth.config.JWTProperties; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import lombok.extern.slf4j.Slf4j; @@ -18,6 +21,12 @@ public class JWTUtil { private final SecretKey secretKey; private final JWTProperties jwtProperties; + /** + * JWTUtil 생성자 + * 설정된 시크릿 키를 바탕으로 HMAC-SHA 알고리즘용 SecretKey를 생성합니다. + * + * @param jwtProperties JWT 관련 설정값을 담고 있는 프로퍼티 객체 + */ public JWTUtil(JWTProperties jwtProperties) { this.jwtProperties = jwtProperties; this.secretKey = Keys.hmacShaKeyFor( @@ -25,46 +34,109 @@ public JWTUtil(JWTProperties jwtProperties) { ); } + /** + * JWT Parser 생성 + * 토큰 검증 및 정보 추출에 사용되는 공통 Parser를 생성합니다. + * + * @return 설정된 JWT Parser 객체 + */ + private JwtParser createParser() { + return Jwts.parser() + .verifyWith(secretKey) + .requireIssuer(jwtProperties.getIssuer()) + .build(); + } + + /** * JWT 토큰 생성 + * 주어진 이메일과 유효기간으로 새로운 JWT를 생성합니다. + * + * @param email 토큰에 포함될 사용자 이메일 + * @param validityInSeconds 토큰 유효 기간 (초) + * @param accessToken + * @return 생성된 JWT 문자열 + * @throws IllegalStateException 토큰 생성 중 오류 발생 시 */ - public String createToken(String email) { + public String createToken(String email, long validityInSeconds, String accessToken) { Date now = new Date(); - Date validity = new Date(now.getTime() + - (jwtProperties.getAccessTokenValidityInSeconds() * 1000)); - - return Jwts.builder() - .issuer(jwtProperties.getIssuer()) - .subject(email) - .issuedAt(now) - .expiration(validity) - .signWith(secretKey) - .compact(); + Date validity = new Date(now.getTime() + (validityInSeconds * 1000)); + + try { + return Jwts.builder() + .issuer(jwtProperties.getIssuer()) + .subject(email) + .issuedAt(now) + .expiration(validity) + .claim("type", accessToken) + .signWith(secretKey) + .compact(); + } catch (JwtException e) { + log.error("토큰 생성 중 오류가 발생했습니다.", e); + throw new IllegalStateException("토큰 생성 중 오류가 발생했습니다.", e); + } } /** * 토큰 유효성 검증 + * 주어진 토큰이 유효한지 검사합니다. 토큰의 서명, 만료 여부, 발급자 등을 확인합니다. + * + * @param token 검증할 JWT 문자열 + * @return 토큰이 유효하면 true, 그렇지 않으면 false */ - public boolean validateToken(String token) { - try { - if (!StringUtils.hasText(token)) { - return false; - } + public boolean validateToken(String token, String expectedType) { + if (!StringUtils.hasText(token)) { + return false; + } - Jwts.parser() + try { + var claims = Jwts.parser() .verifyWith(secretKey) .requireIssuer(jwtProperties.getIssuer()) .build() - .parseSignedClaims(token); - return true; - } catch (Exception e) { - log.warn("JWT 토큰 검증 실패", e); + .parseSignedClaims(token) + .getPayload(); + + // 토큰 타입 검증 + String tokenType = claims.get("type", String.class); + if (!expectedType.equals(tokenType)) { + log.warn("잘못된 토큰 타입입니다. expected: {}, actual: {}", expectedType, tokenType); + return false; + } + + return new Date().before(claims.getExpiration()); + } catch (ExpiredJwtException e) { + log.warn("만료된 JWT 토큰입니다."); + return false; + } catch (JwtException e) { + log.warn("유효하지 않은 JWT 토큰입니다.", e); return false; } } /** - * 토큰에서 이메일 추출 + * 토큰의 만료 시간 추출 + * + * @param token JWT 문자열 + * @return 토큰의 만료 시간 + */ + private Date getExpirationFromToken(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload() + .getExpiration(); + } + + + /** + * 토큰에서 사용자 이메일 추출 + * JWT의 subject 클레임에서 사용자 이메일을 추출합니다. + * + * @param token JWT 문자열 + * @return 토큰에 포함된 사용자 이메일 + * @throws IllegalStateException 토큰에서 이메일을 추출할 수 없는 경우 */ public String getEmail(String token) { return Jwts.parser() diff --git a/src/main/java/com/mycom/socket/auth/security/CookieUtil.java b/src/main/java/com/mycom/socket/auth/security/CookieUtil.java index 1826b31..7cad77e 100644 --- a/src/main/java/com/mycom/socket/auth/security/CookieUtil.java +++ b/src/main/java/com/mycom/socket/auth/security/CookieUtil.java @@ -11,26 +11,65 @@ public class CookieUtil { private final JWTProperties jwtProperties; /** - * 인증 쿠키 생성 + * 공통 쿠키 생성 메소드 + * 모든 종류의 쿠키 생성에 사용되는 기본 메소드입니다. + * + * @param name 쿠키의 이름 + * @param value 쿠키에 저장될 값 (토큰) + * @param maxAge 쿠키의 유효 시간 (초 단위) + * @param secure HTTPS 프로토콜에서만 전송 여부 + * @return 생성된 쿠키 객체 */ - public Cookie createAuthCookie(String token) { - Cookie cookie = new Cookie(jwtProperties.getCookieName(), token); + private Cookie createCookie(String name, String value, long maxAge, boolean secure) { + Cookie cookie = new Cookie(name, value); cookie.setHttpOnly(true); - cookie.setSecure(jwtProperties.isSecureCookie()); - cookie.setPath("/"); - cookie.setMaxAge((int) jwtProperties.getAccessTokenValidityInSeconds()); + cookie.setSecure(secure); + cookie.setPath("/api/auth"); + cookie.setMaxAge((int) maxAge); + cookie.setAttribute("SameSite", "Strict"); //CSRF 공격 방지 설정 추가 return cookie; } /** - * 인증 쿠키 만료 처리 + * Access Token을 저장하는 쿠키 생성 + * 클라이언트 인증에 사용되는 Access Token을 쿠키에 저장합니다. + * + * @param token JWT Access Token 문자열 + * @return Access Token이 저장된 쿠키 */ - public Cookie createExpiredAuthCookie() { - Cookie cookie = new Cookie(jwtProperties.getCookieName(), null); - cookie.setHttpOnly(true); - cookie.setSecure(true); - cookie.setPath("/"); - cookie.setMaxAge(0); // 즉시 만료 - return cookie; + public Cookie createAuthCookie(String token) { + return createCookie( + jwtProperties.getAccessTokenCookieName(), + token, + jwtProperties.getAccessTokenValidityInSeconds(), + jwtProperties.isSecureCookie() + ); + } + + /** + * Refresh Token을 저장하는 쿠키 생성 + * Access Token 재발급에 사용되는 Refresh Token을 쿠키에 저장합니다. + * + * @param token JWT Refresh Token 문자열 + * @return Refresh Token이 저장된 쿠키 + */ + public Cookie createRefreshCookie(String token) { + return createCookie( + jwtProperties.getRefreshTokenCookieName(), + token, + jwtProperties.getRefreshTokenValidityInSeconds(), + jwtProperties.isSecureCookie() + ); + } + + /** + * 만료된 쿠키 생성 + * 로그아웃 또는 토큰 무효화 시 기존 쿠키를 만료시키기 위해 사용됩니다. + * + * @param name 만료시킬 쿠키의 이름 + * @return 즉시 만료되도록 설정된 쿠키 + */ + public Cookie createExpiredCookie(String name) { + return createCookie(name, null, 0, true); } } diff --git a/src/main/java/com/mycom/socket/auth/security/LoginFilter.java b/src/main/java/com/mycom/socket/auth/security/LoginFilter.java index 5c27f35..c49a940 100644 --- a/src/main/java/com/mycom/socket/auth/security/LoginFilter.java +++ b/src/main/java/com/mycom/socket/auth/security/LoginFilter.java @@ -1,6 +1,7 @@ package com.mycom.socket.auth.security; import com.fasterxml.jackson.databind.ObjectMapper; +import com.mycom.socket.auth.config.JWTProperties; import com.mycom.socket.auth.jwt.JWTUtil; import com.mycom.socket.global.dto.ApiResponse; import com.mycom.socket.auth.dto.request.LoginRequest; @@ -28,6 +29,7 @@ public class LoginFilter extends UsernamePasswordAuthenticationFilter { private final AuthenticationManager authenticationManager; private final CookieUtil cookieUtil; private final ObjectMapper objectMapper; + private final JWTProperties jwtProperties; @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) @@ -51,11 +53,23 @@ protected void successfulAuthentication(HttpServletRequest request, HttpServletR MemberDetails memberDetails = (MemberDetails) authResult.getPrincipal(); Member member = memberDetails.getMember(); - String token = jwtUtil.createToken(member.getEmail()); + // JWT 토큰 생성 + String accessToken = jwtUtil.createToken( + member.getEmail(), + jwtProperties.getAccessTokenValidityInSeconds(), + "ACCESS_TOKEN" + ); + String refreshToken = jwtUtil.createToken( + member.getEmail(), + jwtProperties.getRefreshTokenValidityInSeconds(), + "REFRESH_TOKEN" + ); // 쿠키 생성 및 설정 - Cookie authCookie = cookieUtil.createAuthCookie(token); - response.addCookie(authCookie); + Cookie accessTokenCookie = cookieUtil.createAuthCookie(accessToken); //액세스 토큰 쿠키 + Cookie refreshTokenCookie = cookieUtil.createRefreshCookie(refreshToken); //리프레시 토큰 쿠키 + response.addCookie(accessTokenCookie); + response.addCookie(refreshTokenCookie); // 로그인 응답 생성 LoginResponse loginResponse = new LoginResponse(member.getEmail(), member.getNickname()); diff --git a/src/main/java/com/mycom/socket/auth/service/AuthService.java b/src/main/java/com/mycom/socket/auth/service/AuthService.java index 66e97ef..fd53044 100644 --- a/src/main/java/com/mycom/socket/auth/service/AuthService.java +++ b/src/main/java/com/mycom/socket/auth/service/AuthService.java @@ -1,5 +1,6 @@ package com.mycom.socket.auth.service; +import com.mycom.socket.auth.config.JWTProperties; import com.mycom.socket.auth.dto.response.RegisterResponse; import com.mycom.socket.auth.jwt.JWTUtil; import com.mycom.socket.auth.security.CookieUtil; @@ -28,6 +29,7 @@ public class AuthService { private final JWTUtil jwtUtil; private final MailService mailService; private final CookieUtil cookieUtil; + private final JWTProperties jwtProperties; /** * 사용자 로그인 처리 @@ -46,8 +48,21 @@ public LoginResponse login(LoginRequest request, HttpServletResponse response) { throw new BadRequestException("잘못된 비밀번호입니다."); } - String token = jwtUtil.createToken(member.getEmail()); - response.addCookie(cookieUtil.createAuthCookie(token)); // CookieUtil 사용 + // // 먼저 액세스 토큰 생성 + String accessToken = jwtUtil.createToken(member.getEmail(), + jwtProperties.getRefreshTokenValidityInSeconds(), "ACCESS_TOKEN"); + + Cookie accessTokenCookie = cookieUtil.createAuthCookie(accessToken); + + // 그 다음 리프레시 토큰 생성 + String refreshToken = jwtUtil.createToken(member.getEmail(), + jwtProperties.getAccessTokenValidityInSeconds(), "REFRESH_TOKEN"); + + Cookie refreshTokenCookie = cookieUtil.createRefreshCookie(refreshToken); + + // 쿠키 설정 + response.addCookie(refreshTokenCookie); + response.addCookie(accessTokenCookie); return LoginResponse.of(member.getEmail(), member.getNickname()); } @@ -95,11 +110,12 @@ public RegisterResponse register(RegisterRequest request) { /** * 로그아웃 처리 - * Authorization 쿠키를 무효화하여 로그아웃 처리 + * Access Token과 Refresh Token 쿠키를 무효화하여 로그아웃을 수행합니다. * - * @param response HTTP 응답 객체 (쿠키 무효화용) + * @param response HTTP 응답 객체 */ public void logout(HttpServletResponse response) { - response.addCookie(cookieUtil.createExpiredAuthCookie()); // CookieUtil 사용 + response.addCookie(cookieUtil.createExpiredCookie(jwtProperties.getAccessTokenCookieName())); + response.addCookie(cookieUtil.createExpiredCookie(jwtProperties.getRefreshTokenCookieName())); } } diff --git a/src/main/java/com/mycom/socket/auth/service/MailService.java b/src/main/java/com/mycom/socket/auth/service/MailService.java index 18bfda4..5203ac6 100644 --- a/src/main/java/com/mycom/socket/auth/service/MailService.java +++ b/src/main/java/com/mycom/socket/auth/service/MailService.java @@ -90,15 +90,13 @@ public EmailVerificationResponse verifyCode(String email, String code) { throw new BaseException("유효하지 않은 인증 코드 형식입니다.", HttpStatus.BAD_REQUEST); } - try { - String saveCode = redisService.getCode(code); // 인증코드 검증 - if(!saveCode.equals(code)) { - throw new BaseException("인증 코드가 일치하지 않습니다.", HttpStatus.BAD_REQUEST); - } - return EmailVerificationResponse.of("이메일 인증이 완료되었습니다."); - } catch (Exception e) { + String savedCode = redisService.getCode(email); // Redis에서 코드를 가져옴 + + if(!savedCode.equals(code)){ throw new BaseException("인증 코드가 일치하지 않습니다.", HttpStatus.BAD_REQUEST); } + redisService.saveVerifiedEmail(email); + return EmailVerificationResponse.of("이메일 인증이 완료되었습니다."); } } diff --git a/src/main/java/com/mycom/socket/global/service/RedisService.java b/src/main/java/com/mycom/socket/global/service/RedisService.java index 09e8f9e..104f34c 100644 --- a/src/main/java/com/mycom/socket/global/service/RedisService.java +++ b/src/main/java/com/mycom/socket/global/service/RedisService.java @@ -90,7 +90,7 @@ public void saveVerifiedEmail(String email) { */ public boolean isEmailVerified(String email) { Object verified = redisTemplate.opsForValue().get(VERIFIED_EMAIL_PREFIX + email); - return "true".equals(verified); + return verified != null && "true".equals(verified.toString()); } } diff --git a/src/test/java/com/mycom/socket/member/service/LoginTest.java b/src/test/java/com/mycom/socket/member/service/LoginTest.java index bd07eb9..3aa2c7a 100644 --- a/src/test/java/com/mycom/socket/member/service/LoginTest.java +++ b/src/test/java/com/mycom/socket/member/service/LoginTest.java @@ -1,5 +1,6 @@ package com.mycom.socket.member.service; +import com.mycom.socket.auth.config.JWTProperties; import com.mycom.socket.auth.dto.request.LoginRequest; import com.mycom.socket.auth.dto.response.LoginResponse; import com.mycom.socket.auth.jwt.JWTUtil; @@ -13,7 +14,6 @@ import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -36,6 +36,9 @@ class LoginTest { @Mock private PasswordEncoder passwordEncoder; + @Mock + private JWTProperties jwtProperties; + @Mock private JWTUtil jwtUtil; @@ -70,7 +73,7 @@ class LoginTest { when(memberRepository.findByEmail(email)).thenReturn(Optional.of(member)); when(passwordEncoder.matches(password, encodedPassword)).thenReturn(true); - when(jwtUtil.createToken(email)).thenReturn(token); + when(jwtUtil.createToken(email, jwtProperties.getRefreshTokenValidityInSeconds(), "ACCESS_TOKEN")).thenReturn(token); when(cookieUtil.createAuthCookie(token)).thenReturn(authCookie); // CookieUtil 동작 정의 // when @@ -82,7 +85,7 @@ class LoginTest { assertEquals(nickname, response.nickname()); verify(memberRepository).findByEmail(email); verify(passwordEncoder).matches(password, encodedPassword); - verify(jwtUtil).createToken(email); + verify(jwtUtil).createToken(email, jwtProperties.getRefreshTokenValidityInSeconds(), "ACCESS_TOKEN"); verify(cookieUtil).createAuthCookie(token); }