Java

[Spring Boot] Spring Security JWT를 사용한 토큰 인증 구현

cob 2022. 10. 25. 10:25
Spring Cecurity
Spring Security 란?
간단히 말하면 Spring의 보안(인증과 권한, 인가 등)을 담당하는 서블릿 필터의 집합이다.
서블릿 필터는 서블릿 실행 전에 실행되는 클래스들로 토큰 인증을 위해 컨트롤러 메서드의 첫 부분마다 인증 코드를 작성하는 고민을 해결하기 위해 서블릿 필터를 사용한다.

 

JWT(JSON Web Token) 란?
2022.10.24 - [Java] - JWT(JSON Web Token) 개념 및 구조

 

* 전체 Source Code *
https://github.com/kangilbin/TodoList

 

 

 


1. JWT 토큰 발행 방법

토큰 발행 > 로그인 시 토큰 반환 > 토큰을 이용해 API 인증

 

 

1-1) 토큰 생성을 위한 JWT 라이브러리 추가하기

https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt/0.9.0
implementation group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'
  • gradle 추가(또는 maven)

 

 

1-2) 토큰 클래스 생성(TokenProvider.java)

Token 클래스

  • 인증과 인가를 관리하는 패키지를 생성(security package) 후 토큰 클래스 생성

( TokenProvider.java )

@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를 이용해 서명한 부분
		 *  Nn4d1MOVLZg79sfFACTIpCPKqWmpZMZQ.....
		 */
		
		// 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 메서드가 Base64로 디코딩 및 파싱
		// 헤더와 페이로드를 setSigningKey로 넘어온 시크릿을 이용해 서명한 후 token의 서명과 비교
		// 위조되지 않았다면 페이로드(Claims) 리턴, 위조라면 예외를 날림
		// 그중 우리는 userId가 필요하므로 getBody를 부른다
		Claims claims = Jwts.parser()
				.setSigningKey(SECRET_KEY)
				.parseClaimsJws(token)
				.getBody();
		
		return claims.getSubject(); // subject 즉 사용자 아이디를 리턴한다.
	}
}
  • JWT 라이브러리를 사용하면 JSON을 생성, 서명, 인코딩, 디코딩, 파싱 하는 작업을 하지 않아도 된다.

 

1-3) 토큰을 생성한 후 반환할 DTO 생성(UserDTO.java)

UserDTO 생성

( UserDTO.java )

package com.example.demo.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
public class UserDTO {
	private String token;
	private String email;
	private String username;
	private String password;
	private String id;
}

 

 

 

1-4) 사용자 Controller 수정

로그인(signin) API를 호출하게 되면 토큰을 생성 및 반환한다. 

(UserController.java)

// import 생략 
 
@Slf4j
@RestController
@RequestMapping("auth")
public class UserController {
	
	@Autowired
	private UserService userService;
	
	@Autowired
	private TokenProvider tokenProvider;

...

@PostMapping("/signin")
	public ResponseEntity<?> authenticate(@RequestBody UserDTO userDTO) {
		UserEntity user = userService.getByCredntials(userDTO.getEmail(), userDTO.getPassword());
		
        	// 사용자의 id, pwd 일치할 경우
		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 faild.")
					.build();
			return ResponseEntity.badRequest().body(responseDTO);
		}
	}
}
  • 로그인 (/signin) API 호출할 때 Token 인증

 

 

 


2. Spring Security + JWT 구현 방법

2-1) 라이브러리 설치

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

 

2-2) 토큰 인증 필터 생성( JwtAuthenticationFilter.java )

인증필터 생성

  • 토큰을 발행할 때 만들어 두었던 security  패키지에 생성

 

( JwtAuthenticationFilter.java )

@Slf4j
@Component
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);
				
				//인증 완료. SecurityContextHolder에 등록해야 인증된 사용자라고 생가한다.
				AbstractAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
						userId, // 인증된 사용자의 정보. 문자열이 아니어도 아무것이나 넣을 수 있다. 보통 UserDetails라는 오브젝트를 넣는데 우리는 넣지 않았다.
						null, 
						AuthorityUtils.NO_AUTHORITIES
						);
				authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
				SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
				securityContext.setAuthentication(authentication); // 인증 정보 넣기
				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) {
		// Http 요청의 헤더를 파싱해 Bearer 토큰을 리턴한다.
		String bearerToken = request.getHeader("Authorization");
		
		if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
			return bearerToken.substring(7);
		}
		return null;
	}
}
  • OncePerRequestFilter라는 클래스를 상속한다. 해당 클래스는 한 요청당 반드시 한 번만 실행된다. 따라서 한 번만 인증하면 되는 필터이다.
  • SecurityContext : 기본적으로 ThreadLocal에 저장되므로 Thread마다 하나의 콘텍스트를 관리할 수 있으며 같은 스레드 내라면 어디에서든 접근할 수 있다.
  • JwtAuthenticationFilter 인증 흐름
    1) 요청의 헤더에서 Bearer 토큰을 가져온다. 이 작업은 parseBearerToken() 메서드에서 이루어진다. 
    2) TokenProvider클래스를 이용해 토큰을 인증한다.
    3) UsernamePasswordAuthenticationToken을 작성한다. 이 오브젝트에 사용자의 인증 정보를 저장하고 SecurityContext에 인증된 사용자를 등록한다. 등록하여 요청을 처리하는 과정에서 사용자가 인증됐는지 여부나 인증된 사용자가 누군지 알 수 있다. 

 

 


3.  Spring Security 설정 방법

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

 

3-1) Security 설정 파일 생성( WebSecurityConfig.java )

Security 설정파일

  • 설정을 관리하는 config package안에 Security 설정 클래스 생성

 

( WebSecurityConfig.java )

import org.springframework.beans.factory.annotation.Autowired;
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.web.filter.CorsFilter;
import com.example.demo.security.JwtAuthenticationFilter;
import lombok.extern.slf4j.Slf4j;

@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()					// csrf는 현재 사용하지 않으므로 disable
			.disable()
		.httpBasic()			// token을 사용하므로 basic 인증 disable
			.disable()
		.sessionManagement()	// session 기반이 아님을 선언
			.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
		.and()
		.authorizeRequests()	// /와 /autho/** 경로는 인증 안해도 됨
			.antMatchers("/", "/auth/**").permitAll()
		.anyRequest()			// /와 /auth/** 이외의 모든 경로는 인증 해야 됨
			.authenticated();
		
		// filter 등록
		// 매 요청마다
		// CorsFilter 실행한 후에
		// jwtAuthenticationFilter 실행한다.
		http.addFilterAfter(
			jwtAuthenticationFilter,
			CorsFilter.class
				);
	}
}
  • HttpSecurity는 시큐리티 설정을 위한 오브젝트이다. 이 오브젝트는 빌더를 제공해 다양한 설정을 할 수 있다. 말하자면 web.xml 대신 HttpSecurity를 이용해 시큐리티 관련 설정을 하는 것이다.
  • addFilterAfter() : 실제로 실행하는 것이 아니라 JwtAuthenticationFilter를 CorsFilter 이후에 실행하라고 설정하는 것이다.

 

 


4. POSTMAN을 사용한 Token 인증 구현 테스트 방법

1)

토큰 발급

  • /signup으로 아이디 생성 후 로그인 시도하여 token 발급

 

 

2)

토큰 인증

  • 정상정으로 인증됐는지 확인 Authorization > Bearer Token > Token 입력

 

 

3)

로그에서 확인

  • 입력한 Token은 로그에서 확인 가능

 

Token 오류

  • 토큰을 잘 못 입력하게 되면 예외 처리 발생

 

 

 


5. Controller 인증 적용

API 호출 시 인증 적용 

 

( TodoController.java )

@RestController
@RequestMapping("todo")
public class TodoController {
	
	@Autowired
	private TodoService service;
	
	@PostMapping
	public ResponseEntity<?> createTodo(@AuthenticationPrincipal String userId,@RequestBody TodoDTO dto){
		try {
			
			// (1) TodoEntity로 변환한다.
			TodoEntity entity = TodoDTO.toEntity(dto);
			
			// (2) id를 null로 초기화한다. 생성 당시에는 id가 없어야하기 때문이다.
			entity.setId(null);

			// (3) 임시 사용자 아이디를 설정해 준다. @AuthenticaionPrincipal에서 넘어온 userId로 설정
			entity.setUserId(userId);
			
			// (4) 서비스를 이용해 Todo 엔티티를 생성한다.
			List<TodoEntity> entities = service.create(entity);
			
			// (5) 자바 스트림을 이용해 리턴된 엔티티 리스트를 TodoDTO 리스트로 변환한다.
			List<TodoDTO> dtos = entities.stream().map(TodoDTO::new).collect(Collectors.toList());
			
			// (6) 변환된 TodoDTO 리스트를 이용해 ResponseDTO를 초기화한다.
			ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().data(dtos).build();
			
			// (7) ResponseDTO를 리턴한다.
			return ResponseEntity.ok().body(response);
		} catch (Exception e) {
			// (8) ,혹시 예외가 있는 경우 dto 대신 error에 메시지를 넣어 리턴한다.
			String error = e.getMessage();
			ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().error(error).build();
			return ResponseEntity.badRequest().body(response);
		}
	}
....
  • @AuthenticationPrincipal 
    - 스프링에서 매개변수로 넘겨준다.
    - 필터(JwtAuthenticationFilter.java)에서 발급받은 토큰의 첫 매개변수를 가리킨다.
    - 이 오브젝트를 SecurityContext에 등록해주고, 스프링은 해당 오브젝트를 가져와 컨트롤러 메서드에 넘겨준다.  
    * 즉, JwtAuthenticationFilter 클래스에서 AuthenticationPrincipal을 String형의 오브젝트로 지정했기 때문에 @AuthenticationPrincipal의 형으로 String을 사용해야 한다.
반응형