Back-End/React.js, 스프링 부트, AWS로 배우는 웹 개발 101

[React.js, 스프링 부트, AWS로 배우는 웹 개발 101][인증 백엔드 통합] - 스프링 시큐리티 와 서블릿 필터

얄루몬 2022. 6. 27. 04:44

본 포스팅은 'React.js, 스프링 부트, AWS로 배우는 웹 개발 101 - 김다정'님의 책을 보고 작성되었습니다.


목차
1. 스프링 시큐리티를 사용한 인증, 인가
2. JWT 생성 및 반환 구현

3. 스프링 시큐리티와 서블릿 필터
4. JWT를 이용한 인증 구현

1. 스프링 시큐리티를 사용한 인증, 인가

API 요청에 토큰 또는 아이디와 비밀번호를 보내는 작업을 스프링 시큐리티를 사용해 코드를 한 번만 짜고, 이 코드가 모든 API 요청에 수행되기 전에 실행되도록 설정및 구현을 해야한다.

2. JWT 생성 및 반환 구현

사용자 정보를 바탕으로 헤더와 페이로드를 작성하고 전자 서명한 후 토큰을 리턴해야 한다.

  • JWT 관련 라이브러리를 디펜던시에 추가한다.
implementation group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'
  • security 패키지를 만들어 인증, 인가를 위한 모든 클래스를 해당 패키지 안에서 관리한다.

사용자 정보를 받아 JWT를 생성하는 클래스

package com.example.demo.security;

import com.example.demo.model.UserEntity;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;

@Slf4j
@Service
public class TokenProvider {
    private static final String SECRET_KEY = "NMA8JPctFuna59f5";

    public String create(UserEntity userEntity) {
        // 기한 지금으로부터 1일로 설정
        Date expiryDate = Date.from(
                Instant.now()
                        .plus(1, ChronoUnit.DAYS));

		/*
		{ // header
		  "alg":"HS512"
		}.
		{ // payload
		  "sub":"40288093784915d201784916a40c0001",
		  "iss": "demo app",
		  "iat":1595733657,
		  "exp":1596597657
		}.
		// SECRET_KEY를 이용해 서명한 부분
		Nn4d1MOVLZg79sfFACTIpCPKqWmpZMZQsbNrXdJJNWkRv50_l7bPLQPwhMobT4vBOG6Q3JYjhDrKFlBSaUxZOg
		 */
        // JWT Token 생성
        return Jwts.builder()
                // header에 들어갈 내용 및 서명을 하기 위한 SECRET_KEY
                .signWith(SignatureAlgorithm.HS512, SECRET_KEY)
                // payload에 들어갈 내용
                .setSubject(userEntity.getId()) // sub
                .setIssuer("demo app") // iss
                .setIssuedAt(new Date()) // iat
                .setExpiration(expiryDate) // exp
                .compact();
    }

    public String validateAndGetUserId(String token) {
        // parseClaimsJws메서드가 Base 64로 디코딩 및 파싱.
        // 즉, 헤더와 페이로드를 setSigningKey로 넘어온 시크릿을 이용 해 서명 후, token의 서명 과 비교.
        // 위조되지 않았다면 페이로드(Claims) 리턴
        // 그 중 우리는 userId가 필요하므로 getBody를 부른다.
        Claims claims = Jwts.parser()
                .setSigningKey(SECRET_KEY)
                .parseClaimsJws(token)
                .getBody();

        return claims.getSubject();
    }
}
  • create( )
    • JWT 라이브러리를 사용해 JWT 토큰을 생성한다.
  • validateAndGetUserId( )
    • 토큰을 디코딩 및 파싱하고 토큰 위조 여부를 확인한다.
    • 이후 우리가 원하는 subject, 즉 사용자 아이디를 리턴한다.

토큰 생성 클래스를 사용해 토큰을 생성한 후 DTO에 반환하는 코드

 @PostMapping("signin")
    public ResponseEntity<?> authenticate(@RequestBody UserDTO userDTO){
        UserEntity user = userService.getByCredentials(
                userDTO.getEmail(),
                userDTO.getPassword());

        if(user != null){
            final String token = tokenProvider.create(user);
            final UserDTO responseUserDTO = UserDTO.builder()
                    .email(user.getEmail())
                    .id(user.getId())
                    .token(token)
                    .build();
            return ResponseEntity.ok().body(responseUserDTO);
        } else {
            ResponseDTO responseDTO = ResponseDTO.builder()
                    .error("Login Failed")
                    .build();
            return ResponseEntity.badRequest().body(responseDTO);
        }

3. 스프링 시큐리티와 서블릿 필터

  • API가 실핼될 때마다 사용자를 인증해주는 부분을 구현할 것이다(스프링 시큐리티를 사용해서 구현).
  • 토큰 인증을 위해 컨트롤러 메서드의 첫 부분마다 인증 코드를 작성해야 한다는 것이다.
    • 이 고민은 서블릿 필터를 사용해 해결한다.

스프링 시큐리티란?

  • 서블릿 필터의 집합을 스프링 시큐리티라 한다.

서블릿 필터란?

  • 서블릿 실행 전에 실행되는 클래스들을 의미한다.
  • 스프링이 구현하는 서블릿의 이름은 디스패처 서블릿이라고 한다.
  • 즉, 서블릿 필터는 디스패처 서블릿이 실행되기 전에 항상 실행되는 클래스들을 의미한다.
  • 우리는 서블릿 필터를 구현, 서블릿 필터를 서블릿 컨테이너가 실행하도록 설정해주면 된다.

서블릿 컨테이너의 서블릿 필터

  • 서블릿 필터는 이름처럼 구현된 로직에 따라 원하지 않는 HTTP 요청을 걸러낼 수 있으며 조건에 부합되는 요청은 디스패처 서블릿으로 넘어와 컨트롤러에서 실행된다.
  • 서블릿 필터는 여러개가 들어갈 수 있다. 그 이유는 걸러내고 싶은 조건이 많을 수록 필터의 클래스 크기가 커지기 때문에 여러 조건 마다 나눠서 필터로 연결해 사용할 수 있다.

스프링의 서블릿 필터

 

스프링의 서블릿 필터

  • 스프링 시큐리티 프로젝트를 추가하면 스프링 시큐리티가 FilterChainProxy라는 필터를 서블릿 필터에 끼워 넣어준다.
  • 이 FilterChainProxy 클래스 안에서 내부적으로 필터를 실행시키는데 이 필터들이 스프링이 관리하는 스프링 빈 필터다.

서블릿 필터를 사용하기 위한 두 가지 작업

  1. 서블릿 필터 구현
  2. 서블릿 컨테이너에 이 서블릿 필터를 사용하라고 알려주는 설정 작업

4. JWT를 이용한 인증 구현

implementation 'org.springframework.boot:spring-boot-starter-security'

OncePerRequestFilter를 상속하는 필터 구현

package com.example.demo.security;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;


@Component
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    @Autowired
    private TokenProvider tokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        try {
            //요청에서 토큰 가져오기
            String token = parseBearerToken(request);
            log.info("Filter is running...");

            //토큰 검사,JWT 인가 서버에 요청 없이 검증 가능
            if(token != null && !token.equalsIgnoreCase("null")){
                //userId 가져오기.
                String userId = tokenProvider.validateAndGetUserId(token);
                log.info("Authenticated user ID : "+userId);

                //인증 완료
                AbstractAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                        userId,
                        null,
                        AuthorityUtils.NO_AUTHORITIES
                );
                authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
                securityContext.setAuthentication(authenticationToken);
                SecurityContextHolder.setContext(securityContext);
            }
        } catch (Exception ex){
            logger.error("Could not set user authentication in security context", ex);
        }

        filterChain.doFilter(request, response);
    }

    private String parseBearerToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");

        if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Beare ")){
            return bearerToken.substring(7);
        }
        return null;
    }
}
  1. 요청 헤더에서 Bearer 토큰을 가져온다. parseBearerToken( )에서 이 작업이 이루어진다.
  2. 토큰 생성 클래스를 이용해 토큰을 인증한 다음 UsernamePasswordAuthenticationToken을 작성한다. 
  3. 인증된 사용자를 SecurityContext에 저장한다. (인증 사용 여부를 사용해야할 때가 있기 때문이다.)