[Spring/Security] JWT + OAuth2 + SpringSecurity 로 소셜 로그인 구현하기 (1)

민지·2023년 12월 12일

Spring/Security

목록 보기
2/2
post-thumbnail

우리 서비스는 카카오 로그인을 구현할 예정이에요.

참고로!
Spring Boot OAuth 2 client를 사용하여 구현하지 않았습니다.
클래스 직접 구현해서 사용했고(KakaoOAuth2), 추후에 리팩토링 예정임니당. .

Spring Security를 적용하면?

클라이언트가 보호 리소스에 액세스할 때 access token 을 사용하여 인증 수준을 검사할 수 있다.

만약 Spring Security를 적용하지 않는다면,
access token 을 통한 검사가 이루어지지 않겠죠 ?
그럼 토큰이 없는 아이들도 보호되는 리소스에 접근할 수 있다는 말.

이건 시큐리티가 적용되지 않았을때 로직

시큐리티가 적용되어있을 때 로직.

  • 리소스 서버로부터 Authorization Code만 발급받으면, 그 이후는 Spring Security에서 처리를 수행한다.

  • 프로젝트에서 사용하는 Access Token 을 발급받아서 리소스 접근자에 대해서도 토큰 검사를 실시하고 여기에 접근 시간과 제한을 둔다!

우리가 진행할 로그인 동작 과정

용어 정리 먼저 하고 가자.

OAuth2 흐름에서 보면,

  • 클라이언트 = 우리 서버
  • 리소스 오너 = 사용자
  • 인증 서버(리소스 서버) = 클라이언트의 접근 자격을 확인하고 Access Token 발급해주는 구글, 카카오, 네이버 같은 아이들.
  1. 사용자(리소스 오너)가 프론트 서버에 로그인을 요청한다.
  2. 프론트 서버에서 카카오로 데이터를 담아 요청한다.
  3. 카카오(인증 서버)가 검증한 후, 인증이 되면 Authorization code 를 발급해 응답한다.
  4. 프론트 -> 백 : 받은 Authorization code 를 POST 요청한다.
  5. 백 -> 카카오 : Authorization code토큰을 요청한다.
  6. 백 <- 카카오 : Access Token & Refresh Token 발급해서 응답한다.
  7. 백 -> 카카오 : Access Token 으로 필요한 리소스(이메일, 프로필 사진 등 유저 정보겠찌?)를 요청한다.
  8. 카카오 -> 백 : 요청받은 자원을 응답한다.
  9. 프론트 <- 백: 유저 정보 & JWT 토큰(Access Token & Refresh Token)을 응답한다.

즉, 백엔드에서는 4번부터 처리하면 되는것이다!

실습 과정

1. 필수 환경 구성 & 라이브러리 의존성 주입

인증 방식: 권한 코드 승인 방식

의존성은 다음과 같이 추가했다.
SpringBoot OAuth2 client 를 사용하지 않았기 때문에 json 라이브러리를 추가했음.
그 이외는 전 게시물 참고.

# buile.gradle

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation group: 'org.json', name: 'json', version: '20160810'
	implementation 'io.jsonwebtoken:jjwt:0.9.1'
}

본격적으로 구현을 위해서 필요한 클래스들을 만들어주겠습니다.

이정도가 있다.

2. 사전 준비 사항: Kakao Developers 환경 구성

카카오 로그인 시 api 키 받고, 세팅을 해야한다.

카카오디벨로퍼

(1) 카카오 Developer > 내 애플리케이션 > 애플리케이션 등록

(2) 내 애플리케이션 > 플랫폼 > Web > 사이트 도메인 설정

http://localhost:9090
https://develog.co.kr

(3) Redirect URI 등록하러 가기 >

활성화 설정 ON

https://develog.co.kr/login/oauth2/code/kakao 
http://localhost:9090/login/oauth2/code/kakao
https://getpostman.com/oauth2/callback

앞에 redirect_uri 만 잘 설정하면, 뒤에 /login/oauth2/code/kakao 는 정해진 uri이므로 맞춰주면 된다.

(4) 카카오 로그인 > 동의항목

가서 원하는 거 설정해주면 된다 !

3. 백에서하는 처리

(1) 인증이 정상적으로 처리되면 접근할 수 있는 컨트롤러 생성

URI: users/login/kakao
http-method: POST
Request: 카카오에서 받은 Authorization code
Response: 유저정보 + JWT(Access Token+ Refresh Token)

@Slf4j
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    private final JwtTokenProvider jwtTokenProvider;
    private final UserService userService;

    // 카카오 로그인 및 회원가입
    @PostMapping("/login/kakao")
    public ResultTemplate login(@RequestBody RequestLoginDto requestLoginDto) {

        LoginDto user = userService.findKakaoUserByAuthorizedCode(requestLoginDto.getCode(), RedirectUrlProperties.KAKAO_REDIRECT_URL);
        String accessToken = jwtTokenProvider.createAccessToken(user.getUserId(), String.valueOf(user.getUserId()), user.getSocialType());
        String refreshToken = jwtTokenProvider.createRefreshToken(user.getUserId());

        // 리프레시토큰 레디스에 저장한다
        jwtTokenProvider.storeRefreshToken(user.getUserId(), refreshToken);

        ResponseLoginDto responseLoginDto = ResponseLoginDto.builder()
                .userId(user.getUserId())
                .name(user.getNickname())
                .AccessToken(accessToken)
                .RefreshToken(refreshToken)
                .build();

        return ResultTemplate.builder()
                .status(HttpStatus.OK.value())
                .data(responseLoginDto)
                .build();
    }
}

(2) security 설정

SecurityConfig 파일에 작성했다.
REST API 이므로,

  • http 로그인 페이지 폼 없고
  • csrf 보안 필요 없음
  • 토큰 기반 인증으로 세션 생성도 불필요
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtExceptionFilter jwtExceptionFilter;

    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() {
        return new JwtAuthenticationFilter(jwtTokenProvider);
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .formLogin().disable()
                .authorizeRequests()
                .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
                .antMatchers(
                        "/api/users/login/**",
                        "/api/users/reissue").permitAll()
                .antMatchers(
                        "/swagger-ui/**",
                        "/v3/api-docs/**",
                        "/swagger-ui.html").permitAll()
                .anyRequest().authenticated()
//                .antMatchers("/*").permitAll()
                .and()
                .cors()
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint);

        http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
        http.addFilterBefore(jwtExceptionFilter, jwtAuthenticationFilter().getClass());

        return http.build();
    }

}

(3) JwtTokenProvider 작성

토큰 관련 처리를 해줄 JwtTokenProvider 다.

  • Access Token 생성 메서드
  • Refresh Token 생성 메서드
  • Access Token 유효한 지 체크 메서드: getAuthentication() 시큐리티가 해준다
  • 요청 헤더에서 Access Token 값 리턴하는 메서드
  • 요청 헤더에서 Refresh Token 값 리턴하는 메서드
  • Refresh Token 저장하는 메서드

등등 정도가 있다.

@Slf4j
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {

    private final UserDetailsService userDetailsService;

    @Value("${jwt.secretKey}")
    private String secretKey;

    @Value("${jwt.access.expiration}")
    private Long accessTokenValidTime;

    @Value("${jwt.refresh.expiration}")
    private Long refreshTokenValidTime;

    private final UserRepository userRepository;
    private final RedisTemplate<String, String> redisTemplate;

    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    public String createAccessToken(Long userId, String userPk, SocialType socialType) {

        // JWT payload 에 저장되는 정보단위, 보통 여기서 user를 식별하는 값을 넣음
        Claims claims = Jwts.claims().setSubject(userPk);
        claims.put("id", userId);
        claims.put("socialType",socialType);
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims) // 정보 저장
                .setIssuedAt(now) // 토큰 발행 시간 정보
                .setExpiration(new Date(now.getTime() + accessTokenValidTime)) // set Expire Time
                .signWith(SignatureAlgorithm.HS256, secretKey)  // 사용할 암호화 알고리즘과 signature 에 들어갈 secret값 세팅
                .compact();
    }

    public String createRefreshToken(Long userId) {

        Date now = new Date();
        return Jwts.builder()
                .setId(Long.toString(userId)) // 정보 저장
                .setIssuedAt(now) // 토큰 발행 시간 정보
                .setExpiration(new Date(now.getTime() + refreshTokenValidTime)) // set Expire Time
                .signWith(SignatureAlgorithm.HS256, secretKey)  // 사용할 암호화 알고리즘과 signature 에 들어갈 secret값 세팅
                .compact();
    }

    public Authentication getAuthentication(String token) {
        log.info("여기 토큰 userId로 던짐!!:::: " + this.getUserPk(token));
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
        log.info("JwtTokenProvider 클래스 들어옴. getAuthentication 메서드 실행 중");
        return new UsernamePasswordAuthenticationToken(userDetails, token, userDetails.getAuthorities());
    }

    public String getUserPk(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }

    public String getUserId(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getId();
    }

    // Request의 Header에서 accesstoken 값을 가져옴. "Authorization" : "ACCESSTOKEN값'
    public Optional<String> extractAccessToken(HttpServletRequest request) {
        return Optional.ofNullable(request.getHeader("Authorization"));

    }

    // Request의 Header에서 refreshtoken 값을 가져옴. "Authorization-Refresh" : "REFRESHTOKEN값'
    public Optional<String> extractRefreshToken(HttpServletRequest request) {
        return Optional.ofNullable(request.getHeader("Authorization-Refresh"));

    }

    public void storeRefreshToken(long userId, String refreshToken) {
        User user = userRepository.findById(userId).orElse(null);
        if (user != null) {
            redisTemplate.opsForValue().set(
                    Long.toString(userId),
                    refreshToken,
                    refreshTokenValidTime,
                    TimeUnit.MILLISECONDS
            );
        }
    }

    public boolean validateToken(String jwtToken) {
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (SignatureException e) {
            log.warn("JWT 서명이 유효하지 않습니다.");
            throw new SignatureException("잘못된 JWT 시그니쳐");
        } catch (MalformedJwtException e) {
            log.warn("유효하지 않은 JWT 토큰입니다.");
            throw new MalformedJwtException("유효하지 않은 JWT 토큰");
        } catch (ExpiredJwtException e) {
            log.warn("만료된 JWT 토큰입니다.");
            throw new ExpiredJwtException(null, null, "토큰 기간 만료");
        } catch (UnsupportedJwtException e) {
            log.warn("지원되지 않는 JWT 토큰입니다.");
            throw new UnsupportedJwtException("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.warn("JWT claims string is empty.");
        } catch (NullPointerException e) {
            log.warn("JWT RefreshToken is empty");
        } catch (Exception e) {
            log.warn("잘못된 토큰입니다.");
        }
        return false;

    }
}

(3) JwtAuthenticationFilter 작성

이 아이의 역할은 뭐냐?
어떤 요청이 오면 필터인 이 아이가 잡아서 다음 코드를 수행한다.

해당 필터에서는

  • 헤더에서 온 JWT를 받는다
  • 토큰이 유효한 지 검사한다 : validateToken()
    ㄴ 유효하다면 토큰을 가지고 리소스 서버에 유저 정보를 받으러 간다
    ㄴ 유효하지 않다면 경고메시지 반환
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

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

        log.info("시큐리티 인증 필터 진입함 ::: doFilterInternal 메서드");

        // 헤더에서 JWT 받는다
        String accessToken = jwtTokenProvider.extractAccessToken(request).orElse(null);

        log.info("추출한 Access Token:::: " + accessToken);

        // 유효 토큰 검사
        if(accessToken != null && jwtTokenProvider.validateToken(accessToken)) {
            // 유효한 토큰일 때 토큰을 통해 유저 정보를 받아온다
            Authentication authentication = jwtTokenProvider.getAuthentication(accessToken);
            // SecurityContext 요기다가 Authentication 객체를 저장한다.
            SecurityContextHolder.getContext().setAuthentication(authentication);
            log.info("유효 토큰 검사 완료. 해당 유저 인가 처리 완료");
        }

        // null 일때 처리: 더 들어오지 않고 내보낸다 - 일단 보류요 ..

        filterChain.doFilter(request, response);

    }
}

(4) JwtExceptionFilter 작성

얘는 jwt 관련한 예외처리를 하는 필터이다.

@Component
public class JwtExceptionFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            filterChain.doFilter(request, response); // JwtAuthenticationFilter로 이동
        } catch (JwtException ex) {
            // JwtAuthenticationFilter에서 예외 발생하면 바로 setErrorResponse 호출
            setErrorResponse(request, response, ex);
        }
    }

    private void setErrorResponse(HttpServletRequest req, HttpServletResponse res, JwtException ex) throws IOException {

        res.setContentType(MediaType.APPLICATION_JSON_VALUE);

        final Map<String, Object> body = new HashMap<>();
        body.put("status", HttpServletResponse.SC_UNAUTHORIZED);
        body.put("error", "Unauthorized");
        // ex.getMessage() 에는 jwtException을 발생시키면서 입력한 메세지가 들어있다.
        body.put("message", ex.getMessage());
        body.put("path", req.getServletPath());
        final ObjectMapper mapper = new ObjectMapper();
        res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        mapper.writeValue(res.getOutputStream(), body);
    }
}

(4)


번외: 토큰은 ResponseEntity의 header에 보내야할까 body에 보내야할까?

우리 프로젝트는 ssl/tls 인증서를 통해 https 통신을 하여 데이터가 안전하게 통신된다. 따라서 header나 body 둘다 상관없이 보안이 유지된다.

하지만 일반적인 http 통신에서는 header가 맞는 듯하다.
gpt 답변은 아래와 같다.
결론만 말하자면 민감한 정보는 Header에 넣는게 더 옳다.

우리 서비스에서는 API 명세에 적힌대로,
응답 본문에 json형태로 보내도록 했습니당


다시 돌아와서!

시큐리티 인증 필터인 JwtAuthenticationFilter 에 진입하는 시점:

프론트에서 카카오 서버에 가서 로그인을 성공하면 Authorization Code 를 받아와
걔가 인증이 됨과 동시에 리다이렉트를 해서 다음과 같은 url 로 이동됨.

http://localhost:9090/login/oauth2/code/kakao?code=31CGcD_pRyUg9_2N42HM8vhuPY3cGVNXUn9_dUUPbWXTCF2f2oDq_z82xk8KPXWbAAABi2qL5GndCc_9be4aqQ

여기는 프론트랑 얘기를 해서 리다이렉트를 어디로 해줄지 정하면 되고,
(아마 메인페이지가 나오겠지)

code를 백으로 보내면, 이때! 시큐리티 인증 필터에 진입해.

2023-10-26 14:58:44.311  INFO 23537 --- [nio-9090-exec-5] c.s.d.s.jwt.JwtAuthenticationFilter      : 시큐리티 인증 필터 진입함 ::: doFilterInternal 메서드

username is null 에러


여기서 계속 에러가 난다.

해결 어캐 했냐면,
내가 AccessToken 을 생성할 때 JWT를 만드는 과정에서 Claim이라는 객체를 만들고 하는데 여기서 Subject라고 하는 토큰을 구분하는 아이에 실수로 인해 null값을 계속 넣고 있었다.

바보 ...

여기에 구분할 수 있는데 현재는 user의 pk인 userId 뿐이라 이걸 넣어줘서 구분할 수 있게 했다.

근데 여기서 코드를 뜯어보다 보니까 이 UserId로 어떻게 시큐리티가 사용자가 유효한 토큰을 가지고 있는지 확인하지??? 의문이었는데

또 한가지 안한게 있었다 !

내가 시큐리티에서 User를 찾는 인터페이스인 UserDetails, UserDetailsService 를 구현하지 않았었다.

이거 구현하니까 됐다.
자세한 건 나중에!

@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        User user = userRepository.findById(Long.valueOf(username))
                .orElseThrow(() -> new UsernameNotFoundException("해당하는 사용자가 없습니다"));

        return new UserDetailsImpl(user);
    }
}
@RequiredArgsConstructor
public class UserDetailsImpl implements UserDetails {

    private final User user;
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collectors = new ArrayList<>();

        collectors.add(()->{return "ROLE_" + user.getRole();});

        return collectors;
    }

    @Override
    public String getPassword() {
        return null;
    }

    @Override
    public String getUsername() {
        return String.valueOf(user.getUserId());
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

이 에러를 해결하며 시큐리티와 한발짝 더 친해지게 된 것 같다.
잘 복붙했다면 이런것도 몰랐겠지
고맙다 나의 실수야~


profile
한 발 짝

0개의 댓글