Skip to content

Commit

Permalink
feat: 로그인 기능 고도화 (#12)
Browse files Browse the repository at this point in the history
* feat: 401 예외 처리 추가

* refactor: 패키지 정리

* feat: argument resolver 추가

* test: 테스트코드 보완

* fix: 누락된 사용자 초기화 정보 추가

* fix: 사용자 중복 저장되지 않도록 수정

* refactor: 소셜 로그인

* feat: 제약조건 추가

* chore: member altKey 제거

* chore: userKey -> userId 변경

* test: 테스트코드 추가

* test: 테스트코드 수정

* fix: ci 수정
  • Loading branch information
songyi00 authored Jul 25, 2024
1 parent 116cc27 commit a60f69b
Show file tree
Hide file tree
Showing 33 changed files with 359 additions and 165 deletions.
7 changes: 3 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@ jobs:
java-version: '21'
distribution: 'temurin'

- name: Grant execute permission for gradlew
run: chmod +x gradlew

- name: Build with Gradle
run: ./gradlew clean build
uses: gradle/gradle-build-action@67421db6bd0bf253fb4bd25b31ebb98943c375e1
with:
arguments: clean bootJar
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ dependencies {
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

testImplementation 'io.rest-assured:rest-assured'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
import com.nexters.goalpanzi.application.auth.dto.GoogleLoginRequest;
import com.nexters.goalpanzi.application.auth.dto.LoginResponse;
import com.nexters.goalpanzi.application.auth.dto.TokenResponse;
import com.nexters.goalpanzi.config.jwt.Jwt;
import com.nexters.goalpanzi.config.jwt.JwtManager;
import com.nexters.goalpanzi.common.jwt.Jwt;
import com.nexters.goalpanzi.common.jwt.JwtProvider;
import com.nexters.goalpanzi.domain.auth.RefreshTokenRepository;
import com.nexters.goalpanzi.domain.member.Member;
import com.nexters.goalpanzi.domain.member.MemberRepository;
Expand All @@ -22,27 +22,29 @@ public class AuthService {
private final SocialUserProviderFactory socialUserProviderFactory;
private final MemberRepository memberRepository;
private final RefreshTokenRepository refreshTokenRepository;
private final JwtManager jwtManager;
private final JwtProvider jwtProvider;

public LoginResponse appleOAuthLogin(final AppleLoginRequest request) {
SocialUserProvider appleUserProvider = socialUserProviderFactory.getProvider(SocialType.APPLE);
SocialUserInfo socialUserInfo = appleUserProvider.getSocialUserInfo(request.identityToken());

return login(socialUserInfo.socialId(), socialUserInfo.email());
return socialLogin(socialUserInfo, SocialType.APPLE);
}

public LoginResponse googleOAuthLogin(final GoogleLoginRequest request) {
SocialUserInfo socialUserInfo = new SocialUserInfo(request.identityToken(), request.email());

return login(socialUserInfo.socialId(), socialUserInfo.email());
return socialLogin(socialUserInfo, SocialType.GOOGLE);
}

private LoginResponse login(final String socialId, final String email) {
Member member = memberRepository.save(Member.socialLogin(socialId, email));
private LoginResponse socialLogin(final SocialUserInfo socialUserInfo, final SocialType socialType) {
Member member = memberRepository.findBySocialId(socialUserInfo.socialId())
.orElseGet(() ->
memberRepository.save(Member.socialLogin(socialUserInfo.socialId(), socialUserInfo.email(), socialType))
);

String altKey = member.getAltKey();
Jwt jwt = jwtManager.generateTokens(altKey);
refreshTokenRepository.save(altKey, jwt.refreshToken(), jwt.refreshExpiresIn());
Jwt jwt = jwtProvider.generateTokens(member.getId().toString());
refreshTokenRepository.save(member.getId().toString(), jwt.refreshToken(), jwt.refreshExpiresIn());

return new LoginResponse(jwt.accessToken(), jwt.refreshToken(), member.isProfileSet());
}
Expand All @@ -54,20 +56,20 @@ public void logout(final String altKey) {
public TokenResponse reissueToken(final String altKey, final String refreshToken) {
validateRefreshToken(altKey, refreshToken);

Jwt jwt = jwtManager.generateTokens(altKey);
Jwt jwt = jwtProvider.generateTokens(altKey);
refreshTokenRepository.save(altKey, jwt.refreshToken(), jwt.refreshExpiresIn());

return new TokenResponse(jwt.accessToken(), jwt.refreshToken());
}

private void validateRefreshToken(final String altKey, final String refreshToken) {
String storedRefreshToken = refreshTokenRepository.find(altKey);

if (!refreshToken.equals(storedRefreshToken)) {
throw new UnauthorizedException(ErrorCode.INVALID_REFRESH_TOKEN);
}

if (!jwtManager.validateToken(refreshToken)) {
if (!jwtProvider.validateToken(refreshToken)) {
throw new UnauthorizedException(ErrorCode.EXPIRED_REFRESH_TOKEN);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import static java.util.stream.Collectors.toMap;

@Component
class SocialUserProviderFactory {
public class SocialUserProviderFactory {

public Map<SocialType, SocialUserProvider> providers;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
package com.nexters.goalpanzi.application.auth.apple;

import com.nexters.goalpanzi.util.Nonce;
import com.nexters.goalpanzi.common.util.Nonce;
import io.jsonwebtoken.Claims;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class AppleClaimsValidator {

static final String NONCE_KEY = "nonce";
public static final String NONCE_KEY = "nonce";

private final String iss;
private final String clientId;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.nexters.goalpanzi.application.auth.apple;

import com.nexters.goalpanzi.exception.UnauthorizedException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

Expand All @@ -10,7 +11,6 @@
import java.security.spec.InvalidKeySpecException;
import java.security.spec.RSAPublicKeySpec;
import java.util.Base64;
import java.util.List;
import java.util.Map;

@RequiredArgsConstructor
Expand All @@ -22,10 +22,10 @@ public class ApplePublicKeyGenerator {
private static final int POSITIVE_SIGNUM = 1;

private final AppleApiCaller appleApiCaller;
private final AppleTokenManager appleTokenManager;
private final AppleTokenProvider appleTokenProvider;

public PublicKey generatePublicKey(final String identityToken) {
Map<String, String> tokenHeaders = appleTokenManager.getHeader(identityToken);
Map<String, String> tokenHeaders = appleTokenProvider.getHeader(identityToken);
ApplePublicKeys applePublicKeys = appleApiCaller.getApplePublicKeys();
ApplePublicKey matchesKey =
applePublicKeys.getMatchesKey(tokenHeaders.get(ALG_HEADER_KEY), tokenHeaders.get(KID_HEADER_KEY));
Expand All @@ -43,7 +43,7 @@ private PublicKey generatePublicKeyWithApplePublicKey(final ApplePublicKey apple
KeyFactory keyFactory = KeyFactory.getInstance(applePublicKey.kty());
return keyFactory.generatePublic(publicKeySpec);
} catch (NoSuchAlgorithmException | InvalidKeySpecException exception) {
throw new RuntimeException("응답 받은 Apple Public Key로 PublicKey를 생성할 수 없습니다.");
throw new UnauthorizedException("응답 받은 Apple Public Key로 PublicKey를 생성할 수 없습니다.");
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.nexters.goalpanzi.application.auth.apple;

import com.nexters.goalpanzi.exception.UnauthorizedException;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
Expand All @@ -19,6 +20,6 @@ public ApplePublicKey getMatchesKey(final String alg, final String kid) {
.stream()
.filter(k -> k.alg().equals(alg) && k.kid().equals(kid))
.findFirst()
.orElseThrow(() -> new RuntimeException("Apple JWT 값의 alg, kid 정보가 올바르지 않습니다."));
.orElseThrow(() -> new UnauthorizedException("Apple JWT 값의 alg, kid 정보가 올바르지 않습니다."));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nexters.goalpanzi.exception.BaseException;
import com.nexters.goalpanzi.exception.ErrorCode;
import com.nexters.goalpanzi.exception.UnauthorizedException;
import io.jsonwebtoken.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
Expand All @@ -14,7 +14,7 @@

@RequiredArgsConstructor
@Component
public class AppleTokenManager {
public class AppleTokenProvider {

private static final String IDENTITY_TOKEN_VALUE_DELIMITER = "\\.";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
Expand All @@ -27,7 +27,7 @@ public Map<String, String> getHeader(final String identityToken) {
String decodedHeader = new String(Base64.getUrlDecoder().decode(encodedHeader));
return OBJECT_MAPPER.readValue(decodedHeader, Map.class);
} catch (JsonProcessingException | ArrayIndexOutOfBoundsException e) {
throw new BaseException(ErrorCode.INVALID_APPLE_TOKEN);
throw new UnauthorizedException(ErrorCode.INVALID_APPLE_TOKEN);
}
}

Expand All @@ -40,15 +40,16 @@ public Claims getClaimsIfValid(final String idToken, final PublicKey publicKey)
validateClaims(claims);
return claims;
} catch (ExpiredJwtException e) {
throw new BaseException(ErrorCode.EXPIRED_APPLE_TOKEN);
} catch (UnsupportedJwtException | MalformedJwtException | SignatureException | IllegalArgumentException e) {
throw new BaseException(ErrorCode.INVALID_APPLE_TOKEN);
throw new UnauthorizedException(ErrorCode.EXPIRED_APPLE_TOKEN);
} catch (UnauthorizedException | UnsupportedJwtException |
MalformedJwtException | SignatureException | IllegalArgumentException e) {
throw new UnauthorizedException(ErrorCode.INVALID_APPLE_TOKEN);
}
}

private void validateClaims(final Claims claims) {
if (!appleClaimsValidator.isValid(claims)) {
throw new RuntimeException("Apple OAuth Claims 값이 올바르지 않습니다.");
throw new UnauthorizedException(ErrorCode.INVALID_APPLE_TOKEN);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,14 @@
import org.springframework.stereotype.Component;

import java.security.PublicKey;
import java.util.Map;

@RequiredArgsConstructor
@Component
public class AppleUserProvider implements SocialUserProvider {

private static final String EMAIL = "email";

private final AppleTokenManager appleTokenManager;
private final AppleTokenProvider appleTokenProvider;
private final ApplePublicKeyGenerator applePublicKeyGenerator;

public SocialType getSocialType() {
Expand All @@ -25,7 +24,7 @@ public SocialType getSocialType() {

public SocialUserInfo getSocialUserInfo(final String identityToken) {
PublicKey publicKey = applePublicKeyGenerator.generatePublicKey(identityToken);
Claims claims = appleTokenManager.getClaimsIfValid(identityToken, publicKey);
Claims claims = appleTokenProvider.getClaimsIfValid(identityToken, publicKey);

return new SocialUserInfo(claims.getSubject(), claims.get(EMAIL, String.class));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.nexters.goalpanzi.common.argumentresolver;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUserId {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.nexters.goalpanzi.common.argumentresolver;

import com.nexters.goalpanzi.common.jwt.JwtParser;
import com.nexters.goalpanzi.common.jwt.JwtProvider;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
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 UserIdResolver implements HandlerMethodArgumentResolver {

private final JwtParser jwtParser;
private final JwtProvider jwtProvider;

@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(LoginUserId.class);
}

@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();

String token = jwtParser.resolveToken(request);

return Long.parseLong(jwtProvider.getSubject(token));
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.nexters.goalpanzi.config.jwt;
package com.nexters.goalpanzi.common.filter;

import com.nexters.goalpanzi.common.jwt.JwtParser;
import com.nexters.goalpanzi.common.jwt.JwtProvider;
import com.nexters.goalpanzi.exception.BaseException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
Expand All @@ -13,18 +15,17 @@
import java.util.List;

@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
public class JwtAuthenticationFilter extends OncePerRequestFilter {

private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String AUTHORIZATION_SCHEMA = "Bearer ";
private static final String TOKEN_REISSUE_URI = "/api/auth/token:reissue";
private static final String CONTENT_TYPE = "application/json";
private static final String ATTRIBUTE_NAME = "altKey";
private static final String UNAUTHORIZED_MESSAGE = "{\"error\": \"Unauthorized\"}";

private static final List<String> whitelist = List.of("/api/auth/login");

private final JwtManager jwtManager;
private final JwtProvider jwtProvider;
private final JwtParser jwtParser;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
Expand All @@ -35,15 +36,15 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
}

try {
String token = resolveToken(request);
String token = jwtParser.resolveToken(request);
if (isTokenReissueRequest(requestURI, token)) {
request.setAttribute(ATTRIBUTE_NAME, jwtManager.getSubject(token));
request.setAttribute(ATTRIBUTE_NAME, jwtProvider.getSubject(token));
filterChain.doFilter(request, response);
return;
}

if (isAuthenticated(token)) {
request.setAttribute(ATTRIBUTE_NAME, jwtManager.getSubject(token));
request.setAttribute(ATTRIBUTE_NAME, jwtProvider.getSubject(token));
filterChain.doFilter(request, response);
return;
}
Expand All @@ -58,26 +59,14 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
}
}

private String resolveToken(HttpServletRequest request) {
String authorizationHeader = request.getHeader(AUTHORIZATION_HEADER);
if (isBearerToken(authorizationHeader)) {
return authorizationHeader.substring(AUTHORIZATION_SCHEMA.length());
}
return null;
}

private Boolean isBearerToken(String header) {
return StringUtils.hasText(header) && header.startsWith(AUTHORIZATION_SCHEMA);
}

private Boolean isTokenReissueRequest(String requestURI, String token) {
return requestURI.equals(TOKEN_REISSUE_URI)
&& StringUtils.hasText(token)
&& jwtManager.isExpired(token);
&& jwtProvider.isExpired(token);
}

private Boolean isAuthenticated(String token) {
return StringUtils.hasText(token) && jwtManager.validateToken(token);
return StringUtils.hasText(token) && jwtProvider.validateToken(token);
}

private Boolean isWhitelisted(String requestURI) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.nexters.goalpanzi.config.jwt;
package com.nexters.goalpanzi.common.jwt;

import com.sun.istack.NotNull;
import jakarta.validation.constraints.NotEmpty;
Expand Down
Loading

0 comments on commit a60f69b

Please sign in to comment.