본문 바로가기
개발이야기

JWT 토큰인증 방식을 사용한 소셜로그인 구현하기

by janiiiiie 2021. 11. 5.
반응형

 

Overview

이제는 완전히 완료 된 작업이지만, 처음해보는 JWT 토큰방식 로그인이라 나중에라도 잊어버리지 않기 위해 정리해보는 글입니다. 

카카오/구글 소셜로그인을 완료한 상태이며, 추후 iOS 버전에 대응하기 위해 애플로그인을 추가할 예정입니다.

 

JWT 토큰 인증 방식으로 프론트 쪽에서 카카오 인증 및 access token을 발급받고, 백엔드에 요청을 보낼 때 카카오에서 받은 사용자 정보를 JWT 토큰으로 암호화하여 토큰을 활용해 현재 유저를 식별하도록 하였습니다. 

 

일반 회원가입/로그인을 놔두고 왜 소셜로그인으로만 했는지 처음 보시는 분들은 궁금해 하실 수도 있을 것 같습니다. 만약 실제로 마켓에 배포를 하게 된다면 개인정보를 함부로 다루어서는 안됩니다.(진짜 철컹철컹 할 수도 있음...) 일반 회원가입을 위해서는 법적인 동의 절차 및 약관도 법에 따라서 제대로 작성을 해야하는데요, 혹시나 놓친 부분으로 인한 법적문제를 발생시키지 않기 위해서 소셜로그인 이라는 방식을 도입하게 되었습니다.

 

 

전체적인 소셜로그인 Flow


먼저, 카카오의 경우 access token발급을 위한 인가코드 발급요청이 한번 더 있는 것을 제외하고는, 카카오/구글 모두 access token으로 사용자 정보를 조회할 수 있습니다. 이 access token은 일회성 이기 때문에 한번 사용했다면 다시 재발급을 받아야 합니다.

 

액세스 토큰을 헤더에 담아 백엔드로 로그인 요청을 보냅니다. 이 때, 백엔드는 카카오/구글에 액세스 토큰을 가지고 해당 사용자의 정보를 받아옵니다.

 

해당 사용자가 신규 회원이라면 DB에 저장을 하고 내려받은 사용자 정보의 아이디와 권한 등을 JWT 토큰 생성 기능을 활용하여 암호화 후, 해당 토큰을 반환합니다.

 

기존 사용자 라면 JWT 토큰이 만료되어 재요청이 온 것이기 때문에 JWT 토큰을 새로 발급합니다.

 

 

Spring security config 설정

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final AuthTokenProvider authTokenProvider;
    
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/v2/api-docs", "/configuration/**", "/swagger*/**", "/webjars/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        JwtAuthenticationFilter jwtAuthFilter = new JwtAuthenticationFilter(authTokenProvider);

        http
                .authorizeRequests()
                .antMatchers(HttpMethod.OPTIONS).permitAll()
                .antMatchers("/auth/**").permitAll()
                .anyRequest().authenticated().and() // 해당 요청을 인증된 사용자만 사용 가능
                .headers()
                .frameOptions()
                .sameOrigin().and()
                .cors().and()
                .csrf().disable()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

    }
}
  • 우선, Swagger 로 요청이 들어올 땐 헤더에 access token이 없으므로, JWT filter를 타지 않도록 ignore 처리를 해주도록 합니다.만약 이 때 풀어두지 않는다면 authentication기능이 활성화 되어 swagger ui가 켜지지 않기 때문에 주의가 필요합니다. (안켜지면 프론트엔드 분들이 원활하게 백엔드 API와의 연동이 불가능하겠죠? 😂)
  • JWT 토큰은 기본적으로 session을 사용하지 않기 때문에 STATELESS(무상태)를 유지해야 합니다. 
  • 로그인을 시도할 때 로그인요청 url 이전에 OPTIONS 라는 요청을 동시에 보내게 됩니다. OPTION 요청에 대해 열어두지 않는다면 CORS Preflight 가 발생할 수 있습니다. 

 

 

로그인 요청 

/**
* KAKAO 소셜 로그인 기능
* @return ResponseEntity<AuthResponse>
*/
@ApiOperation(value = "카카오 로그인", notes = "카카오 엑세스 토큰을 이용하여 사용자 정보 받아 저장하고 앱의 토큰 신환")
@PostMapping(value = "/kakao")
public ResponseEntity<AuthResponse> kakaoAuthRequest(@RequestBody AuthRequest authRequest) {
	return ApiResponse.success(kakaoAuthService.login(authRequest));
}

카카오 기준 로그인 요청 controller 입니다. 이 요청을 타기 전에 아래와 같은 Filter를 타게 됩니다.

 

 

로그인 요청시, Controller 전에 동작하게 되는 JwtAuthenticationFilter

@Log4j2
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final AuthTokenProvider tokenProvider;

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

        final String authorizationHeader = request.getHeader("Authorization");

        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            String tokenStr = JwtHeaderUtil.getAccessToken(request);
            AuthToken token = tokenProvider.convertAuthToken(tokenStr);

            if (token.validate()) {
                Authentication authentication = tokenProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }

            filterChain.doFilter(request, response);
        }
    }
}

Swagger ui 로 오는 요청처럼, 모든 요청 헤더에 access token이 있는 것은 아니기 때문에, 헤더 내에 Authorization Bearer ~ 로 시작하는 값이 있는지를 확인하여 없을 때는 Filter를 타지 않도록 설정합니다.

 

 

Filter 동작 후, AuthService

@Transactional
public AuthResponse login(AuthRequest authRequest) {
  Members kakaoMember = clientKakao.getUserData(authRequest.getAccessToken());
  String socialId = kakaoMember.getSocialId();
  Members member = memberQuerydslRepository.findBySocialId(socialId);

  AuthToken appToken = authTokenProvider.createUserAppToken(socialId);

  if (member == null) {
  	memberRepository.save(kakaoMember);
  }

  return AuthResponse.builder()
  			.appToken(appToken.getToken())
  			.build();
}

먼저, access token을 가지고 ClientKakao(ClientGoogle) 을 호출하여 카카오/구글의 사용자 정보를 조회하러 갑니다.

사용자 정보 조회 후, 내려받은 사용자식별 ID 값으로 현재 저희 서비스의 DB에서 이미 가입된 사람인지를 판별 후, 새로운 유저라면 저장을 하고 JWT 토큰을 발급하고 기존 사용자라면 토큰 만료로 인한 재요청이기 때문에 DB와의 커넥션 없이 바로 새로운 토큰만 발급하여 반환합니다. 

 

 

사용자 정보 조회를 위해 외부 API 호출하는 ClientKakao/ClientGoogle

@Log4j2
@Component
@RequiredArgsConstructor
public class ClientKakao implements ClientProxy {

    private final WebClient webClient;

    // TODO ADMIN 유저 생성 시 getAdminUserData 메소드 생성 필요
    @Override
    public Members getUserData(String accessToken) {
        KakaoUserResponse kakaoUserResponse = webClient.get()
                .uri("https://kapi.kakao.com/v2/user/me")
                .headers(h -> h.setBearerAuth(accessToken))
                .retrieve()
                .onStatus(HttpStatus::is4xxClientError, response -> Mono.error(new TokenValidFailedException("Social Access Token is unauthorized")))
                .onStatus(HttpStatus::is5xxServerError, response -> Mono.error(new TokenValidFailedException("Internal Server Error")))
                .bodyToMono(KakaoUserResponse.class)
                .block();

        return Members.builder()
                .socialId(String.valueOf(kakaoUserResponse.getId()))
                .name(kakaoUserResponse.getProperties().getNickname())
                .email(kakaoUserResponse.getKakaoAccount().getEmail())
                .gender(kakaoUserResponse.getKakaoAccount().getGender())
                .memberProvider(MemberProvider.KAKAO)
                .roleType(RoleType.USER)
                .profileImagePath(kakaoUserResponse.getProperties().getProfileImage())
                .build();
    }
}

사용자 정보 조회를 위한 외부 API 사용을 위해, 현재는 WebClient를 사용하였습니다. (특정 API에 대해서만 호출하기 때문에 사용하였습니다. webclient는 spring webflux 의존성을 추가하시면 됩니다!)

 

요즘은 @Feign 이라는 넷플릭스가 개발한 Http client binder 가 있다는 내용이 우아한 형제들 기술블로그에 기고되어 있네요. 현재 프로젝트는 단순히 interface - service로 가고 있는데 Feign을 사용하면 구조가 훨씬 깔끔해지는 것 같아 다음 버전업에 참고해보면 좋을 것 같다는 생각이 들었습니다 :)

 

https://techblog.woowahan.com/2630/

 

우아한 feign 적용기 | 우아한형제들 기술블로그

{{item.name}} 안녕하세요. 저는 비즈인프라개발팀에서 개발하고 있는 고정섭입니다. 이 글에서는 배달의민족 광고시스템 백엔드에서 feign 을 적용하면서 겪었던 것들에 대해서 공유 하고자 합니다

techblog.woowahan.com

 

주의! 카카오와 구글에서 내려주는 사용자 정보의 json 포맷이 매우 다릅니다. 때문에, 정보를 받아오는 DTO는 각각의 모듈에 맞게 설정을 해두셔야 합니다.

 

 

사용자 정보를 담은 JWT 토큰 발급하러 가는 AuthTokenProvider

@Slf4j
@Component
public class AuthTokenProvider {

    @Value("${app.auth.tokenExpiry}")
    private String expiry;

    private final Key key;
    private static final String AUTHORITIES_KEY = "role";

    public AuthTokenProvider(@Value("${app.auth.tokenSecret}") String secretKey) {
        this.key = Keys.hmacShaKeyFor(secretKey.getBytes());
    }

    public AuthToken createToken(String id, RoleType roleType, String expiry) {
        Date expiryDate = getExpiryDate(expiry);
        return new AuthToken(id, roleType, expiryDate, key);
    }

    public AuthToken createUserAppToken(String id) {
        return createToken(id, RoleType.USER, expiry);
    }

    public AuthToken convertAuthToken(String token) {
        return new AuthToken(token, key);
    }

    public static Date getExpiryDate(String expiry) {
        return new Date(System.currentTimeMillis() + Long.parseLong(expiry));
    }

    public Authentication getAuthentication(AuthToken authToken) {

        if(authToken.validate()) {

            Claims claims = authToken.getTokenClaims();
            Collection<? extends GrantedAuthority> authorities =
                    Arrays.stream(new String[]{claims.get(AUTHORITIES_KEY).toString()})
                            .map(SimpleGrantedAuthority::new)
                            .collect(Collectors.toList());

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

            return new UsernamePasswordAuthenticationToken(principal, authToken, authorities);
        } else {
            throw new TokenValidFailedException();
        }
    }
}

현재 로그인 로직에서는 중간에 createUserAppToken()을 사용합니다. 소셜로그인으로 받아온 id와 해당 유저의 Role(아직은 mvp 모델이기 때문에 USER 권한만 존재), 토큰의 유효시간, 그리고 spring security key 와 함께 JWT 토큰을 생성합니다.

 

 

JWT 토큰을 생성하는 AuthToken - createAuthToken

@Slf4j
@RequiredArgsConstructor
public class AuthToken {

    @Getter
    private final String token;
    private final Key key;

    private static final String AUTHORITIES_KEY = "role";

    AuthToken(String socialId, RoleType roleType, Date expiry, Key key) {
        String role = roleType.toString();
        this.key = key;
        this.token = createAuthToken(socialId, role, expiry);
    }

    private String createAuthToken(String socialId, String role, Date expiry) {
        return Jwts.builder()
                .setSubject(socialId)
                .claim(AUTHORITIES_KEY, role)
                .signWith(key, SignatureAlgorithm.HS256)
                .setExpiration(expiry)
                .compact();
    }

    public boolean validate() {
        return this.getTokenClaims() != null;
    }

    public Claims getTokenClaims() {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token)
                    .getBody();
        } catch (SecurityException e) {
            log.info("Invalid JWT signature.");
        } catch (MalformedJwtException e) {
            log.info("Invalid JWT token.");
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT token.");
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT token.");
        } catch (IllegalArgumentException e) {
            log.info("JWT token compact of handler are invalid.");
        }
        return null;
    }
}

createAuthToken을 호출하여 이곳에서 비로소 토큰이 생성되고, 다시 service 레이어로 반환되어 최종적으로 프론트 쪽에 전달이 됩니다. 이제, 해당 토큰을 가지고서 사용자를 식별하고 로그인/회원가입 처리를 할 수 있게 되었습니다!! 

 

 

기존에 늘 하던 방식인 세션 로그인 방식에서 탈출하여, 세션으로 인한 서버 과부화를 방지할 수 있게 되었습니다.(대신, DB를 조회해야하니 connection에 문제가 생길수도 있긴 합니다.) 

 

쿠키를 사용할 때의 문제가 되었던 것이 현재 발행한 서버에서만 유효하다는 단점(즉, 현재 켜져있는 브라우저에서만)이 있었지만, 토큰은 html body 형식으로 내려주기 때문에 세션처럼 다른 도메인(다른 브라우저 창)에서도 사용할 수 있다는 장점이 있습니다. 

 

동료분과 몇 주동안 밤 늦게까지 작업한 결과물이 잘 작동하게 되어서 다행이네요..^^ 소스코드는 아래 제 프로젝트 링크를 타고 들어가시면 있으니 참고 부탁드립니다:) 

 

 

 

https://github.com/depromeet/bread-map-backend

 

GitHub - depromeet/bread-map-backend: 디프만 10기 7조 백엔드 - 대동빵지도

디프만 10기 7조 백엔드 - 대동빵지도. Contribute to depromeet/bread-map-backend development by creating an account on GitHub.

github.com

 

 

https://github.com/Jane096

 

Jane096 - Overview

Make it count! Jane096 has 5 repositories available. Follow their code on GitHub.

github.com

 

반응형