[Spring Boot] JWT + 소셜 로그인 전체 과정 정리

susu·2023년 6월 3일
3

JWT + 소셜 로그인

목록 보기
3/3
post-thumbnail

작년에 호기롭게 시작했던 시리즈인데...
다른 프로젝트로 바빠 방치하다가 최근 비슷한 로직을 다시 구현할 일이 생겨 과정을 다시 정리해봤습니다.

따로 백엔드 서버를 두고 있는 프로젝트이므로 REST 기반으로 로그인을 진행해보겠습니다.
참고로 소셜 로그인 구현은 서버리스로도 가능하지만 과정이 다릅니다.

이 글에서는 HTTP REST 방식으로 Google 소셜 로그인을 진행하고,
로그인 결과를 바탕으로 서버 단에서 JWT를 발급하는 로직을 설명합니다.

절차

  1. 클라이언트 단에서 Google OAuth2 서버에 REST 요청을 보내서 Authorization Code 발급
  2. 해당 코드를 이용해 로그인 서버에 회원 정보 요청
  3. 받아온 정보를 DB에 저장 (* 본 프로젝트에서는 MySQL 사용)
  4. 해당 정보를 통해 JWT를 발급해 클라이언트 응답 헤더에 부착

으로 진행됩니다.

구현

Spring Boot 3.1.0, Java 17 기준으로 진행합니다.

먼저 Authorization Code(인가 코드)를 이용해 구글 로그인 서버에서 회원 정보를 받아와야 합니다.
회원 정보에 접근할 수 있는 인가 코드를 먼저 주고,
이 인가 코드를 통해 토큰을 받아 비로소 이 토큰으로 회원 정보를 가져올 수 있게 되는 방식입니다.

먼저 인가 코드를 받아오는 부분입니다.
클라이언트의 로그인 요청을 처리하는 로직에서,

  • 인가 코드를 받아오는 부분부터 모든 과정을 서버에 위임할 수도 있고
  • 인가 코드를 받아오는 건 클라이언트가, 이후 과정은 서버가

크게 이렇게 두 가지의 선택지가 있는데, 저는 후자를 택했습니다.

먼저 인가 코드를 받아오는 프론트엔드 코드입니다.
Next.js 13 기반으로 작성했습니다.

클라이언트 - Authorization Code 받기

"use client"
 
import { useEffect } from "react";

export default function GoogleLoginBtn() {

    const handleLogin = () => {
        const clientId = process.env.GOOGLE_CLIENT_ID;
        const redirectUri = window.location.href;
        const scope = 'https://www.googleapis.com/auth/userinfo.profile';

        const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?scope=https%3A//www.googleapis.com/auth/drive.metadata.readonly&access_type=offline&include_granted_scopes=true&response_type=code&state=state_parameter_passthrough_value&redirect_uri=${redirectUri}&client_id=${clientId}`

        window.location.href = authUrl;
    };

    useEffect(() => {
            const urlParams = new URLSearchParams(window.location.search);
            const code = urlParams.get('code');
            console.log(code);
        
    })

    return (
        <button onClick={handleLogin}>버튼 내용</button>
    )
}

이제 이 코드를 바탕으로 서버에 로그인 요청을 보낼 것입니다.

서버가 해야 할 일은

  • 인가 코드를 통해 사용자 정보를 받아오기
  • 해당 사용자가 DB에 없다면 저장하고
  • 로그인 결과로 Access Token을 발급하기

크게 이렇게 나눌 수 있겠습니다.

서버 - 사용자 정보 가져오기

MemberService

@Slf4j
@Service
@RequiredArgsConstructor
public class MemberService {

    @Value("${spring.google.client_id}")
    String clientId;

    @Value("${spring.google.client_secret}")
    String clientSecret;

    @Transactional
    @RequestMapping(value="/api/v1/oauth2/google", method = RequestMethod.GET)
    public String getGoogleInfo(@RequestParam(value = "code") String authCode){
        RestTemplate restTemplate = new RestTemplate();
        GoogleRequest googleOAuthRequestParam = GoogleRequest
                .builder()
                .clientId(clientId)
                .clientSecret(clientSecret)
                .code(authCode)
                .redirectUri("Google Console 사용자 정보에 등록한 URI")
                .grantType("authorization_code").build();
        ResponseEntity<GoogleResponse> response = restTemplate.postForEntity("https://oauth2.googleapis.com/token",
                googleOAuthRequestParam, GoogleResponse.class);
        String jwtToken=response.getBody().getId_token();
        Map<String, String> map=new HashMap<>();
        map.put("id_token",jwtToken);
        ResponseEntity<GoogleInfoResponse> infoResponse = restTemplate.postForEntity("https://oauth2.googleapis.com/tokeninfo",
                map, GoogleInfoResponse.class);
        String email=infoResponse.getBody().getEmail();

        log.info("이메일 "+ email);
        return email;
    }

}

Authorization Code를 받아오면 이 내용을 요청 DTO에 담아 요청하고,
응답으로 토큰을 받아서 이 토큰을 'id_token' 항목에 붙여 다시 요청하면 회원 정보를 받아올 수 있게 됩니다.
이때 Request를 보내고 Response를 받아오는 과정에서 DTO를 통해 매핑할 것입니다.

GoogleRequest

@Data
@Builder
public class GoogleRequest {
    private String clientId;
    private String redirectUri;
    private String clientSecret;
    private String responseType;
    private String scope;
    private String code;
    private String accessType;
    private String grantType;
    private String state;
    private String includeGrantedScopes;
    private String loginHint;
    private String prompt;
}

https://oauth2.googleapis.com/token로 보내는 첫 번째 요청 DTO입니다.
code에 해당하는 항목이 바로 Authorization Code입니다.

GoogleResponse

@Data
@NoArgsConstructor
public class GoogleResponse {
    private String access_token;
    private String expires_in;
    private String refresh_token;
    private String scope;
    private String token_type;
    private String id_token;
}

구글에서 응답하는 DTO입니다.
id_token을 통해 비로소 사용자 정보를 받아올 수 있습니다.

GoogleInfoResponse

@Data
@NoArgsConstructor
public class GoogleInfoResponse {
    private String iss;
    private String azp;
    private String aud;
    private String sub;
    private String email;
    private String email_verified;
    private String at_hash;
    private String name;
    private String picture;
    private String given_name;
    private String family_name;
    private String locale;
    private String iat;
    private String exp;
    private String alg;
    private String kid;
    private String typ;
}

회원 정보를 요청하기 위해 https://oauth2.googleapis.com/tokeninfo로 보내는 요청 DTO입니다.
드디어 사용자 정보를 받아올 수 있게 되었습니다.

이제 이메일을 받아왔으니, 본격적인 로그인 로직을 시작해보겠습니다.
그전에 먼저 JWT 발급 부분을 구현해야겠죠.

서버 - JWT 발급

JWT를 발급하는 과정은 파고들다 보면 끝도 없이 복잡해지는 것 같습니다.
그럼에도 최대한 간략하게 소개하고 싶어서 코드를 많이 줄였습니다.

SecurityConfiguration

먼저 설정 클래스를 빈으로 등록하는 과정입니다.
간단한 설명은 주석으로 적어뒀습니다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtProvider jwtProvider;
    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
    private final CustomAccessDeniedHandler customAccessDeniedHandler;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .httpBasic().disable()
                .formLogin().disable()
                .headers()
                .frameOptions()
                .sameOrigin()
                .and()
                .cors() // CORS 에러 방지용

                // 세션을 사용하지 않을거라 세션 설정을 Stateless 로 설정
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                // 접근 권한 설정부
                .and().authorizeHttpRequests()
                .requestMatchers(HttpMethod.OPTIONS).permitAll() // CORS Preflight 방지
                .requestMatchers("/", "/h2-console/**", "/member/login/**").permitAll()
                .anyRequest().authenticated()

                // JWT 토큰 예외처리부
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(customAuthenticationEntryPoint)
                .accessDeniedHandler(customAccessDeniedHandler)
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}

이전 프로젝트까지는 스프링 부트 2.7 이하 버전으로 개발했었기에 deprecated되었을지라도 WebSecurityConfigurerAdapter를 상속받아 구현하는 방식으로 진행해왔는데요.
이후 버전부터는 WebSecurityConfigurerAdapter 클래스가 아예 제거되었습니다.
따라서 WebSecurityConfigurerAdapter 대신 filterChain을 빈으로 등록했습니다.
🔗 공식 문서

Web ignoring 관련해서는 WebSecurity 대신 WebSecurityCustomizer 클래스를 사용합니다.

이제 Configuration에서 사용했던 필터들에 대해 알아보겠습니다.

CustomAccessDeniedHandler

인증, 인가 단계에서 발생한 에러를 처리하는 클래스입니다.
권한이 부여되지 않은 상태에서 접근 시 403 FOBIDDEN 응답을 하도록 했습니다.

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(
            HttpServletRequest request,
            HttpServletResponse response,
            AccessDeniedException accessDeniedException) throws IOException, ServletException {

        response.sendError(HttpServletResponse.SC_FORBIDDEN); //403
    }
}

CustomAuthenticationEntryPoint

정상적인 JWT가 오지 않은 경우에 대해 필터링하는 클래스입니다.
비정상적인 JWT를 가지고 접근 시 401 UNAUTHORIZED 응답을 하도록 했습니다.

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException authException) throws IOException {

        response.sendError(HttpServletResponse.SC_UNAUTHORIZED); //401
    }
}

JwtAuthenticationFilter

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";
    private final JwtProvider jwtProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws IOException, ServletException {

        String jwt = resolveToken(request);

        if (StringUtils.hasText(jwt) && jwtProvider.validateToken(jwt)) {
            Authentication authentication = jwtProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

doFilterInternal에서는 실제 필터링 로직을 수행합니다.
JWT의 인증 정보를 검사해 현재 쓰레드의 SecurityContext에 저장하도록 하는 것입니다.

먼저 resolveToken 메소드로 요청 헤더에서 토큰을 꺼내고,
JwtProvider의 validateToken 메소드로 토큰의 유효성을 검사합니다.
이때 유효한 토큰이면 토큰이 인증 정보를 뱉는데, 이걸 SecurityContext에 담아 필터링 로직을 진행합니다.

JwtProvider

JWT의 생성과 인증, 인가를 담당하는 책임을 가진 클래스입니다.

@Slf4j
@Component
public class JwtProvider {

    private final Key key;
    private static final String AUTHORITIES_KEY = "auth";
    private static final String BEARER_TYPE = "bearer";
    private static final String BEARER_PREFIX = "Bearer ";
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30; //access 30분
    private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7; //refresh 7일

    public JwtProvider(@Value("${spring.jwt.secret}") String secretKey) {
        byte[] keyBytes= Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    public TokenDto generateTokenDto(String email) {

        long now = (new Date()).getTime();

        // Access Token 생성
        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        String accessToken = Jwts.builder()
                .setSubject(email) //payload "sub" : "name"
                .claim(AUTHORITIES_KEY, Authority.ROLE_USER) //payload "auth" : "ROLE_USER"
                .setExpiration(accessTokenExpiresIn) //payload "exp" : 1234567890 (10자리)
                .signWith(key, SignatureAlgorithm.HS512) //header "alg" : 해싱 알고리즘 HS512
                .compact();

        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

        return TokenDto.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .accessTokenExpiresIn(accessTokenExpiresIn.getTime())
                .refreshToken(refreshToken)
                .build();
    }

    public Authentication getAuthentication(String accessToken) {

        Claims claims = parseClaims(accessToken);

        if (claims.get(AUTHORITIES_KEY) == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        UserDetails principal = new User(claims.getSubject(), "", authorities);

        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    public boolean validateToken(String token) {

        try{
            Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            /* 커스텀 에러처리 */
        } catch (ExpiredJwtException e) {
            /* 커스텀 에러처리 */
        } catch (UnsupportedJwtException e) {
            /* 커스텀 에러처리 */
        } catch (IllegalArgumentException e) {
            /* 커스텀 에러처리 */
        }
        return false;
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(accessToken)
                    .getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}
  • generateTokenDto: email을 Claim으로 하여 JWT를 생성합니다.
    Refresh Token은 로그인 유지를 위한 것이므로 Claim 없이 만료 시간만 담아줍니다.
    참고로 TokenDTO의 구조는 다음과 같습니다.
@Builder
@Data
@AllArgsConstructor
public class TokenDto {

    private String grantType;   // Bearer
    private String accessToken;
    private String refreshToken;
    private Long accessTokenExpiresIn;
}
  • getAuthentication: Access Token을 검사해 Authentication을 리턴합니다.

  • validateToken: 토큰의 유효성을 검증합니다.

  • parseClaim: 토큰 생성 시 집어넣었던 Claim을 추출합니다.

서버 - 로그인 진행

이제 토큰 발급 로직을 구현했으니 로그인을 진행하는 서비스와 컨트롤러를 구현하겠습니다.
로그인과 관련된 토큰 발행 처리는 SecurityService로 분리했습니다.

SecurityService

@Slf4j
@Service
@RequiredArgsConstructor
public class SecurityService {

    private final MemberRepository memberRepository;
    private final RefreshTokenRepository tokenRepository;
    private final JwtProvider jwtProvider;

    @Transactional
    public TokenDto login(String email) {
        Member member = memberRepository.findByEmail(email);

        if (member == null) {
            member = Member.builder()
                    .email(email)
                    .authority(Authority.ROLE_USER)
                    .build();
            memberRepository.saveAndFlush(member);
        }
        log.info("[login] 계정을 찾았습니다. " + member);

        TokenDto tokenDto = jwtProvider.generateTokenDto(email);

        RefreshToken refreshToken = RefreshToken.builder()
                .key(member.getMemberId())
                .token(tokenDto.getRefreshToken())
                .build();
        tokenRepository.save(refreshToken);
        return tokenDto;
    }

    public HttpHeaders setTokenHeaders(TokenDto tokenDto) {
        HttpHeaders headers = new HttpHeaders();
        ResponseCookie cookie = ResponseCookie.from("RefreshToken", tokenDto.getRefreshToken())
                .path("/")
                .maxAge(60*60*24*7) // 쿠키 유효기간 7일로 설정
                .secure(true)
                .sameSite("None")
                .httpOnly(true)
                .build();
        headers.add("Set-cookie", cookie.toString());
        headers.add("Authorization", tokenDto.getAccessToken());

        return headers;
    }
}
  • login: 로그인을 시도하는 사용자에게 토큰을 발급하고, Refresh Token만 DB에 저장하는 메소드입니다.
    가입이 안 된 사용자로 확인되는 경우 (사용자 정보가 DB에 없는 경우) 회원 정보가 복잡하지 않으니 바로 새 회원으로 등록시켰지만,
    이 방법보다는 다른 클래스로 넘겨서 따로 예외처리를 하는 것이 이상적이라고 생각합니다.

  • setTokenHeaders: 발행한 토큰을 헤더에 배치하는 메소드입니다.
    Access Token은 Authorization 헤더에 담고, Refresh Token은 HttpOnly 쿠키에 담아 응답하게 했습니다.

MemberController

로그인 요청을 처리하는 컨트롤러입니다.
클라이언트에서 넘어온 인가 코드를 파라메터로 받아 로그인 로직을 처리합니다.

저는 ResponseEntity의 빌더 패턴을 이용해 응답을 처리했지만,
큰 프로젝트라면 커스텀 Api Response 객체를 활용하기도 합니다.

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/member")
public class MemberController {

    private final MemberService memberService;
    private final SecurityService securityService;


    @GetMapping("/login")
    public ResponseEntity login(@RequestParam String code) {
        String email = memberService.getGoogleInfo(code);
        TokenDto tokenDto = securityService.login(email);
        HttpHeaders headers = securityService.setTokenHeaders(tokenDto);

        return ResponseEntity.ok().headers(headers).body("accessToken: " + tokenDto.getAccessToken());
    }
}

끝내며

서비스가 다른 서비스를 참조하지 않게 분리하고 빌더 패턴을 활용하는 등 좋은 코드를 작성해보려고 노력하고 있지만 여전히 부족한 점이 많은 것 같습니다.
코드나 로직에 오류가 있는 경우 언제든지 알려주시면 바로 확인하고 수정하겠습니다.

백엔드 개발자라면 한 번씩은 꼭 마주하게 되는 과정인데,
이 과정을 한 글에 정리할 수 있게 되기까지는 일 년이 걸렸네요.
제가 보려고 쓴 글이지만 다른 분들에게도 도움이 될 수 있었으면 좋겠습니다.

profile
블로그 이사했습니다 ✈ https://jennairlines.tistory.com

0개의 댓글