diff --git a/gradle.properties b/gradle.properties index ff5dd21b..3be9c45d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,6 +22,4 @@ sentryVersion=4.1.1 ### AWS-CLOUD ### springCloudAwsVersion=3.1.0 ### JWT ### -jwtVersion=0.9.1 -jwtBindingVersion=4.0.1 -jwtJaxbApiVersion=2.3.1 +jwtVersion=0.11.5 diff --git a/gradle/devtool.gradle b/gradle/devtool.gradle index 5b4ad617..0f7f1cc7 100644 --- a/gradle/devtool.gradle +++ b/gradle/devtool.gradle @@ -3,13 +3,9 @@ allprojects { compileOnly "org.projectlombok:lombok:${lombokVersion}" annotationProcessor "org.projectlombok:lombok" - implementation "io.jsonwebtoken:jjwt:${jwtVersion}" - - // com.sun.xml.bind - implementation "com.sun.xml.bind:jaxb-impl:${jwtBindingVersion}" - implementation "com.sun.xml.bind:jaxb-core:${jwtBindingVersion}" - // javax.xml.bind - implementation "javax.xml.bind:jaxb-api:${jwtJaxbApiVersion}" + implementation "io.jsonwebtoken:jjwt-api:${jwtVersion}" + runtimeOnly "io.jsonwebtoken:jjwt-impl:${jwtVersion}" + runtimeOnly "io.jsonwebtoken:jjwt-jackson:${jwtVersion}" testCompileOnly "org.projectlombok:lombok:${lombokVersion}" testAnnotationProcessor "org.projectlombok:lombok" diff --git a/src/main/java/net/teumteum/auth/service/AuthService.java b/src/main/java/net/teumteum/auth/service/AuthService.java index 13326e5b..ee91cce1 100644 --- a/src/main/java/net/teumteum/auth/service/AuthService.java +++ b/src/main/java/net/teumteum/auth/service/AuthService.java @@ -1,7 +1,6 @@ package net.teumteum.auth.service; import jakarta.servlet.http.HttpServletRequest; -import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.teumteum.auth.domain.response.TokenResponse; @@ -25,16 +24,15 @@ public TokenResponse reissue(HttpServletRequest request) { String accessToken = jwtService.extractAccessToken(request); checkRefreshTokenValidation(refreshToken); - - User user = findUserByAccessToken(accessToken).orElseThrow( - () -> new IllegalArgumentException("access token 에 해당하는 user를 찾을 수 없습니다.")); + User user = findUserByAccessToken(accessToken); checkRefreshTokenMatch(user, refreshToken); return issueNewToken(user); } - public Optional findUserByAccessToken(String accessToken) { - return userConnector.findUserById(Long.parseLong(jwtService.getUserIdFromToken(accessToken))); + public User findUserByAccessToken(String accessToken) { + return userConnector.findUserById(jwtService.getUserIdFromToken(accessToken)) + .orElseThrow(() -> new IllegalArgumentException("access token 에 해당하는 user를 찾을 수 없습니다.")); } private void checkRefreshTokenValidation(String refreshToken) { diff --git a/src/main/java/net/teumteum/auth/service/OAuthService.java b/src/main/java/net/teumteum/auth/service/OAuthService.java index 2adf5f44..410f2e57 100644 --- a/src/main/java/net/teumteum/auth/service/OAuthService.java +++ b/src/main/java/net/teumteum/auth/service/OAuthService.java @@ -46,7 +46,7 @@ public TokenResponse oAuthLogin(String registrationId, String code) { registrationId); Authenticated authenticated = getAuthenticated(clientRegistration.getRegistrationId()); OAuthUserInfo oAuthUserInfo = getOAuthUserInfo(clientRegistration, authenticated, code); - return checkUserAndMakeResponse(oAuthUserInfo, authenticated); + return makeResponse(oAuthUserInfo, authenticated); } private Authenticated getAuthenticated(String registrationId) { @@ -65,7 +65,7 @@ private OAuthUserInfo getOAuthUserInfo(ClientRegistration clientRegistration, Au return new KakaoOAuthUserInfo(oAuthAttribute); } - private TokenResponse checkUserAndMakeResponse(OAuthUserInfo oAuthUserInfo, Authenticated authenticated) { + private TokenResponse makeResponse(OAuthUserInfo oAuthUserInfo, Authenticated authenticated) { String oauthId = oAuthUserInfo.getOAuthId(); return getUser(oauthId, authenticated) diff --git a/src/main/java/net/teumteum/core/security/filter/JwtAccessDeniedHandler.java b/src/main/java/net/teumteum/core/security/filter/JwtAccessDeniedHandler.java index 3a1f71e9..7a6778da 100644 --- a/src/main/java/net/teumteum/core/security/filter/JwtAccessDeniedHandler.java +++ b/src/main/java/net/teumteum/core/security/filter/JwtAccessDeniedHandler.java @@ -1,31 +1,40 @@ package net.teumteum.core.security.filter; -import jakarta.servlet.ServletException; +import static jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN; + +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.OutputStream; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import net.teumteum.core.error.ErrorResponse; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.stereotype.Component; -import java.io.IOException; - -import static jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN; - @Slf4j @Component +@RequiredArgsConstructor public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + @Override public void handle(HttpServletRequest request, - HttpServletResponse response, - AccessDeniedException accessDeniedException - ) throws IOException, ServletException { + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { this.sendUnAuthorizedError(response, accessDeniedException); } private void sendUnAuthorizedError(HttpServletResponse response, - Exception exception) throws IOException { + Exception exception) throws IOException { + response.setStatus(SC_FORBIDDEN); + OutputStream os = response.getOutputStream(); log.error("Responding with unauthorized error. Message - {}", exception.getMessage()); - response.sendError(SC_FORBIDDEN, exception.getMessage()); + objectMapper.writeValue(os, ErrorResponse.of("인가 과정에서 오류가 발생했습니다.")); + os.flush(); } } diff --git a/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationEntryPoint.java b/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationEntryPoint.java index 9bf74010..96c4b543 100644 --- a/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationEntryPoint.java +++ b/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationEntryPoint.java @@ -1,10 +1,13 @@ package net.teumteum.core.security.filter; +import static jakarta.servlet.http.HttpServletResponse.SC_UNAUTHORIZED; + import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.OutputStream; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.teumteum.core.error.ErrorResponse; import org.springframework.security.core.AuthenticationException; @@ -13,22 +16,25 @@ @Slf4j @Component +@RequiredArgsConstructor public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + private static final String ATTRIBUTE_NAME = "exception"; + + private final ObjectMapper objectMapper; + @Override - public void commence(HttpServletRequest request, - HttpServletResponse response, - AuthenticationException authenticationException - ) throws IOException { - this.sendUnAuthenticatedError(response, authenticationException); + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authenticationException) throws IOException { + this.sendUnAuthenticatedError(request, response, authenticationException); } - private void sendUnAuthenticatedError(HttpServletResponse response, - Exception exception) throws IOException { + private void sendUnAuthenticatedError(HttpServletRequest request, HttpServletResponse response, Exception exception) + throws IOException { + response.setStatus(SC_UNAUTHORIZED); OutputStream os = response.getOutputStream(); - ObjectMapper objectMapper = new ObjectMapper(); log.error("Responding with unauthenticated error. Message - {}", exception.getMessage()); - objectMapper.writeValue(os, ErrorResponse.of("인증 과정에서 오류가 발생했습니다.")); + objectMapper.writeValue(os, ErrorResponse.of((String) request.getAttribute(ATTRIBUTE_NAME))); os.flush(); } } diff --git a/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java b/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java index 98181cf4..db33f357 100644 --- a/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java @@ -14,7 +14,6 @@ import net.teumteum.user.domain.User; import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Component; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; @@ -25,6 +24,8 @@ @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { + private static final String ATTRIBUTE_NAME = "exception"; + private final JwtService jwtService; private final AuthService authService; private final JwtProperty jwtProperty; @@ -39,7 +40,7 @@ protected void doFilterInternal(HttpServletRequest request, try { String token = this.resolveTokenFromRequest(request); - if (checkTokenExistenceAndValidation(token)) { + if (checkTokenExistenceAndValidation(request, token)) { User user = getUser(token); saveUserAuthentication(user); } @@ -50,12 +51,19 @@ protected void doFilterInternal(HttpServletRequest request, } private User getUser(String token) { - return this.authService.findUserByAccessToken(token) - .orElseThrow(() -> new UsernameNotFoundException("일치하는 회원 정보가 존재하지 않습니다.")); + return this.authService.findUserByAccessToken(token); } - private boolean checkTokenExistenceAndValidation(String token) { - return StringUtils.hasText(token) && this.jwtService.validateToken(token); + private boolean checkTokenExistenceAndValidation(HttpServletRequest request, String token) { + if (!StringUtils.hasText(token)) { + setRequestAttribute(request, "요청에 대한 JWT 정보가 존재하지 않습니다."); + return false; + } + if (!jwtService.validateToken(token)) { + setRequestAttribute(request, "요청에 대한 JWT 가 유효하지 않습니다."); + return false; + } + return true; } private void saveUserAuthentication(User user) { @@ -66,8 +74,13 @@ private void saveUserAuthentication(User user) { private String resolveTokenFromRequest(HttpServletRequest request) { String token = request.getHeader(jwtProperty.getAccess().getHeader()); if (!ObjectUtils.isEmpty(token) && token.toLowerCase().startsWith(jwtProperty.getBearer().toLowerCase())) { - return token.substring(jwtProperty.getBearer().length()).trim(); + return token.substring(7); } + setRequestAttribute(request, "요청에 대한 JWT 파싱 과정에서 문제가 발생했습니다."); return null; } + + private void setRequestAttribute(HttpServletRequest request, String name) { + request.setAttribute(ATTRIBUTE_NAME, name); + } } diff --git a/src/main/java/net/teumteum/core/security/service/JwtService.java b/src/main/java/net/teumteum/core/security/service/JwtService.java index d29371c6..fda0e80d 100644 --- a/src/main/java/net/teumteum/core/security/service/JwtService.java +++ b/src/main/java/net/teumteum/core/security/service/JwtService.java @@ -1,12 +1,15 @@ package net.teumteum.core.security.service; +import static io.jsonwebtoken.SignatureAlgorithm.HS512; + import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; -import io.jsonwebtoken.Jws; import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; import jakarta.servlet.http.HttpServletRequest; +import java.security.Key; import java.util.Date; import java.util.UUID; import lombok.RequiredArgsConstructor; @@ -14,6 +17,7 @@ import net.teumteum.auth.domain.response.TokenResponse; import net.teumteum.core.property.JwtProperty; import net.teumteum.user.domain.User; +import org.springframework.beans.factory.InitializingBean; import org.springframework.security.oauth2.jwt.JwtException; import org.springframework.stereotype.Service; import org.springframework.util.ObjectUtils; @@ -22,10 +26,20 @@ @Slf4j @Service @RequiredArgsConstructor -public class JwtService { +public class JwtService implements InitializingBean { + + private static final String TOKEN_SUBJECT = "ACCESSTOKEN"; private final JwtProperty jwtProperty; private final RedisService redisService; + private Key key; + + @Override + public void afterPropertiesSet() { + byte[] secretKey = Decoders.BASE64.decode(jwtProperty.getSecret()); + key = Keys.hmacShaKeyFor(secretKey); + } + public String extractAccessToken(HttpServletRequest request) { String accessToken = request.getHeader(jwtProperty.getAccess().getHeader()); @@ -44,17 +58,16 @@ public String extractRefreshToken(HttpServletRequest request) { return null; } - public String getUserIdFromToken(String token) { + public Long getUserIdFromToken(String token) { try { - return Jwts.parser().setSigningKey(jwtProperty.getSecret().getBytes()) - .parseClaimsJws(token).getBody().getSubject(); + return Long.valueOf(getClaims(token).get("id", String.class)); } catch (Exception exception) { throw new JwtException("Access Token is not valid"); } } public TokenResponse createServiceToken(User users) { - String accessToken = createAccessToken(String.valueOf(users.getId())); + String accessToken = createAccessToken(users.getId().toString()); String refreshToken = createRefreshToken(); this.redisService.setDataWithExpiration(String.valueOf(users.getId()), refreshToken, @@ -63,38 +76,44 @@ public TokenResponse createServiceToken(User users) { return new TokenResponse(jwtProperty.getBearer() + " " + accessToken, refreshToken); } - public String createAccessToken(String payload) { - return this.createToken(payload, jwtProperty.getAccess().getExpiration()); + public String createAccessToken(String userId) { + return this.createToken(userId, jwtProperty.getAccess().getExpiration()); } public String createRefreshToken() { return this.createToken(UUID.randomUUID().toString(), jwtProperty.getRefresh().getExpiration()); } - private String createToken(String payload, Long tokenExpiration) { - Claims claims = Jwts.claims().setSubject(payload); - Date tokenExpiresIn = new Date(new Date().getTime() + tokenExpiration); + private String createToken(String payLoad, Long tokenExpiration) { + Date tokenExpiresIn = new Date(new Date().getTime() + tokenExpiration); return Jwts.builder() - .setClaims(claims) - .setIssuedAt(new Date()) + .setSubject(TOKEN_SUBJECT) + .claim("id", payLoad) + .signWith(key, HS512) .setExpiration(tokenExpiresIn) - .signWith(SignatureAlgorithm.HS512, jwtProperty.getSecret().getBytes()) .compact(); } public boolean validateToken(String token) { try { - Jws claimsJws = Jwts.parser().setSigningKey(jwtProperty.getSecret().getBytes()) - .parseClaimsJws(token); - return !claimsJws.getBody().getExpiration().before(new Date()); - } catch (ExpiredJwtException exception) { - log.warn("만료된 jwt 입니다."); - } catch (UnsupportedJwtException exception) { - log.warn("지원되지 않는 jwt 입니다."); - } catch (IllegalArgumentException exception) { - log.warn("jwt 에 오류가 존재합니다."); + Claims claims = getClaims(token); + return !claims.getExpiration().before(new Date()); + } catch (ExpiredJwtException e) { + log.error("JWT 가 만료되었습니다."); + } catch (UnsupportedJwtException e) { + log.error("지원되지 않는 JWT 입니다."); + } catch (IllegalArgumentException e) { + log.error("JWT 가 잘못되었습니다."); } return false; } + + private Claims getClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } } diff --git a/src/test/java/net/teumteum/core/property/PropertyTest.java b/src/test/java/net/teumteum/core/property/PropertyTest.java index b86415ba..72fe17d2 100644 --- a/src/test/java/net/teumteum/core/property/PropertyTest.java +++ b/src/test/java/net/teumteum/core/property/PropertyTest.java @@ -55,11 +55,10 @@ class Read_jwt_value_from_application_yml { void Make_jwt_property_from_application_yml() { // given String expectedBearer = "Bearer"; - String expectedSecret = "secret"; // when & then Assertions.assertEquals(expectedBearer, jwtProperty.getBearer()); - Assertions.assertEquals(expectedSecret, jwtProperty.getSecret()); + Assertions.assertNotNull(jwtProperty.getSecret()); } } } diff --git a/src/test/java/net/teumteum/unit/auth/service/AuthServiceTest.java b/src/test/java/net/teumteum/unit/auth/service/AuthServiceTest.java index 4312b9f3..5e194698 100644 --- a/src/test/java/net/teumteum/unit/auth/service/AuthServiceTest.java +++ b/src/test/java/net/teumteum/unit/auth/service/AuthServiceTest.java @@ -59,7 +59,7 @@ void Return_new_jwt_if_access_and_refresh_is_exist() { given(jwtService.extractRefreshToken(any(HttpServletRequest.class))).willReturn("refresh token"); - given(jwtService.getUserIdFromToken(anyString())).willReturn("1"); + given(jwtService.getUserIdFromToken(anyString())).willReturn(1L); given(jwtService.createAccessToken(anyString())).willReturn("new access token"); @@ -85,6 +85,7 @@ void Return_new_jwt_if_access_and_refresh_is_exist() { @Test @DisplayName("유효하지 않은 access token 과 유효하지 않은 refresh token 이 주어지면, 500 server 에러로 응답한다. ") void Return_500_bad_request_if_refresh_token_is_not_valid() { + // given Optional user = Optional.of(new User(1L, "oauthId", 네이버)); HttpServletRequest httpServletRequest = mock(HttpServletRequest.class); @@ -95,14 +96,14 @@ void Return_500_bad_request_if_refresh_token_is_not_valid() { given(jwtService.validateToken(anyString())).willReturn(true); - given(jwtService.getUserIdFromToken(anyString())).willReturn("1"); + given(jwtService.getUserIdFromToken(anyString())).willReturn(1L); given(userConnector.findUserById(anyLong())).willReturn(user); given(redisService.getData(anyString())).willThrow( new IllegalArgumentException("refresh token 이 일치하지 않습니다.")); - // when + // when & then assertThatThrownBy(() -> authService.reissue(httpServletRequest)).isInstanceOf( IllegalArgumentException.class).hasMessage("refresh token 이 일치하지 않습니다."); diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index d9752b2a..8e75058c 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -27,10 +27,10 @@ spring.security.oauth2.client.provider.kakao.user-name-attribute=https://kauth.k spring.cloud.aws.credentials.access-key=12345678910 spring.cloud.aws.credentials.secret-key=12345678910 spring.cloud.aws.region.static=ap-northeast-2 -spring.cloud.aws.s3.bucket: test-bucket +spring.cloud.aws.s3.bucket=test-bucket ### Redis ### spring.data.redis.host=localhost spring.data.redis.port=6378 ### JWT ### jwt.bearer=Bearer -jwt.secret=secret +jwt.secret=a2FyaW10b2thcmltdG9rYXJpbXRva2FyaW10b2thcmltdG9rYXJpbXRva2FyaW10b2thcmltdG9rYXJpbXRva2FyaW10b2thcmltdG9rYXJpbXRvsdsadwsadasdwSDSAweasDSadwXJsecretsecretsecretsecretsecreetsecret