하효닝
log(hahyun ^ B)
하효닝
전체 방문자
오늘
어제
  • 분류 전체보기 (140)
    • Diary (0)
    • Web (7)
    • Frontend (8)
    • Python (44)
      • Python (1)
      • Algorithm (13)
      • Coding Test (30)
    • Django (3)
      • Django (2)
      • Django Rest (1)
    • Java (14)
      • Java (10)
      • Java Tuning (4)
    • Spring (34)
      • Spring (7)
      • Spring MVC (5)
      • DB 접근기술 (1)
      • JPA (10)
      • Spring Security (3)
      • Rest API (8)
    • Computer Science (26)
      • Operating System (8)
      • Linux (2)
      • Network (2)
      • Database (9)
      • SQL Tuning (5)
    • AWS (2)
    • Git (0)
    • etc (1)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
하효닝

log(hahyun ^ B)

Spring/Spring Security

JWT 인증 구현

2022. 1. 21. 22:15

서블릿 필터

  • 서블릿 필터란 서블릿 실행 전에 실행되는 클래스들로, 디스패처 서블릿이 실행되기 전에 항상 실행된다.
  • 구현된 로직에 따라 원하지 않는 HTTP 요청을 걸러낼 수 있으며, 걸러낸 HTTP는 거절된다.
  • 서블릿 필터에서 전부 살아남은 HTTP 요청은 디스패처 서블릿으로 넘어와 컨트롤러에서 실행된다.
  • 서블릿 필터를 구현하려면 HttpFilter 또는 Filter를 상속해 doFilter() 라는 메서드를 원하는대로 오버라이딩
  • 서블릿 필터들은 FilterChain을 이용해 연쇄적으로 순서대로 실행되며, 각 필터는 다음으로 부를 Filter를 FilterChain 안에 갖고 있어 FilterChain을 통해 다음 필터를 실행할 수 있다.

 

스프링 시큐리티

스프링 시큐리티는 스프링 기반 어플리케이션의 권한과 인증, 인가 등의 보안을 담당하는 프레임워크로, 간단히 말하면 서블릿 필터의 집합이라고 할 수 있다.

 

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


 

스프링 시큐리티 인증 과정

  1. 사용자가 로그인 정보와 함께 인증 요청 (HttpRequest)
  2. AuthenticationFilter가 요청을 가로채고, 가로챈 정보를 통해 UsernamePasswordAuthenticationToken 객체 생성 (미인증 상태)
  3. AuthenticationManager 구현체인 ProviderManager에게 UsernamePasswordAuthenticationToken 객체 전달
  4. AuthenticationProvider에 UsernamePasswordAuthenticationToken 객체 전달
  5. 실제 DB로부터 사용자 인증 정보를 가져오는 UserDetailsService에 사용자 정보 전달
  6. 넘겨받은 정보를 통해 DB에서 찾은 사용자 정보인 UserDetails 객체 생성
  7. AuthenticationProvider는 UserDetails를 넘겨받고 사용자 정보 비교
  8. 인증이 완료되면 사용자 정보를 담은 Authentication 객체 반환
  9. AuthenticationFilter에 Authentication 객체가 반환
  10. Authentication 객체를 SecurityContext에 저장
더보기

OAuth 2.0 로그인을 사용하면 AuthenticationFilter 대신 OAuth2LoginAuthenticationFilter 호출

JWT 로그인 사용을 위해서 앞으로 OncePerRequestFiler를 상속받은 JwtAuthenticationFilter를 구현할 것

 

JWT 인증

의존성 추가

implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'org.springframework.boot:spring-boot-starter-security'

 

TokenProvider

사용자 정보를 받아 JWT를 생성하고 토큰을 검증하는 클래스

@Slf4j
@Service
public class TokenProvider{

    private static final String SECRET_KEY = "NMA8JPctFuna59f5";

 	// JWT 라이브러리를 이용해 JWT 토큰 생성
    public String create(UserEntity userEntity){
        // 만료기한 1일
        Date expiryDate = Date.from(Instant.now()
                        .plus(1, ChronoUnit.DAYS));

        // JWT Token 생성
        return Jwts.builder()
                // header, secret_key
                .signWith(SignatureAlgorithm.HS512, SECRET_KEY)
                // payload
                .setSubject(userEntity.getId())
                .setIssuer("My app")
                .setIssuedAt(new Date())
                .setExpiration(expiryDate)
                .compact();
    }

	// 토큰을 디코딩 및 파싱하고 토큰의 위조 여부를 확인한 후, 사용자의 아이디를 리턴
    public String validateAndGetUserId(String token){
        // parseClaimsJws: Base64로 디코딩 및 파싱
        // 헤더와 페이로드를 setSigningKey로 넘어온 시크릿을 이용해 서명한 후 token의 서명과 비교
        // 위조되지 않았다면 페이로드 리턴, 위조라면 예외 날림
        // userId가 필요하므로 getBody 호출
        Claims claims = Jwts.parser()
                .setSigningKey(SECRET_KEY)
                .parseClaimsJws(token)
                .getBody();

        return claims.getSubject();
    }
}

JwtAuthenticationFilter

@Slf4j
@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final 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 authentication = new UsernamePasswordAuthenticationToken(
                        userId, // AuthenticationPrincipal
                        null, // 인증된 사용자의 정보, 보통 UserDetails 라는 오브젝트를 넣는다.
                        AuthorityUtils.NO_AUTHORITIES);
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

                // SecurityContextHolder 에 등록
                SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
                securityContext.setAuthentication(authentication);
                SecurityContextHolder.setContext(securityContext);
            }
        } catch (Exception e) {
            logger.error("Could not set user authentication in security context", e);
        }
        filterChain.doFilter(request, response);
    }

    private String parseBearerToken(HttpServletRequest request) {
        // http 요청의 헤더를 파싱해 bearer 토큰 리턴
        String bearerToken = request.getHeader("Authorization");

        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

 

  • HttpFilter 대신 OncePerRequestFilter를 상속하며, doFilter 대신 doFilterInternal을 오버라이딩 한다.
  • 내부 토큰 인정 과정
    1. 요청의 헤더에서 Bearer 토큰을 가져온다. (parseBearerToken() 메서드)
    2. TokenProvider를 이용해 토큰을 인증하고 UsernamePasswordAuthenticationToken을 작성한다.
    3. UsernamePasswordAuthenticationToken에 사용자 인증 정보를 저장하고 SecurityContext에 인증된 사용자를 등록한다. (요청을 처리하는 과정에서 사용자 인증 여부나 인증된 사용자가 누군지 확인하기 위함)

 

요청에서 인증된 사용자 식별

  • 스프링 시큐리티의 SecurityContext는 SecurityContextHolder의 createEmptyContext() 메서드를 이용해 생성
  • 생성한 컨텍스트에 인증 정보인 authentication을 넣고 다시 SecurityContextHolder에 컨텍스트로 등록한다.
  • SecurityContextHolder는 기본적으로 ThreadLocal에 저장되며, Thread 마다 하나의 컨텍스트를 관리할 수 있고 같은 스레드 내라면 어디에서든 접근할 수 있다.
    • ThreadLocal에 저장된 오브젝트는 각 스레드별로 저장되고 마찬가지로 불러온 내 스레드에서 저장한 오브젝트만 불러올 수 있다.

 

  • 스프링은 컨트롤러 메서드를 호출할 때 @AuthenticationPricipal 어노테이션이 있으면, SecurityContextHolder에서 SecurityContext::Authentication, 즉 UsernamePasswordAuthenticationToken 오브젝트를 가져온다.
  • 이 오브젝트에서 AuthenticationPrincipal을 가져와 컨트롤러 메서드에 넘겨준다.
  • AuthenticationPrincipal을 String 형의 오브젝트로 지정했으므로, @AuthenticationPrincipal의 형으로 String을 사용해야 한다.

 

WebSecurityConfig

  • HttpSecurity는 스프링 시큐리티 설정을 위한 오브젝트로, 제공하는 빌더를 통해 다양한 설정을 할 수 있다.
  • 서블릿 컨테이너에 서블릿 필터를 사용하라고 알려주는 설정 작업 또한 HttpSecurity를 이용한다.
@EnableWebSecurity
@Slf4j
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // http 시큐리티 빌더
        http.cors() // WebMvcConfig 에서 이미 설정했으르로 기본 cors 설정
                .and()
                .csrf()
                    .disable()
                .httpBasic() // token을 사용하므로 basic 인증은 disable
                    .disable()
                .sessionManagement() // session 기반이 아님을 선언
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                    .antMatchers("/", "/auth/**").permitAll()
                .anyRequest()
                    .authenticated();

        // filter 등록
        // 매 요청마다 CorsFilter 실행 후 jwtAuthenticationFiler 실행
        http.addFilterAfter(jwtAuthenticationFilter, CorsFilter.class);
    }
}

 

패스워드 암호화

  • 패스워드 암호화를 위해서 스프링 시큐리티가 제공하는 BCryptPasswordEncoder를 사용한다.
  • BCryptPasswordEncoder는 같은 값을 인코딩하더라도 할 때마다 값이 다르고, 패스워드에 랜덤하게 의미없는 값을 붙여 결과를 생성한다.
    • 이런 의미 없는 값을 보안 용어로 Salt라고 하고, Salt를 붙여 인코딩하는 것을 Salting이라고 한다.
  • 사용자에게 받은 패스워드를 인코딩해도 데이터베이스에 저장된 패스워드와는 다를 확률이 높기 때문에, BCryptPasswordEncoder는 어떤 두 값의 일치 여부를 알려주는 메서드인 matchers()를 제공한다.

 

Contoller

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

    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);
    }
}

 

Service

@Transactional(readOnly = true)
public UserEntity getByCredentials(final String email, final String password, final PasswordEncoder encoder) {
    final UserEntity originalUser =  userRepository.findByEmail(email);

    if (originalUser != null && encoder.matches(password, originalUser.getPassword())) {
        return originalUser;
    }
    return null;
}

'Spring > Spring Security' 카테고리의 다른 글

스프링 시큐리티 설정과 로그인 구현  (0) 2022.01.25
OAuth2 로그인 인증  (0) 2022.01.21
    'Spring/Spring Security' 카테고리의 다른 글
    • 스프링 시큐리티 설정과 로그인 구현
    • OAuth2 로그인 인증
    하효닝
    하효닝

    티스토리툴바