AWS Back Day 73. "Spring Boot로 구현하는 JWT 인증과 예외 처리"

이강용·2023년 4월 14일

Spring Boot

목록 보기
8/20

Review -Ⅰ

1단계





로그인

2단계

Post (feat.POSTMAN)
JSON
(auth/login)

3단계

AuthController
@RequestBody
Dto

{
	username
    password
}

4단계
AuthService <- AuthenticationManager

SpringBoot Security -> IOC(Inversion of Control) -> amb

JWT 토큰 return -> AuthController -> 응답 ResponseEntity, DataResponse(JWT) -> 로그인
-> 최종적으로 Authentication 객체가 return


인증 (Json Web Token) 이란?

  • 인터넷에서 데이터를 안전하게 전송하기 위한 토큰 기반 인증 방식 중 하나

  • JWT는 JSON 객체를 사용하여 정보를 안전하게 전달

  • JWT는 헤더(Header), 페이로드(Payload), 서명(Signature) 세 부분으로 이루어져 있으면, 각 부분은 .으로 구분

    • HeaderJWT의 종류와 사용된 암호화 알고리즘 정보를 담고 있음
    • PayloadJWT에 담을 정보를 JSON 객체로 표현한 부분으로, 사용자 정보와 같은 클레임(Claim)을 포함할 수 있음
    • SignatureToken이 변조되었는지 검증하기 위한 부분
  • JWT 는 발급된 이후에는 수정이 불가능하며, JWT를 사용하여 인증할 경우, 서버 측에서는 클라이언트가 보낸 JWT를 검증하기만 하면 됨

  • 이러한 특징으로 인해 JWT는 분산 시스템에서 인증에 활용되는 것이 효과적임

  • 흐름구조
    Client -> Filter -> Controller

SecurityConfig (수정)

package com.web.study.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import com.web.study.security.jwt.JwtAuthenticationFilter;
import com.web.study.security.jwt.JwtTokenProvider;

import lombok.RequiredArgsConstructor;




@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	
	private final JwtTokenProvider jwtTokenProvider;
	
	@Bean
	public BCryptPasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
	
	// security filter
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		
		http.csrf().disable();
		http.httpBasic().disable(); // 웹 기본 인증 방식
		http.formLogin().disable(); // 폼태그 통한 로그인
		http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); // 세션 비활성 (무상태성)
		
		
		http.authorizeRequests()
			.antMatchers("/auth/register/**", "/auth/login/**")
			.permitAll()
			.anyRequest()
			.authenticated()
			.and()
			.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
			
	}
}

인증security filter단에서 이루어져야한다.

null에 들어갈 Filter가 필요하다

JwtAuthenticationFilter 생성

package com.web.study.security.jwt;

import java.io.IOException;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.GenericFilterBean;

import lombok.RequiredArgsConstructor;


@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
	
	//DI (의존성 주입)  JwtAuthenticationFilter가 생성될 때 jwtTokenProvider가 만들어짐
	private final JwtTokenProvider jwtTokenProvider;  

	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		
		String token = getToken(request);
		
		boolean validationFlag = jwtTokenProvider.validateToken(token);
		
		if(validationFlag ) {	
			Authentication authentication = jwtTokenProvider.getAuthentication(token);
			SecurityContextHolder.getContext().setAuthentication(authentication);
		}
		
		
		chain.doFilter(request, response);
	}
	
	private String getToken(ServletRequest request) {
		HttpServletRequest httpServletRequest = (HttpServletRequest) request;
		String type = "Bearer";
		String token = httpServletRequest.getHeader("Authorization");
		// hasText 문자열이 Null 또는 공백이 아닌지 확인 
		if(StringUtils.hasText(token) && token.startsWith(type)) {
			return token.substring(type.length() + 1);
		}
		return null;
	}
		
}

JwtTokenProvider (수정)

package com.web.study.security.jwt;

import java.security.Key;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import com.web.study.dto.response.auth.JwtTokenRespDto;
import com.web.study.exception.CustomException;
import com.web.study.security.PrincipalUserDetails;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SecurityException;
import lombok.extern.slf4j.Slf4j;


@Slf4j
@Component
public class JwtTokenProvider {
	private final Key key;
	
	
	public JwtTokenProvider(@Value("${jwt.secretKey}") String secretKey) {
		byte[] keyBytes = Decoders.BASE64.decode(secretKey);
		this.key = Keys.hmacShaKeyFor(keyBytes);
	}
	
	public JwtTokenRespDto creatToken(Authentication authentication) {
		
		StringBuilder authoritiesBuilder = new StringBuilder();
		
		authentication.getAuthorities().forEach(grantedcAuthority -> { 
			
			authoritiesBuilder.append(grantedcAuthority.getAuthority());
			authoritiesBuilder.append(",");
		});
		
		authoritiesBuilder.delete(authoritiesBuilder.length() - 1, authoritiesBuilder.length());
		
		String authorities = authoritiesBuilder.toString();
		
		long now = (new Date()).getTime();
		// 1000 == 1초
		Date tokenExpiresDate = new Date(now + (1000 * 60 * 300)); // 토큰 만료 시간 ( 현재 시간 + Timer)
		
		PrincipalUserDetails userDetails = (PrincipalUserDetails) authentication.getPrincipal();
		
		String accessToken = Jwts.builder()
				.setSubject(authentication.getName())
				.claim("userId", userDetails.getUserId())
				.claim("auth", authorities)
				.setExpiration(tokenExpiresDate)
				.signWith(key, SignatureAlgorithm.HS256)
				.compact();
					
		
		return JwtTokenRespDto.builder()
				.grantType("Bearer")
				.accessToken(accessToken)
				.build();
	}
	
	public boolean validateToken(String token) {
		try {
			Jwts.parserBuilder()
			.setSigningKey(key)
			.build()
			.parseClaimsJws(token);	
			
			return true;
		}catch(SecurityException | MalformedJwtException e) {
			// Security 라이브러리에 오유가 있거나, JSON의 포맷이 잘못된 형식의 JWT가 들어왔을 때 예외
			// SignatureException이 포함되어 있음
			log.info("Invalid JWT Token", e);
		}catch (ExpiredJwtException e) {
			// 토큰의 유효기간이 만료된 경우의 예외
			log.info("Expired JWT Token", e);
		}catch (UnsupportedJwtException e) {
			// jwt의 형식을 지키지 않은 경우 (Header.Payload.Signature)
			log.info("Unsupported JWT Token", e);
		}catch (IllegalArgumentException e) {
			// jwt 토큰이 없을때
			log.info("IllegalArgument JWT Token", e);
		}catch (Exception e) {
			log.info("JWT Token", e);
		}
		return false;
	}
	
	public Authentication getAuthentication(String accessToken){
		
		Claims claims = parseClaims(accessToken);
		Object roles = claims.get("auth");
		
		if(roles == null) {
			throw new CustomException("권한 정보가 없는 토큰입니다.");
		}
		
		List<SimpleGrantedAuthority> authorities = new ArrayList<>();
		String[] rolesArray = roles.toString().split(",");
		Arrays.asList(rolesArray).forEach(role -> {
			authorities.add(new SimpleGrantedAuthority(role));
		});
		
		UserDetails userDetails = new User(claims.getSubject(),"",authorities);
				
		
		return new UsernamePasswordAuthenticationToken(userDetails,"",authorities);
	}
	
	private Claims parseClaims(String accessToken) {
		
		try {
			return Jwts.parserBuilder()
					.setSigningKey(key)
					.build()
					.parseClaimsJws(accessToken)
					.getBody();
		}catch (ExpiredJwtException e) {
			return e.getClaims();
		}
	}
	
}

POSTMAN SEND

예외처리

JwtAuthenticationEntryPoint 생성

package com.web.study.security.jwt;

import java.io.IOException;
import java.io.PrintWriter;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;


import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.web.study.dto.ErrorResponseDto;

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
	
	
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
		AuthenticationException authException) throws IOException, ServletException {
	
	response.setContentType(MediaType.APPLICATION_JSON_VALUE);
	response.setStatus(HttpStatus.UNAUTHORIZED.value());
	PrintWriter out = response.getWriter();
	ObjectMapper responseJson = new ObjectMapper();
	out.println(responseJson.writeValueAsString(ErrorResponseDto.of(HttpStatus.UNAUTHORIZED,authException)));
	
	}
}

Token 등록


Review - Ⅱ

1. 회원가입

- 회원가입할 정보를 입력
- 해당 정보로 회원가입을 요청
- 회원가입 정보(password는 암호화)를 DB에 저장
> AuthController 회원가입 정보를 post요청(RegisteUserReqDto)
> toEntity에서 password 암호화 진행
> 회원가입 등록 완료 후, 예외가 있는게 아니면 Controller에서 return

2. 로그인

- 로그인할 정보를 입력 (username, password)
- 해당 정보로 로그인 요청
- AuthenticationManager에게 username, password를 전달
- AuthenticationManager가 인증을 시작
- UserDetailsService의 loadUserByUsername(String username)이 호출
  > userDetails를 리턴받아서 Authentication객체를 생성하기 위함
  > 이때, 해당 username으로 DB에서 조회된 UserEntity가 없으면 등록되지 않은 회원 (예외처리)
  > Authentication객체가 생성되면 로그인 성공
- Authentication객체를 JWT로 변환하는 작업을 수행
- 변환된 JWT를 클라이언트에게 응답
- 클라이언트는 JWT토큰을 로컬스토리지나 쿠키에 저장

3. 요청시 토큰 인증

- 요청 Header에 Bearer 방식으로 JWT 토큰을 전달
- Spring Security에서 인증이 필요한 요청들은 JwtAuthenticationFilter를 통해 인증절차를 진행
  > 이때, 인증의 최종 목표는 SecurityContextHolder객체의 Context에 Authentication을 등록하는 것
  > Authentication 객체가 등록이 되어 있으면 인증이 됨
- JwtAuthenticationFilter에서 요청 Header에 들어있는 Authorization의 JWT토큰을 추출
- JWT 토큰을 검증
  > 이때 검증에 실패하여  Exception이 생성되면 AuthenticationEntryPoint가 실행되면서 401 응답을 하게됨
- JWT 토큰 검증이 완료되면 JWT 토큰에서 Claims를 추출
- Claims에서 username과 Authorities를 추출하여 Authentication 객체를 생성
- 생성된 Authentication객체를 SecurityContext에 등록
- 등록이 완료되면 다음 doChain이 호출
profile
HW + SW = 1

0개의 댓글