JWT + OAuth2.0을 활용하여 로그인, 회원가입 구현하기 (React, Spring) (JWT + OAuth2.0 프로세스)

Lord·2024년 7월 23일
post-thumbnail

첫 번째 글에서 이어지는 두번째 글이다. 이번에는 인증과 관련된 주요 소스코드에 대한 설명을 할 예정이다.

왜 JWT를 사용하였는가?

JWT란?

JWT(JSON Web Token)는 JSON 형식의 데이터를 이용해 두 개체 간 정보를 안전하게 전송하기 위한 토큰이다. 이 토큰은 주로 사용자 인증 및 정보 교환에 사용되며, Header, Payload, Signature 세 부분으로 구성된다.

JWT의 특징

  1. 자가 포함 토큰(Self-contained Token): JWT는 인증에 필요한 모든 정보를 자체적으로 포함하고 있어, 서버가 별도의 저장소 없이도 클라이언트 요청을 검증할 수 있다.

  2. 무결성과 보안: JWT는 서명을 통해 데이터의 무결성을 보장한다. 이 서명은 데이터가 변조되지 않았음을 확인하며, 비밀키 또는 공개키를 사용하여 생성된다.

  3. 다양한 사용 사례: JWT는 인증 외에도 데이터 교환, 권한 부여 등 다양한 용도로 활용될 수 있다. 예를 들어, OAuth 2.0에서 액세스 토큰으로 사용되며, 사용자 권한 정보를 포함할 수 있다.

JWT 사용 이유

  1. 간편한 인증 처리: 서버 측에서 세션을 관리할 필요가 없기 때문에 인증 처리가 간편해진다. 클라이언트가 JWT를 서버에 전달하면, 서버는 이를 검증하고 필요한 정보를 추출한다.

  2. 확장성: 서버가 세션을 유지하지 않으므로, 서버 확장 시 추가 작업이 필요 없다. 각 서버는 독립적으로 JWT를 검증할 수 있어 상태 정보를 공유할 필요가 없다.

  3. Cross-domain 인증: JWT는 특정 도메인에 종속되지 않으므로, 여러 도메인 간에 인증 정보를 쉽게 공유할 수 있다. 프론트와 백엔드 서버를 따로 구성한 프로젝트여서 편리하였다.

  4. 유연한 토큰 구성: JWT의 Payload 부분에 다양한 정보를 포함할 수 있어, 필요한 데이터를 손쉽게 추가할 수 있다. 이는 사용자 권한, 만료 시간 등의 정보를 포함하여 유연하게 토큰을 구성할 수 있게 해준다.

JWT관련 주요 소스코드

Jwt 서비스 (JwtServiceImpl.java)

@Override
public String createAccessToken(String email, String role) {
    Date now = new Date();
    return JWT.create()
            .withSubject(ACCESS_TOKEN_SUBJECT)
            .withExpiresAt(new Date(now.getTime() + accessTokenExpirationPeriod))
            .withClaim(EMAIL_CLAIM, email)
            .withClaim(ROLE_CLAIM, role)
            .sign(Algorithm.HMAC512(secretKey));
}

@Override
public String createRefreshToken(String email) {
    Date now = new Date();
    return JWT.create()
            .withSubject(REFRESH_TOKEN_SUBJECT)
            .withExpiresAt(new Date(now.getTime() + refreshTokenExpirationPeriod))
            .withClaim(EMAIL_CLAIM, email)
            .sign(Algorithm.HMAC512(secretKey));
}

@Override
public void sendAccessToken(HttpServletResponse response, String accessToken) {
    response.setStatus(HttpServletResponse.SC_OK);
    response.setHeader(accessHeader, accessToken);
    log.info("Access Token : {}", accessToken);
}

@Override
public void sendAccessTokenAndRefreshToken(HttpServletResponse response, String accessToken, String refreshToken) {
    response.setStatus(HttpServletResponse.SC_OK);

    setAccessTokenHeader(response, accessToken);
    setRefreshTokenHeader(response, refreshToken);

    log.info("Access Token, RefreshToken Header Complete");
}

@Override
public Optional<String> extractAccessToken(HttpServletRequest request) {
    return Optional.ofNullable(request.getHeader(accessHeader))
            .filter(accessToken -> accessToken.startsWith(BEARER))
            .map(accessToken -> accessToken.replace(BEARER, ""));
}

@Override
public Optional<String> extractRefreshToken(HttpServletRequest request) {
    return Optional.ofNullable(request.getHeader(refreshHeader))
            .filter(refreshToken -> refreshToken.startsWith(BEARER))
            .map(refreshToken -> refreshToken.replace(BEARER, ""));
}

@Override
public Optional<String> extractEmail(String token) {
    try {
        return Optional.ofNullable(JWT.require(Algorithm.HMAC512(secretKey))
                .build()
                .verify(token)
                .getClaim(EMAIL_CLAIM)
                .asString());
    } catch (Exception e) {
        log.error("유효하지 않은 토큰입니다.");
        return Optional.empty();
    }
}

@Override
public void updateRefreshToken(String email, String refreshToken) {
    if (memberRepository.findByEmail(email).isPresent()) {
        redisService.setValues("RefreshToken" + email, refreshToken);
    }
}

@Override
public boolean isTokenValid(String token) {
    try {
        JWT.require(Algorithm.HMAC512(secretKey)).build().verify(token);
        return true;
    } catch (Exception e) {
        log.error("유효하지 않은 토큰입니다. {}", e.getMessage());
        return false;
    }
}

JwtServiceImpl 클래스는 JWT 토큰을 생성, 검증, 관리하는 기능을 제공하는 서비스 구현체이다.

  1. createAccessToken

    • 설명: 주어진 이메일과 역할 정보를 포함한 액세스 토큰을 생성한다.
    • 입력: email, role
    • 출력: 생성된 액세스 토큰 (String)
  2. createRefreshToken

    • 설명: 주어진 이메일 정보를 포함한 리프레시 토큰을 생성한다.
    • 입력: email
    • 출력: 생성된 리프레시 토큰 (String)
  3. sendAccessToken

    • 설명: HTTP 응답 헤더에 액세스 토큰을 설정한다.
    • 입력: HttpServletResponse, accessToken
    • 출력: 없음
  4. sendAccessTokenAndRefreshToken

    • 설명: HTTP 응답 헤더에 액세스 토큰과 리프레시 토큰을 설정한다.
    • 입력: HttpServletResponse, accessToken, refreshToken
    • 출력: 없음
  5. extractAccessToken

    • 설명: HTTP 요청 헤더에서 액세스 토큰을 추출한다.
    • 입력: HttpServletRequest
    • 출력: 액세스 토큰 (Optional<String>)
  6. extractRefreshToken

    • 설명: HTTP 요청 헤더에서 리프레시 토큰을 추출한다.
    • 입력: HttpServletRequest
    • 출력: 리프레시 토큰 (Optional<String>)
  7. extractEmail

    • 설명: 주어진 토큰에서 이메일 클레임을 추출한다.
    • 입력: token
    • 출력: 이메일 (Optional<String>)
  8. updateRefreshToken

    • 설명: 주어진 이메일에 해당하는 리프레시 토큰을 Redis에 업데이트한다.
    • 입력: email, refreshToken
    • 출력: 없음
  9. isTokenValid

    • 설명: 주어진 토큰의 유효성을 검증한다.
    • 입력: token
    • 출력: 유효 여부 (boolean)

Jwt 인증 필터 (JwtAuthenticationFilter.java)

@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private static final String IGNORE_URL = "/members/login";

    private final JwtService jwtService;
    private final MemberRepository memberRepository;
    private final RedisService redisService;

    private final GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if (request.getRequestURI().equals(IGNORE_URL)) {
            filterChain.doFilter(request, response);
            return;
        }

        String refreshToken = jwtService.extractRefreshToken(request)
                .filter(jwtService::isTokenValid)
                .orElse(null);

        if (refreshToken != null) {
            checkRefreshTokenAndReissueAccessToken(response, refreshToken);
            return;
        }

        checkAccessToken(request, response, filterChain);
    }

    public void checkRefreshTokenAndReissueAccessToken(HttpServletResponse response, String refreshToken) {
        String email = String.valueOf(jwtService.extractEmail(refreshToken));
        String storedRefreshToken = redisService.getValues("RefreshToken" + email);
        if (storedRefreshToken == null || !storedRefreshToken.equals(refreshToken)) {
            throw new BadCredentialsException("Invalid refresh token");
        }
        memberRepository.findByEmail(email)
                .ifPresent(member -> {
                    String reissuedRefreshToken = reissueRefreshToken(member.getEmail());
                    jwtService.sendAccessTokenAndRefreshToken(response, jwtService.createAccessToken(member.getEmail(), String.valueOf(member.getRole())),
                            reissuedRefreshToken);
                });
    }

    private String reissueRefreshToken(String email) {
        String reissuedRefreshToken = jwtService.createRefreshToken(email);
        redisService.setValues("RefreshToken" + email, reissuedRefreshToken);
        return reissuedRefreshToken;
    }

    public void checkAccessToken(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        log.info("checkAccessToken");
        jwtService.extractAccessToken(request)
                .filter(jwtService::isTokenValid)
                .flatMap(accessToken -> jwtService.extractEmail(accessToken)
                        .flatMap(memberRepository::findByEmail))
                .ifPresent(this::saveAuthentication);
        filterChain.doFilter(request, response);
    }

    public void saveAuthentication(Member member) {
        String password = member.getPassword();
        if (password == null) {
            password = getRandomPassword(10);
        }

        UserDetails userDetails = User.builder()
                .username(member.getEmail())
                .password(password)
                .roles(member.getRole().name())
                .build();

        Authentication authentication =
                new UsernamePasswordAuthenticationToken(userDetails, null,
                        authoritiesMapper.mapAuthorities(userDetails.getAuthorities()));

        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

    private static final char[] rndAllCharacters = new char[]{
            //number
            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
            //uppercase
            'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
            'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
            //lowercase
            'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
            'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
            //special symbols
            '@', '$', '!', '%', '*', '?', '&'
    };

    public String getRandomPassword(int length) {
        SecureRandom random = new SecureRandom();
        StringBuilder stringBuilder = new StringBuilder();

        int rndAllCharactersLength = rndAllCharacters.length;
        for (int i = 0; i < length; i++) {
            stringBuilder.append(rndAllCharacters[random.nextInt(rndAllCharactersLength)]);
        }

        return stringBuilder.toString();
    }
}

JwtAuthenticationFilter 클래스는 JWT 토큰을 기반으로 인증을 처리하는 필터 클래스이다. OncePerRequestFilter를 상속받아 각 요청마다 한 번씩 필터링 작업을 수행한다.

  1. doFilterInternal

    • 설명: 요청을 필터링하여 JWT 토큰을 검사하고, 유효한 경우 인증을 설정한다.
    • 로직:
      • 요청 URI가 /members/login인 경우 필터링을 건너뛴다.
      • 요청 헤더에서 리프레시 토큰을 추출하고, 유효한 경우 액세스 토큰을 재발급하여 응답 헤더에 설정한다.
      • 리프레시 토큰이 없거나 유효하지 않은 경우 액세스 토큰을 검사한다.
  2. checkRefreshTokenAndReissueAccessToken

    • 설명: 리프레시 토큰을 검사하고, 유효한 경우 새로운 액세스 토큰과 리프레시 토큰을 재발급한다.
    • 로직:
      • 리프레시 토큰에서 이메일을 추출하고, Redis에서 저장된 리프레시 토큰과 일치하는지 확인한다.
      • 일치하는 경우 새로운 리프레시 토큰을 생성하고, 액세스 토큰과 함께 응답 헤더에 설정한다.
  3. checkAccessToken

    • 설명: 액세스 토큰을 검사하고, 유효한 경우 인증을 설정한다.
    • 로직:
      • 요청 헤더에서 액세스 토큰을 추출하고, 유효한 경우 토큰에서 이메일을 추출한다.
      • 이메일을 통해 회원 정보를 조회하고, 인증 정보를 설정한다.
  4. saveAuthentication

    • 설명: 인증 정보를 설정한다.
    • 로직:
      • 회원 정보에서 이메일과 비밀번호를 가져와 UserDetails 객체를 생성한다.
      • UsernamePasswordAuthenticationToken을 생성하고, SecurityContextHolder에 인증 정보를 설정한다.
  5. getRandomPassword

    • 설명: 지정된 길이의 임의의 비밀번호를 생성한다.
    • 로직:
      • 숫자, 대문자, 소문자, 특수문자 배열에서 무작위로 문자를 선택하여 비밀번호를 생성한다.

이 클래스는 JWT 토큰을 기반으로 사용자를 인증하고, 필요한 경우 리프레시 토큰을 통해 새로운 액세스 토큰을 재발급하여 보안성을 유지한다. 랜덤 패스워드 같은 로직은 OAuth2.0 로그인시 따로 비밀번호를 입력 받지 않기 때문에 랜덤한 비밀번호를 데이터에 넣어 활용하기 위해 추가하였다.


OAuth2.0 주요 프로세스 (카카오로 로그인하기)

OAuth2.0은 웹 애플리케이션에서 사용자 인증과 권한 부여를 구현하기 위한 표준 프로토콜이다. Spring Security를 이용하여 카카오 로그인을 활용한 OAuth2.0 구현의 주요 프로세스를 알아보자.

OAuth2.0 로그인 흐름

  1. 사용자 인증 요청:

    • 사용자가 클라이언트 애플리케이션에 로그인 요청을 하면, 클라이언트는 카카오 권한 서버로 리디렉션한다.
  2. 권한 서버 인증:

    • 사용자가 카카오 로그인 페이지에서 자격 증명을 입력하여 인증을 완료하면, 카카오는 인가 코드(Authorization Code)를 클라이언트 애플리케이션에 리디렉션한다.
  3. 인가 코드 교환:

    • 클라이언트 애플리케이션은 인가 코드를 사용하여 카카오 권한 서버에 액세스 토큰을 요청한다.
  4. 액세스 토큰 수신:

    • 카카오 권한 서버는 클라이언트 애플리케이션에 액세스 토큰을 반환한다.
  5. 사용자 정보 요청:

    • 클라이언트 애플리케이션은 액세스 토큰을 사용하여 카카오 리소스 서버에 사용자 정보를 요청한다.
  6. 사용자 정보 수신:

    • 카카오 리소스 서버는 사용자 정보를 클라이언트 애플리케이션에 반환한다.
  7. 인증 완료:

    • 클라이언트 애플리케이션은 수신한 사용자 정보를 기반으로 세션을 생성하고 사용자를 인증한다.

로그인 후 처리

  1. 리디렉션:

    • 인증이 완료된 사용자가 만약 처음 로그인하였으면 OAuth 전용 회원가입 페이지로 이동하고 기존에 가입한 이력이 있는 사용자라면 홈으로 리디렉션된다.
  2. 세션 관리:

    • 사용자의 세션을 관리하고, 인증된 사용자로서 애플리케이션의 기능을 이용할 수 있다.

OAuth2.0 관련 주요 소스코드

커스텀 OAuth2 회원 서비스 (CustomOAuth2MemberService.java)

@Slf4j
@Service
@RequiredArgsConstructor
public class CustomOAuth2MemberService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final MemberRepository memberRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        log.info("OAuth2 로그인 요청이 들어왔습니다.");
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

        SocialType socialType = SocialType.KAKAO;
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
        Map<String, Object> attributes = oAuth2User.getAttributes();

        OAuth2Attributes extractAttributes = OAuth2Attributes.of(userNameAttributeName, attributes);

        Member createdMember = getMember(extractAttributes, socialType);

        return new CustomOAuth2Member(
                Collections.singleton(new SimpleGrantedAuthority(createdMember.getRole().getKey())),
                attributes,
                extractAttributes.getNameAttributeKey(),
                createdMember.getEmail(),
                createdMember.getRole()
        );
    }

    private Member getMember(OAuth2Attributes attributes, SocialType socialType) {
        Member findMember = memberRepository.findBySocialTypeAndSocialId(socialType, attributes.getOAuth2MemberInfo().getId()).orElse(null);

        if (findMember == null) {
            return saveMember(attributes, socialType);
        }
        return findMember;
    }

    private Member saveMember(OAuth2Attributes oAuth2Attributes, SocialType socialType) {
        if (memberRepository.findByEmail(oAuth2Attributes.getOAuth2MemberInfo().getEmail()).isPresent()) {
            Member existingMember = memberRepository.findByEmail(oAuth2Attributes.getOAuth2MemberInfo().getEmail()).get();
            SocialType existingSocialType = existingMember.getSocialType();
            throw new BadCredentialsException(oAuth2Attributes.getOAuth2MemberInfo().getEmail() + "&socialType=" + existingSocialType);
        }
        Member createdMember = oAuth2Attributes.toEntity(socialType, oAuth2Attributes.getOAuth2MemberInfo());
        return memberRepository.save(createdMember);
    }
}

CustomOAuth2MemberService 클래스는 OAuth2 인증을 통해 로그인한 사용자의 정보를 처리하는 서비스 클래스이다. OAuth2UserService<OAuth2UserRequest, OAuth2User> 인터페이스를 구현하여 OAuth2 인증 요청을 처리한다.

  1. loadUser

    • 설명: OAuth2 로그인 요청이 들어왔을 때 호출되며, 사용자 정보를 로드하고 회원 정보를 반환한다.
    • 로직:
      • DefaultOAuth2UserService를 사용하여 기본 OAuth2 사용자 정보를 로드한다.
      • 사용자 정보를 기반으로 OAuth2Attributes 객체를 생성한다.
      • getMember 메소드를 호출하여 회원 정보를 가져오거나, 새로운 회원을 생성한다.
      • CustomOAuth2Member 객체를 생성하여 반환한다.
  2. getMember

    • 설명: 소셜 타입과 소셜 ID를 사용하여 회원 정보를 조회한다. 만약 해당 회원이 존재하지 않으면, 새로운 회원을 생성한다.
    • 입력: OAuth2Attributes, SocialType
    • 출력: Member
    • 로직:
      • 소셜 타입과 소셜 ID를 기반으로 회원 정보를 조회한다.
      • 회원이 존재하지 않으면 saveMember 메소드를 호출하여 새로운 회원을 생성한다.
  3. saveMember

    • 설명: 새로운 회원 정보를 저장한다.
    • 입력: OAuth2Attributes, SocialType
    • 출력: Member
    • 로직:
      • 이메일을 기반으로 기존 회원이 존재하는지 확인한다.
      • 기존 회원이 존재하는 경우 BadCredentialsException 예외를 발생시킨다.
      • 새로운 회원 정보를 생성하고 저장한다.

OAuth2 인증을 통해 로그인한 사용자의 정보를 처리하고, 해당 사용자가 기존 회원인지 확인하거나 새로운 회원을 생성하여 저장하는 역할을 한다. 로그를 통해 OAuth2 로그인 요청을 기록하며, CustomOAuth2Member 객체를 생성하여 반환한다.

OAuth2 속성 클래스 (OAuth2Attributes.java)

@Getter
public class OAuth2Attributes {
    private final String nameAttributeKey;
    private final OAuth2MemberInfo oAuth2MemberInfo;

    @Builder
    private OAuth2Attributes(String nameAttributeKey, OAuth2MemberInfo oAuth2MemberInfo) {
        this.nameAttributeKey = nameAttributeKey;
        this.oAuth2MemberInfo = oAuth2MemberInfo;
    }

    public static OAuth2Attributes of(String userNameAttributeName, Map<String, Object> attributes) {
        return ofKakao(userNameAttributeName, attributes);
    }

    private static OAuth2Attributes ofKakao(String userNameAttributeName, Map<String, Object> attributes) {
        return OAuth2Attributes.builder()
                .nameAttributeKey(userNameAttributeName)
                .oAuth2MemberInfo(new KakaoOAuth2MemberInfo(attributes))
                .build();
    }

    public Member toEntity(SocialType socialType, OAuth2MemberInfo oAuth2MemberInfo) {
        return Member.builder()
                .socialType(socialType)
                .socialId(oAuth2MemberInfo.getId())
                .email(oAuth2MemberInfo.getEmail())
                .role(Role.GUEST)
                .build();
    }
}

OAuth2Attributes 클래스는 OAuth2 인증을 통해 얻은 사용자 정보를 처리하고, Member 엔티티로 변환하는 역할을 한다. 이 클래스는 다양한 OAuth2 제공자(Google, Kakao 등)로부터 받은 사용자 정보를 일관된 방식으로 다룬다.

  1. 필드

    • nameAttributeKey: 사용자 이름을 식별하는 키.
    • oAuth2MemberInfo: OAuth2 회원 정보를 담고 있는 객체.
  2. 생성자 및 빌더

    • 설명: @Builder 어노테이션을 통해 객체를 생성하며, nameAttributeKeyoAuth2MemberInfo를 초기화한다.
  3. of 메소드

    • 설명: OAuth2 제공자의 사용자 정보를 OAuth2Attributes 객체로 변환한다.
    • 입력: userNameAttributeName, attributes
    • 출력: OAuth2Attributes
    • 로직: 현재는 Kakao 인증만 처리하도록 ofKakao 메소드를 호출한다.
  4. ofKakao 메소드

    • 설명: Kakao로부터 받은 사용자 정보를 OAuth2Attributes 객체로 변환한다.
    • 입력: userNameAttributeName, attributes
    • 출력: OAuth2Attributes
    • 로직:
      • nameAttributeKey를 설정한다.
      • KakaoOAuth2MemberInfo 객체를 생성하여 oAuth2MemberInfo 필드에 설정한다.
  5. toEntity 메소드

    • 설명: OAuth2Attributes 객체를 Member 엔티티로 변환한다.
    • 입력: socialType, oAuth2MemberInfo
    • 출력: Member
    • 로직:
      • Member.builder()를 사용하여 Member 객체를 생성한다.
      • socialType, socialId, email, role을 설정하여 Member 객체를 초기화한다.

OAuth2 로그인 성공 핸들러 (OAuth2LoginSuccessHandler.java)

@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtService jwtService;
    private final MemberRepository memberRepository;
    private final RedisService redisService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, SecurityException {
        log.info("OAuth2 로그인 성공");
        CustomOAuth2Member oAuth2User = (CustomOAuth2Member) authentication.getPrincipal();
        if (oAuth2User.getRole() == Role.GUEST) {
            Member member = memberRepository.findByEmail(oAuth2User.getEmail()).orElseThrow(() -> new MemberException(MemberExceptionType.NOT_FOUND_MEMBER));
            String accessToken = jwtService.createAccessToken(oAuth2User.getEmail(), Role.USER.getKey());
            String targetUrl = UriComponentsBuilder.fromUriString("http://localhost:3000/members/oauth2/join")
                    .queryParam("email", member.getEmail())
                    .queryParam("accessToken", accessToken)
                    .build()
                    .encode(StandardCharsets.UTF_8)
                    .toUriString();
            getRedirectStrategy().sendRedirect(request, response, targetUrl);
        } else {
            loginSuccess(request, response, oAuth2User);
        }
    }

    private void loginSuccess(HttpServletRequest request, HttpServletResponse response, CustomOAuth2Member oAuth2User) throws IOException {
        log.info("OAuth2.0 기존 사용자 로그인");
        Member member = memberRepository.findByEmail(oAuth2User.getEmail()).orElseThrow(() -> new MemberException(MemberExceptionType.NOT_FOUND_MEMBER));
        String accessToken = jwtService.createAccessToken(oAuth2User.getEmail(), oAuth2User.getRole().getKey());
        String refreshToken = jwtService.createRefreshToken(oAuth2User.getEmail());

        if (redisService.getValues("RefreshToken" + oAuth2User.getEmail()) != null && jwtService.isTokenValid(refreshToken)) {
            refreshToken = redisService.getValues("RefreshToken" + oAuth2User.getEmail());
        }

        jwtService.updateRefreshToken(oAuth2User.getEmail(), refreshToken);

        String targetUrl = UriComponentsBuilder.fromUriString("http://localhost:3000/login")
                .queryParam("email", member.getEmail())
                .queryParam("nickname", member.getNickname())
                .queryParam("accessToken", accessToken)
                .queryParam("refreshToken", refreshToken)
                .build()
                .encode(StandardCharsets.UTF_8)
                .toUriString();

        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }
}

OAuth2LoginSuccessHandler 클래스는 OAuth2 인증이 성공적으로 완료되었을 때 호출되는 핸들러이다. SimpleUrlAuthenticationSuccessHandler를 상속받아 OAuth2 인증 성공 시 추가 작업을 처리한다.

  1. onAuthenticationSuccess

    • 설명: OAuth2 인증이 성공적으로 완료되었을 때 호출된다.
    • 로직:
      • CustomOAuth2Member 객체를 통해 인증된 사용자 정보를 가져온다.
      • 사용자의 역할이 GUEST인 경우, 사용자를 추가 정보 입력 페이지로 리디렉션한다.
        • 회원 정보를 조회하여 액세스 토큰을 생성한다.
        • UriComponentsBuilder를 사용하여 추가 정보 입력 페이지의 URL을 생성하고 리디렉션한다.
      • 사용자의 역할이 GUEST가 아닌 경우, loginSuccess 메소드를 호출하여 기존 사용자로 로그인 처리를 한다.
  2. loginSuccess

    • 설명: 기존 사용자로 로그인 처리를 한다.
    • 로직:
      • 회원 정보를 조회하고, 액세스 토큰과 리프레시 토큰을 생성한다.
      • 기존 리프레시 토큰이 유효한 경우, Redis에서 가져온 리프레시 토큰을 사용한다.
      • Redis에 리프레시 토큰을 업데이트한다.
      • UriComponentsBuilder를 사용하여 로그인 성공 후 리디렉션할 URL을 생성하고 리디렉션한다.

OAuth2 인증이 성공적으로 완료된 후 사용자를 적절한 페이지로 리디렉션하고, 필요한 경우 JWT 토큰을 생성하여 응답 헤더에 설정한다.

OAuth2 로그인 실패 핸들러 (OAuth2LoginFailureHandler.java)

@Slf4j
@Component
public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException authenticationException) throws IOException, ServletException {
        String targetUrl;
        if (authenticationException instanceof BadCredentialsException) {
            response.getWriter().write("이미 가입된 이메일입니다.");
            targetUrl = UriComponentsBuilder.fromUriString("http://localhost:3000/error/already-exist")
                    .queryParam("email", authenticationException.getMessage())
                    .build()
                    .toUriString();
        } else if (authenticationException instanceof LockedException) {
            response.getWriter().write("비활성화된 계정입니다. 관리자에 문의해주세요.");
            targetUrl = UriComponentsBuilder.fromUriString("http://localhost:3000/error/locked")
                    .build()
                    .encode(StandardCharsets.UTF_8)
                    .toUriString();
        } else {
            response.getWriter().write("소셜 로그인에 실패하였습니다.");
            targetUrl = UriComponentsBuilder.fromUriString("http://localhost:3000/error")
                    .build()
                    .encode(StandardCharsets.UTF_8)
                    .toUriString();
        }

        getRedirectStrategy().sendRedirect(request, response, targetUrl);

        log.info("소셜 로그인에 실패하였습니다. 에러 메시지 : {}", authenticationException.getMessage());
    }
}

OAuth2LoginFailureHandler 클래스는 OAuth2 인증이 실패했을 때 호출되는 핸들러이다. SimpleUrlAuthenticationFailureHandler를 상속받아 OAuth2 인증 실패 시 추가 작업을 처리한다.

  1. onAuthenticationFailure
    • 설명: OAuth2 인증이 실패했을 때 호출된다.
    • 로직:
      • 인증 실패 원인에 따라 적절한 에러 메시지와 리디렉션 URL을 설정한다.
      • BadCredentialsException: 이미 가입된 이메일로 인증이 실패한 경우, "이미 가입된 이메일입니다." 메시지를 반환하고, /error/already-exist 페이지로 리디렉션한다.
        • UriComponentsBuilder를 사용하여 리디렉션 URL을 생성하고, 이메일 정보를 쿼리 파라미터로 추가한다.
      • LockedException: 계정이 비활성화된 경우, "비활성화된 계정입니다. 관리자에 문의해주세요." 메시지를 반환하고, /error/locked 페이지로 리디렉션한다.
        • UriComponentsBuilder를 사용하여 리디렉션 URL을 생성한다.
      • 그 외의 예외: 소셜 로그인 실패 시, "소셜 로그인에 실패하였습니다." 메시지를 반환하고, /error 페이지로 리디렉션한다.
        • UriComponentsBuilder를 사용하여 리디렉션 URL을 생성한다.
      • getRedirectStrategy().sendRedirect(request, response, targetUrl) 메소드를 호출하여 설정된 URL로 리디렉션한다.
      • 로그를 통해 인증 실패 메시지를 기록한다.

OAuth2 인증이 실패했을 때 적절한 에러 메시지와 리디렉션 URL을 설정하여 사용자에게 알리고, 로그를 통해 실패 원인을 출력한다.

이렇게 JWT와 OAuth2.0에 관련한 주요 코드와 설명을 모두 마쳤다. 개발한 내용을 하나하나 다시 살펴보니 중간에 실수한 부분도 있고 고쳐야 할 부분도 있어, 나중에 Refactoring 관련 포스팅도 작성할 예정이다. 이렇게 조금씩 발전해나가자! 다음 포스팅에서는 일반 Login에 대한 내용으로 백엔드 프로세스를 모두 완료할 예정이다.

profile
다재다능한 Backend 개발자에 도전하는 개발자

0개의 댓글