Spring Boot + OAuth2에 JWT 적용하기

Programmingzi·2023년 12월 31일

서버에서 OAuth2를 통해 로그인을 하고 JWT로 사용자를 관리할 생각이다.

JWT를 도입한 이유?

기존에는 사용자의 정보를 가져오기 위해서 path parameter를 이용했다. 예를 들어 id가 1번 사용자가 글을 작성한다고 하면, http://localhost:8080/board/1 이렇게 path parameter로 사용자 id로 넘겨서 로직을 처리했다.

하지만, 이런 방식은 사용자 id와 같은 중요 정보가 노출되므로 좋지 않은 방식이라 생각했다.
그래서 JWT를 통해서 사용자 인증 처리를 하려 한다.

JWT(Json Web Token)란?

JWT는 토큰 기반 인증으로, 요청과 응답에 토큰을 함께 보내 사용자가 유효한 사용자인지를 확인한다.

클라이언트가 회원가입 시 서버는 토큰을 생성해서 응답한다. 클라이언트는 이 토큰을 저장해두었다가 인증이 필요한 api에 토큰 정보를 실어서 요청한다. 서버는 토큰이 유효한지 검증한 후, 유효한 경우 응답을 한다.

토큰?
JWT에서는 복잡하고 읽을 수 없는 String 형태로, 사용자의 인증정보가 담겨있다.

토큰 사용 방식의 특징

  1. 무상태성: 토큰을 클라이언트에 저장하기 때문에 서버에서 별도에 저장하지 않아도 되므로 무상태성을 가진다. 따라서 서버를 확장할 때 용이하다.
  2. 무결성: 발급 후 토큰의 정보를 변경할 수 없기 때문에 변조할 수 없다.



JWT 구현

1. 의존성 추가

// jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2'

2. TokenService 생성

토큰을 생성하고, 유효성을 검사는 로직이 있는 Token Service를 생성한다.

@Service
@RequiredArgsConstructor
public class TokenService {
    private String secretKey = "testSecretKey202301125testSecretKey202301125testSecretKey202301125";
    private final OauthMemberService memberService;

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

    public Token generateToken(String uid, List<String> roles) {
        long accessTokenPeriod = 1000L * 60L * 10L;
        long refreshPeriod = 1000L * 60L * 60L * 24L * 30L * 3L;

        Claims claims = Jwts.claims().setSubject(uid);
        claims.put("roles", roles);

        Date now = new Date();

        String accessToken = Jwts.builder()
                .setClaims(claims)  // save info
                .setIssuedAt(now)   // token generated time info
                .setExpiration(new Date(now.getTime() + accessTokenPeriod)) // set expire time
                .signWith(SignatureAlgorithm.HS256, secretKey)  // using encryption algorithm and set secret value
                .compact();

        String refreshToken = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + refreshPeriod))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        return Token.builder().accessToken(accessToken).refreshToken(refreshToken).build();
    }

    public boolean verifyToken(String token) {
        try {
            Jws<Claims> claims = Jwts.parser()
                    .setSigningKey(secretKey)
                    .parseClaimsJws(token);

            return claims.getBody()
                    .getExpiration().after(new Date());

        } catch (ExpiredJwtException e) {
            System.out.println("토큰 기한 만료");
            return false;
        }

        catch (Exception e) {
            System.out.println(e.getMessage());
            return false;
        }
    }

    // search authentication
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = memberService.loadUserByUsername(this.getUserPK(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    // get member info from token
    public String getUserPK(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token)
                .getBody().getSubject();
    }

    // extract token from header
    public String extractToken(HttpServletRequest request) {
        String header = request.getHeader(HttpHeaders.AUTHORIZATION);

        if (StringUtils.hasText(header) && header.startsWith("Bearer ")) {
            return header.substring("Bearer ".length());
        } else {
            return null;
        }
    }
}

3. 필터 등록

SecurityConfig.java에 필터를 등록한다.

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final TokenService tokenService;

    // register passwordEncoder bean for encryption
    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    // register authenticationManager bean
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .exceptionHandling()
                .and()
                .headers()
                .frameOptions()
                .sameOrigin()
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers("/oauth/**").permitAll()
                .anyRequest().authenticated()
                .and()
                .addFilterBefore(new JwtAuthFilter(tokenService), UsernamePasswordAuthenticationFilter.class);
    }
}
  • .antMatchers("/oauth/**").permitAll(): oauth redirect url은 토큰없이도 접근해야 하므로 permitAll 설정함
  • .anyRequest().authenticated(): 위에서 설정한 이외의 url은 권한이 있어야 접근 가능하도록 설정함
  • .addFilterBefore(new JwtAuthFilter(tokenService), UsernamePasswordAuthenticationFilter.class): JWT 필터를 생성해서 거치게 만듦

4. JWT 필터 생성

요청이 들어오면, 토큰이 있는지 확인해서 권한을 검증하는 JWT 필터를 만든다.

@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
    private final TokenService tokenService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // get token from header
        String token = tokenService.extractToken(request);
        System.out.println("token: " + token);

        // if token is valid
        if ((token != null) && (tokenService.verifyToken(token))) {
            // get member info from token
            Authentication authentication = tokenService.getAuthentication(token);

            // save member in securityContext
            SecurityContextHolder.getContext().setAuthentication(authentication);

        }
        // execute next filter
        filterChain.doFilter(request, response);
    }
}
  1. header에서 토큰을 추출한다.
  2. 토큰이 유효한지 검사한다.
  3. 토큰이 유효하다면, SecurityContextHolder에 인증정보를 저장한다.
  4. 다음 필터로 넘어간다.



로그인 시

로그인 시 토큰을 생성해서 body에 리턴한다.

MemberController.java

@GetMapping("/oauth/kakao")
    public ResponseEntity kakaoLoin(@RequestParam("code") String code)
                                        throws JsonProcessingException {
        Member member = oauthMemberService.oauthLogin(code, "kakao");
        // generate jwt
        Token token = tokenService.generateToken(member.getEmail(), member.getRoles());

        MemberResponseDto memberResponseDto = new MemberResponseDto(member.getEmail(), member.getNickname(),
                                                                    token.getAccessToken(), token.getRefreshToken());
        return ResponseEntity.status(HttpStatus.OK).body(memberResponseDto);
    }
  1. 회원의 이메일과 role을 사용해 토큰을 생성한다.
  • 현재 role은 부여하지 않았음
  1. responseDto에 필요한 정보를 넣고 return 한다.

API 호출 시

@GetMapping("/tourist-facilities/{contentId}")
    public ResponseEntity returnTouristInfo(@PathVariable("contentId") String contentId) {
        Member member = (Member) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        TouristFacilityInfoResponseDto result = touristFacilityService.returnInfoDto(member.get(), contentId);
        

        return ResponseEntity.status(HttpStatus.OK).body(result);
    }

SecurityContextHolder에 저장된 인증 정보를 가져온다.
가져온 정보(email)로 사용자 정보를 가져온다.


postman을 사용해서 테스트해보자.

API를 호출할 때 헤더에 jwt를 담아서 호출한다.
Authorization 탭에서 Bearer Token을 선택한 후, 로그인 시 발급받은 토큰을 입력한 다음 요청을 한다.

토큰이 유효하다면 결과값이 반환될 것이고, 유효하지 않다면, 403 Forbidden 에러가 발생한다.

참고
https://shinsunyoung.tistory.com/110
https://green-bin.tistory.com/27

0개의 댓글