forked from kakao-tech-campus-2nd-step3/Team29_BE
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* Feat: Member 도메인 구현 * Feat: Kakao 로그인 Client로직 구현 - 안드로이드에서 전달 된 이미 검증이 된 Kakao accesstoken으로 Member조회 * Test: Kakao 로그인 fetchMember 단위테스트 작성 * Feat: JWT 관련 기능 구현 - Interceptor에서 특정 path를 제외하고 토큰이 유효한지 검증 - @Auth 어노테이션이 붙어있으면서 Long타입인 파라미터인 경우 ArgumentResolver에서 토큰검증 및 memberId를 반환해주도록 구현 * Feat: Member API 구현 * Style: 불필요한 import 제거 * Test: 하드코딩 된 값 변수추출 * Refactor: 불필요한 토큰 검증 제거 - Interceptor에서 검증된 토큰으로 memberId를 추출해 ArgumenResolver에서 그 memberId를 사용할 수 있도록 개선
- Loading branch information
1 parent
610a808
commit 077b175
Showing
30 changed files
with
699 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
package notai.auth; | ||
|
||
import io.swagger.v3.oas.annotations.Hidden; | ||
|
||
import java.lang.annotation.Retention; | ||
import java.lang.annotation.Target; | ||
|
||
import static java.lang.annotation.ElementType.PARAMETER; | ||
import static java.lang.annotation.RetentionPolicy.RUNTIME; | ||
|
||
@Hidden | ||
@Target(PARAMETER) | ||
@Retention(RUNTIME) | ||
public @interface Auth { | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
package notai.auth; | ||
|
||
|
||
import jakarta.servlet.http.HttpServletRequest; | ||
import lombok.RequiredArgsConstructor; | ||
import notai.member.domain.MemberRepository; | ||
import org.springframework.core.MethodParameter; | ||
import org.springframework.stereotype.Component; | ||
import org.springframework.web.bind.support.WebDataBinderFactory; | ||
import org.springframework.web.context.request.NativeWebRequest; | ||
import org.springframework.web.method.support.HandlerMethodArgumentResolver; | ||
import org.springframework.web.method.support.ModelAndViewContainer; | ||
|
||
@RequiredArgsConstructor | ||
@Component | ||
public class AuthArgumentResolver implements HandlerMethodArgumentResolver { | ||
|
||
private final MemberRepository memberRepository; | ||
|
||
@Override | ||
public boolean supportsParameter(MethodParameter parameter) { | ||
return parameter.hasParameterAnnotation(Auth.class) | ||
&& parameter.getParameterType().equals(Long.class); | ||
} | ||
|
||
@Override | ||
public Long resolveArgument( | ||
MethodParameter parameter, | ||
ModelAndViewContainer mavContainer, | ||
NativeWebRequest webRequest, | ||
WebDataBinderFactory binderFactory | ||
) { | ||
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); | ||
Long memberId = (Long) request.getAttribute("memberId"); | ||
return memberRepository.getById(memberId).getId(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
package notai.auth; | ||
|
||
public record TokenPair(String accessToken, String refreshToken) { | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
package notai.auth; | ||
|
||
import org.springframework.boot.context.properties.ConfigurationProperties; | ||
|
||
@ConfigurationProperties("token") | ||
public record TokenProperty( | ||
String secretKey, | ||
long accessTokenExpirationMillis, | ||
long refreshTokenExpirationMillis | ||
) { | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
package notai.auth; | ||
|
||
import io.jsonwebtoken.ExpiredJwtException; | ||
import io.jsonwebtoken.Jwts; | ||
import io.jsonwebtoken.io.Decoders; | ||
import io.jsonwebtoken.security.Keys; | ||
import notai.common.exception.type.UnAuthorizedException; | ||
import notai.member.domain.Member; | ||
import notai.member.domain.MemberRepository; | ||
import org.springframework.stereotype.Component; | ||
|
||
import javax.crypto.SecretKey; | ||
import java.util.Date; | ||
|
||
@Component | ||
public class TokenService { | ||
private static final String MEMBER_ID_CLAIM = "memberId"; | ||
|
||
private final SecretKey secretKey; | ||
private final long accessTokenExpirationMillis; | ||
private final long refreshTokenExpirationMillis; | ||
private final MemberRepository memberRepository; | ||
|
||
public TokenService(TokenProperty tokenProperty, MemberRepository memberRepository) { | ||
this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(tokenProperty.secretKey())); | ||
this.accessTokenExpirationMillis = tokenProperty.accessTokenExpirationMillis(); | ||
this.refreshTokenExpirationMillis = tokenProperty.refreshTokenExpirationMillis(); | ||
this.memberRepository = memberRepository; | ||
} | ||
|
||
public String createAccessToken(Long memberId) { | ||
return Jwts.builder() | ||
.claim(MEMBER_ID_CLAIM, memberId) | ||
.issuedAt(new Date()) | ||
.expiration(new Date(System.currentTimeMillis() + accessTokenExpirationMillis)) | ||
.signWith(secretKey, Jwts.SIG.HS512) | ||
.compact(); | ||
} | ||
|
||
private String createRefreshToken() { | ||
return Jwts.builder() | ||
.issuedAt(new Date()) | ||
.expiration(new Date(System.currentTimeMillis() + refreshTokenExpirationMillis)) | ||
.signWith(secretKey, Jwts.SIG.HS512) | ||
.compact(); | ||
} | ||
|
||
public TokenPair createTokenPair(Long memberId) { | ||
String accessToken = createAccessToken(memberId); | ||
String refreshToken = createRefreshToken(); | ||
|
||
Member member = memberRepository.getById(memberId); | ||
member.updateRefreshToken(refreshToken); | ||
memberRepository.save(member); | ||
|
||
return new TokenPair(accessToken, refreshToken); | ||
} | ||
|
||
public TokenPair refreshTokenPair(String refreshToken) { | ||
try { | ||
Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(refreshToken); | ||
} catch (ExpiredJwtException e) { | ||
throw new UnAuthorizedException("만료된 Refresh Token입니다."); | ||
} catch (Exception e) { | ||
throw new UnAuthorizedException("유효하지 않은 Refresh Token입니다."); | ||
} | ||
Member member = memberRepository.getByRefreshToken(refreshToken); | ||
|
||
return createTokenPair(member.getId()); | ||
} | ||
|
||
public Long extractMemberId(String token) { | ||
try { | ||
return Jwts.parser() | ||
.verifyWith(secretKey) | ||
.build() | ||
.parseSignedClaims(token) | ||
.getPayload() | ||
.get(MEMBER_ID_CLAIM, Long.class); | ||
} catch (Exception e) { | ||
throw new UnAuthorizedException("유효하지 않은 토큰입니다."); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
package notai.client; | ||
|
||
import org.springframework.web.client.RestClient; | ||
import org.springframework.web.client.support.RestClientAdapter; | ||
import org.springframework.web.service.invoker.HttpServiceProxyFactory; | ||
|
||
public class HttpInterfaceUtil { | ||
public static <T> T createHttpInterface(RestClient restClient, Class<T> clazz) { | ||
HttpServiceProxyFactory build = HttpServiceProxyFactory | ||
.builderFor(RestClientAdapter.create(restClient)).build(); | ||
return build.createClient(clazz); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
package notai.client.oauth; | ||
|
||
import notai.member.domain.Member; | ||
import notai.member.domain.OauthProvider; | ||
|
||
public interface OauthClient { | ||
|
||
OauthProvider oauthProvider(); | ||
|
||
Member fetchMember(String accessToken); | ||
} |
33 changes: 33 additions & 0 deletions
33
src/main/java/notai/client/oauth/OauthClientComposite.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
package notai.client.oauth; | ||
|
||
import notai.common.exception.type.BadRequestException; | ||
import notai.member.domain.Member; | ||
import notai.member.domain.OauthProvider; | ||
import org.springframework.stereotype.Component; | ||
|
||
import java.util.Map; | ||
import java.util.Optional; | ||
import java.util.Set; | ||
|
||
import static java.util.function.Function.identity; | ||
import static java.util.stream.Collectors.toMap; | ||
|
||
@Component | ||
public class OauthClientComposite { | ||
|
||
private final Map<OauthProvider, OauthClient> oauthClients; | ||
|
||
public OauthClientComposite(Set<OauthClient> oauthClients) { | ||
this.oauthClients = oauthClients.stream() | ||
.collect(toMap(OauthClient::oauthProvider, identity())); | ||
} | ||
|
||
public Member fetchMember(OauthProvider oauthProvider, String accessToken) { | ||
return oauthClients.get(oauthProvider).fetchMember(accessToken); | ||
} | ||
|
||
public OauthClient getOauthClient(OauthProvider oauthProvider) { | ||
return Optional.ofNullable(oauthClients.get(oauthProvider)).orElseThrow( | ||
() -> new BadRequestException("지원하지 않는 소셜 로그인 타입입니다.")); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
package notai.client.oauth.kakao; | ||
|
||
import org.springframework.web.bind.annotation.RequestHeader; | ||
import org.springframework.web.service.annotation.GetExchange; | ||
|
||
import static org.springframework.http.HttpHeaders.AUTHORIZATION; | ||
|
||
public interface KakaoClient { | ||
|
||
@GetExchange(url = "https://kapi.kakao.com/v2/user/me") | ||
KakaoMemberResponse fetchMember(@RequestHeader(name = AUTHORIZATION) String accessToken); | ||
} |
27 changes: 27 additions & 0 deletions
27
src/main/java/notai/client/oauth/kakao/KakaoClientConfig.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
package notai.client.oauth.kakao; | ||
|
||
import lombok.extern.slf4j.Slf4j; | ||
import notai.common.exception.type.ExternalApiException; | ||
import org.springframework.context.annotation.Bean; | ||
import org.springframework.context.annotation.Configuration; | ||
import org.springframework.http.HttpStatusCode; | ||
import org.springframework.web.client.RestClient; | ||
|
||
import static notai.client.HttpInterfaceUtil.createHttpInterface; | ||
|
||
@Slf4j | ||
@Configuration | ||
public class KakaoClientConfig { | ||
|
||
@Bean | ||
public KakaoClient kakaoClient() { | ||
RestClient restClient = RestClient.builder() | ||
.defaultStatusHandler(HttpStatusCode::isError, (request, response) -> { | ||
String responseData = new String(response.getBody().readAllBytes()); | ||
log.error("카카오톡 API 오류 : {}", responseData); | ||
throw new ExternalApiException(responseData, response.getStatusCode().value()); | ||
}) | ||
.build(); | ||
return createHttpInterface(restClient, KakaoClient.class); | ||
} | ||
} |
38 changes: 38 additions & 0 deletions
38
src/main/java/notai/client/oauth/kakao/KakaoMemberResponse.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
package notai.client.oauth.kakao; | ||
|
||
import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; | ||
import com.fasterxml.jackson.databind.annotation.JsonNaming; | ||
import notai.member.domain.Member; | ||
import notai.member.domain.OauthId; | ||
import notai.member.domain.OauthProvider; | ||
|
||
import java.time.LocalDateTime; | ||
|
||
@JsonNaming(value = SnakeCaseStrategy.class) | ||
public record KakaoMemberResponse( | ||
Long id, | ||
boolean hasSignedUp, | ||
LocalDateTime connectedAt, | ||
KakaoAccount kakaoAccount) { | ||
|
||
public Member toDomain() { | ||
return new Member( | ||
new OauthId(String.valueOf(id), OauthProvider.KAKAO), | ||
kakaoAccount.email, | ||
kakaoAccount.profile.nickname); | ||
} | ||
|
||
@JsonNaming(value = SnakeCaseStrategy.class) | ||
public record KakaoAccount( | ||
Profile profile, | ||
boolean emailNeedsAgreement, | ||
boolean isEmailValid, | ||
boolean isEmailVerified, | ||
String email) { | ||
} | ||
|
||
@JsonNaming(value = SnakeCaseStrategy.class) | ||
public record Profile( | ||
String nickname) { | ||
} | ||
} |
27 changes: 27 additions & 0 deletions
27
src/main/java/notai/client/oauth/kakao/KakaoOauthClient.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
package notai.client.oauth.kakao; | ||
|
||
import lombok.RequiredArgsConstructor; | ||
import notai.client.oauth.OauthClient; | ||
import notai.member.domain.Member; | ||
import notai.member.domain.OauthProvider; | ||
import org.springframework.stereotype.Component; | ||
|
||
import static notai.member.domain.OauthProvider.KAKAO; | ||
|
||
@Component | ||
@RequiredArgsConstructor | ||
public class KakaoOauthClient implements OauthClient { | ||
|
||
private final KakaoClient kakaoClient; | ||
|
||
@Override | ||
public Member fetchMember(String accessToken) { | ||
return kakaoClient.fetchMember(accessToken).toDomain(); | ||
} | ||
|
||
@Override | ||
public OauthProvider oauthProvider() { | ||
return KAKAO; | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
package notai.common.config; | ||
|
||
import lombok.RequiredArgsConstructor; | ||
import notai.auth.AuthArgumentResolver; | ||
import org.springframework.context.annotation.Configuration; | ||
import org.springframework.web.method.support.HandlerMethodArgumentResolver; | ||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry; | ||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; | ||
|
||
import java.util.List; | ||
|
||
@RequiredArgsConstructor | ||
@Configuration | ||
public class AuthConfig implements WebMvcConfigurer { | ||
private final AuthInterceptor authInterceptor; | ||
private final AuthArgumentResolver authArgumentResolver; | ||
|
||
@Override | ||
public void addInterceptors(InterceptorRegistry registry) { | ||
registry.addInterceptor(authInterceptor) | ||
.addPathPatterns("/api/**") | ||
.excludePathPatterns("/api/members/oauth/login/**") | ||
.excludePathPatterns("/api/members/token/refresh"); | ||
} | ||
|
||
@Override | ||
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { | ||
resolvers.add(authArgumentResolver); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
package notai.common.config; | ||
|
||
import jakarta.servlet.http.HttpServletRequest; | ||
import jakarta.servlet.http.HttpServletResponse; | ||
import notai.auth.TokenService; | ||
import org.springframework.stereotype.Component; | ||
import org.springframework.web.servlet.HandlerInterceptor; | ||
|
||
import static org.springframework.http.HttpHeaders.AUTHORIZATION; | ||
|
||
@Component | ||
public class AuthInterceptor implements HandlerInterceptor { | ||
private final TokenService tokenService; | ||
private static final String AUTHENTICATION_TYPE = "Bearer "; | ||
private static final int BEARER_PREFIX_LENGTH = 7; | ||
|
||
public AuthInterceptor(TokenService tokenService) { | ||
this.tokenService = tokenService; | ||
} | ||
|
||
@Override | ||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { | ||
String header = request.getHeader(AUTHORIZATION); | ||
if (header == null || !header.startsWith(AUTHENTICATION_TYPE)) { | ||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); | ||
return false; | ||
} | ||
|
||
String token = header.substring(BEARER_PREFIX_LENGTH); | ||
Long memberId = tokenService.extractMemberId(token); | ||
request.setAttribute("memberId", memberId); | ||
|
||
return true; | ||
} | ||
} |
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.