Skip to content

Commit

Permalink
Feat: Member 기능 구현 (#20)
Browse files Browse the repository at this point in the history
* 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
yugyeom-ghim authored Sep 20, 2024
1 parent 610a808 commit 077b175
Show file tree
Hide file tree
Showing 30 changed files with 699 additions and 8 deletions.
15 changes: 15 additions & 0 deletions src/main/java/notai/auth/Auth.java
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 {
}
37 changes: 37 additions & 0 deletions src/main/java/notai/auth/AuthArgumentResolver.java
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();
}
}
4 changes: 4 additions & 0 deletions src/main/java/notai/auth/TokenPair.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package notai.auth;

public record TokenPair(String accessToken, String refreshToken) {
}
11 changes: 11 additions & 0 deletions src/main/java/notai/auth/TokenProperty.java
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
) {
}
84 changes: 84 additions & 0 deletions src/main/java/notai/auth/TokenService.java
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("유효하지 않은 토큰입니다.");
}
}
}
13 changes: 13 additions & 0 deletions src/main/java/notai/client/HttpInterfaceUtil.java
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);
}
}
11 changes: 11 additions & 0 deletions src/main/java/notai/client/oauth/OauthClient.java
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 src/main/java/notai/client/oauth/OauthClientComposite.java
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("지원하지 않는 소셜 로그인 타입입니다."));
}
}
12 changes: 12 additions & 0 deletions src/main/java/notai/client/oauth/kakao/KakaoClient.java
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 src/main/java/notai/client/oauth/kakao/KakaoClientConfig.java
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 src/main/java/notai/client/oauth/kakao/KakaoMemberResponse.java
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 src/main/java/notai/client/oauth/kakao/KakaoOauthClient.java
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;
}

}
30 changes: 30 additions & 0 deletions src/main/java/notai/common/config/AuthConfig.java
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);
}
}
35 changes: 35 additions & 0 deletions src/main/java/notai/common/config/AuthInterceptor.java
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;
}
}
8 changes: 0 additions & 8 deletions src/main/java/notai/common/config/WebConfig.java

This file was deleted.

Loading

0 comments on commit 077b175

Please sign in to comment.