본문 바로가기

Web Sever 개발과 CS 기초/스프링

Spring Security와 JWT 토큰을 이용하여 REST API 인증,인가 구현하기

관련 내용

해당 프로젝트 깃허브

해당 프로젝트 깃허브 커밋시점

[스프링] - 쿠키와 세션의 구조 - 세션 로그인과 JWT 로그인 차이

[스프링] - TCP 통신 보안 허점과 RSA 보안 방식

개요와 목적

이번 글에서는 Spring Security와 JWT 토큰을 이용하여 REST API 인증,인가 처리를 해보려고 한다.

구현하려고 하는 기능

로그인 하기 인증 처리

UI서버-http://127.0.0.1:8080/user/manage/login 페이지에 들어가서 로그인 버튼을 클릭하면, 인풋에 입력된 id와 password로 API서버 /login 주소로 로그인 요청한다.

UI 페이지에서 보낸 정보가 DB에 있는 id,password와 일치한다면, JWT 인증 토큰을 제공한다. 브라우저는 해당 토큰을 저장한다.

개인 페이지 입장 성공(인가 성공)

UI서버 - http://127.0.0.1:8080/user/manage/account 페이지에 들어 가게 되면,

@RequiredArgsConstructor
@RestController
@Slf4j
@RequestMapping("/api/user/manage")
public class UserManageController {
    @GetMapping("/check")
    public ResponseEntity check() {
        return new ResponseEntity(HttpStatus.OK);
    }
}

API서버 -해당 컨트롤러로 요청을 하게 된다.

올바른 JWT 토큰을 헤더에 담아 요청하게 되면, API check 컨트롤러에서 HttpStatus.OK 메세지를 받아서, 개인 페이지 입장이 가능해진다.

개인 페이지 입장 실패 (인가 실패)

잘못된 JWT 토큰이거나, JWT 토큰 없이

UI서버 - http://127.0.0.1:8080/user/manage/account 페이지에 들어 가서, API서버 check 컨트롤러에 요청을 하게 되면, 인가 처리에 실패하게 되고, HttpStatus.OK를 받지 못한다. 그래서 개인 페이지 입장이 아닌, 로그인 페이지로 이동한다.

개발 환경

  • SpringBoot(gradle) - 2.7.8
  • JWT - 3.19.2

인증과 인가의 차이

인증(Authentication)

  • 인증이란, 식별 가능한 정보를 통해서, 서비스의 등록 유저라는 것을 증명하는 것이다.
  • 예를 들어 서비스에 가입된 사용자에게만, 서비스를 제공하는 것이다.

인가(Authorization)

  • 인증만으로 서비스를 운영하기는 무리가 있다.
  • 예를 들어, A유저의 메일함을 인증 받은 모든 사용자가 이용할 수 있다면 문제가 발생한다.
  • 그래서 인가를 통해, 해당 자원에 권한이 있는지 확인하는 절차가 필요하다.

회원 가입 구현

Users Entity

IgnorantEnglish\API\src\main\java\hello\api\entity\Users.java

@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class Users {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String password;
    private String name;
    private String roles;

    public List<String> getRoleList() {
        if (this.roles.length() > 0) {
            return Arrays.asList(this.roles.split(","));
        }
        return new ArrayList<>();
    }
}

회원 가입 요청을 하면 Users 엔티티를 사용한다.

회원 가입 요청 처리

IgnorantEnglish\API\src\main\java\hello\api\controller\UserManageController.java

@RequiredArgsConstructor
@RestController
@Slf4j
@RequestMapping("/api/user/manage")
public class UserManageController {

    private final UserManageService userManageService;

    @PostMapping("/signup")
    public ResponseEntity signup(
        @Valid @RequestBody UserSignupRequest userSignupRequest) {
        userManageService.signup(userSignupRequest);
        return new ResponseEntity(HttpStatus.CREATED);
    }
}

IgnorantEnglish\API\src\main\java\hello\api\service\UserManageService.java

@Service
@RequiredArgsConstructor
@Slf4j
public class UserManageService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    public void signup(UserSignupRequest request) {
        if (userRepository.findByUsername(request.getUsername()).isPresent()) {
            throw new UserManageException(
                UserManageExceptionEnum.DUPLICATED_SIGNUP_EMAIL.getErrormessage());
        }
        Users users = Users.builder()
            .username(request.getUsername())
            .password(passwordEncoder.encode(request.getPassword()))
            .name(request.getName())
            .roles("ROLE_USER")
            .build();
        userRepository.save(users);
    }
}

회원 가입 요청이 들어오면 , findByUsername으로 먼저 중복된 아이디인지 확인을 하고, 중복된 회원 예외를 발생한다.

중복된 아이디가 아니라면, ROLE_USER 권한을 부여하고 DB에 저장한다.

추가로 비밀번호를 저장할 때, BCryptPasswordEncoder 인코딩 후 DB에 저장한다.

(UserRepository는 Users 엔티티로 만든 Spring data JPA이다.)

회원 가입 구현 테스트

password와 roles가 잘 설정되어 저장된 것을 볼 수 있다.

SpringSecurity JWT 로그인과 인가 처리

build.gradle

IgnorantEnglish\API\build.gradle

implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'com.auth0:java-jwt:3.19.2'

Spring Security 와 JWT 토큰을 쉽게 구현할 수 있는 라이브러리를 추가한다.

로그인 인증 기능 구현

JwtAuthenticationFilter

IgnorantEnglish\API\src\main\java\hello\api\jwt\JwtAuthenticationFilter.java

@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter{

	private final AuthenticationManager authenticationManager;
	
	@Override
	// Authentication 객체 만들어서 리턴해야 한다.(AuthenticationManager를 통해서)
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
			throws AuthenticationException {

		log.info("JwtAuthenticationFilter 로그인 : 진입");
		//로그인 요청 시 들어온 데이터를 객체로 변환
		ObjectMapper om = new ObjectMapper();
		UserLoginRequest userLoginRequest = null;
		try {
			userLoginRequest = om.readValue(request.getInputStream(), UserLoginRequest.class);
		} catch (Exception e) {
			e.printStackTrace();
		}

		//해당 객체로 로그인 시도를 위한 유저네임패스워드 authenticationToken 생성
		UsernamePasswordAuthenticationToken authenticationToken = 
				new UsernamePasswordAuthenticationToken(
					userLoginRequest.getUsername(),
					userLoginRequest.getPassword());
		

		// authenticate() 함수가 호출 되면 인증 프로바이더가 유저 디테일 서비스의
		// loadUserByUsername(토큰의 첫번째 파라미터) 를 호출하고
		// UserDetails를 리턴받아서 토큰의 두번째 파라메터(credential)과
		// UserDetails(DB값)의 getPassword()함수로 비교해서 동일하면
		// Authentication 객체를 만들어서 필터체인으로 리턴해준다.
		Authentication authentication =
			authenticationManager.authenticate(authenticationToken);
		// Tip: 인증 프로바이더의 디폴트 서비스는 UserDetailsService 타입
		// Tip: 인증 프로바이더의 디폴트 암호화 방식은 BCryptPasswordEncoder
		// 결론은 인증 프로바이더에게 알려줄 필요가 없음.

		//위 영역이 성공했다면, session영역에 authenticaion 객체가 저장된다->로그인이 성공
		return authentication;
	}

	@Override
	// 로그인 인증 성공하면 들어오는 메소드
	protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
			Authentication authResult) throws IOException, ServletException {
		//Authentication에 있는 정보로 JWT Token 생성해서 response에 담아주기
		PrincipalDetails principalDetailis = (PrincipalDetails) authResult.getPrincipal();
		String jwtToken = JWT.create()
				.withSubject(principalDetailis.getUsername())
				.withExpiresAt(new Date(System.currentTimeMillis()+JwtProperties.EXPIRATION_TIME))
				.withClaim("id", principalDetailis.getUser().getId())
				.withClaim("username", principalDetailis.getUser().getUsername())
				.sign(Algorithm.HMAC512(JwtProperties.SECRET));
		
		response.addHeader(JwtProperties.HEADER_STRING, JwtProperties.TOKEN_PREFIX+jwtToken);
	}
}

API/login url로 post 요청을 하게 되면, 해당 필터가 실행된다

authenticate() 함수 호출 시 사용되는 UserDetails 관련 코드는 깃 허브

IgnorantEnglish\API\src\main\java\hello\api\jwt\PrincipalDetails.java

IgnorantEnglish\API\src\main\java\hello\api\jwt\PrincipalDetails.java

에서 확인 할 수 있다.

로그인 인증 그리고 아래에서 설명할 JWT 토큰 생성 검증에 사용되는 값들은 해당 인터페이스에 저장하여 사용한다.

IgnorantEnglish\API\src\main\java\hello\api\jwt\JwtProperties.java

public interface JwtProperties {
	String SECRET = "ghkdeodud"; // 우리 서버만 알고 있는 비밀값
	int EXPIRATION_TIME = 864000000; // 10일 (1/1000초)
	String TOKEN_PREFIX = "Bearer ";
	String HEADER_STRING = "Authorization";
}

인가 기능 구현

@RequiredArgsConstructor
@RestController
@Slf4j
@RequestMapping("/api/user/manage")
public class UserManageController {
    
		@GetMapping("/check")
    public ResponseEntity check() {
        return new ResponseEntity(HttpStatus.OK);
    }
}

해당 컨트롤러에서 HttpStatus.OK 메세지를 받기 위해선, 올바른 JWT 토큰을 가지고 있어야 한다.

JWT 토큰을 헤더에 담아서 요청이 오면 어떻게 인가 처리를 하는 지 알아보자.

JwtAuthorizaionFilter

IgnorantEnglish\API\src\main\java\hello\api\jwt\JwtAuthorizationFilter.java

@Slf4j
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

	private UserRepository userRepository;

	public JwtAuthorizationFilter(AuthenticationManager authenticationManager, UserRepository userRepository) {
		super(authenticationManager);
		this.userRepository = userRepository;
	}

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		log.info("JwtAuthorizationFilter 인가 : 진입");

		//JWT 토큰이 헤더가 존재한다면, if문 안으로 들어가서 토큰 검증을 시작한다.
		String header = request.getHeader(JwtProperties.HEADER_STRING);
		if (header != null && header.startsWith(JwtProperties.TOKEN_PREFIX)) {
			System.out.println("header : " + header);
			String token = request.getHeader(JwtProperties.HEADER_STRING)
				.replace(JwtProperties.TOKEN_PREFIX, "");

			// 토큰 자체 검증
			// 토큰 자체에 인가 정보가 담겨 있기 때문에 AuthenticationManager가 필요 없다.
			//내가 가지고 있는 secret키를 사용해, JWT 토큰이 올바른 지 확인
			String username = JWT.require(Algorithm.HMAC512(JwtProperties.SECRET)).build().verify(token)
				.getClaim("username").asString();

			if (username != null) {
				Users user = userRepository.findByUsername(username).orElseThrow();

				// 인증은 토큰 검증시 끝. 인증을 하기 위해서가 아닌 스프링 시큐리티가 수행해주는 권한 처리를 위해
				// 아래와 같이 토큰을 만들어서 Authentication 객체를 강제로 만들고 그걸 세션에 저장하기!!
				PrincipalDetails principalDetails = new PrincipalDetails(user);
				Authentication authentication = new UsernamePasswordAuthenticationToken(
					principalDetails, // 나중에 컨트롤러에서 DI해서 쓸 때 사용하기 편함.
					null, // 패스워드는 모르니까 null 처리
					principalDetails.getAuthorities());

				// 강제로 시큐리티의 세션에 접근하여 값 저장
				//서명을 통해서 ahthentication을 객체를 만들어준다.
				SecurityContextHolder.getContext().setAuthentication(authentication);
			}
		}
		//다음 과정으로 넘어간다. 
		//JWT 토큰이 맞다면 Security 세션에 값이 있는 상태에서 로그인 상태로 다음 절차 진행
		//JWT 토큰 인증 실패했다면, Security 세션이 빈 상태로 다음 절차 진행 -> 에러 발생
		doFilter(request, response, chain);
	}
}

해당 필터는 권한 이나, 인증이 필요한 요청일 때 진행된다.

즉 위 check 컨트롤러에 요청을 하면 해당 필터가 실행되고, JWT 토큰 검사를 실시한다.

SecurityConfig에 등록하기

SecurityConfig

IgnorantEnglish\API\src\main\java\hello\api\webconfig\SecurityConfig.java

@Configuration
@EnableWebSecurity // 시큐리티 활성화 -> 기본 스프링 필터체인에 등록
public class SecurityConfig {

	@Autowired
	private UserRepository userRepository;

	@Bean
	CorsConfigurationSource corsConfigurationSource() {
		CorsConfiguration configuration = new CorsConfiguration();
		configuration.addAllowedOriginPattern("*");
		configuration.addAllowedHeader("*");
		configuration.addAllowedMethod("*");
		configuration.addExposedHeader("*");
		configuration.setAllowCredentials(true);
		UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
		source.registerCorsConfiguration("/**", configuration);
		return source;
	}

	@Bean
	SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		return http
				.csrf().disable()
				.cors()
				.and()
				.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
				.and()
				.formLogin().disable()
				.httpBasic().disable()
				.apply(new MyCustomDsl()) // 커스텀 필터 등록
				.and()
				.authorizeRequests(authroize -> authroize

					.antMatchers("/api/user/manage/check")
					.hasAnyRole("USER")
					.antMatchers("api/user/manage/information")
					.hasAnyRole("USER")

					.anyRequest().permitAll())
				.build();
	}

	public class MyCustomDsl extends AbstractHttpConfigurer<MyCustomDsl, HttpSecurity> {
		@Override
		public void configure(HttpSecurity http) throws Exception {
			AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
			http
					.addFilter(new JwtAuthenticationFilter(authenticationManager))
					.addFilter(new JwtAuthorizationFilter(authenticationManager, userRepository));
		}
	}
}

@EnableWebSecurity 을 통해 기본 스프링 필터체인에 시큐리티를 등록한다.

.authorizeRequests() 설정을 통해서, 인가 권한이 필요한 컨트롤러 주소를 입력한다.

 http
	.addFilter(new JwtAuthenticationFilter(authenticationManager))
	.addFilter(new JwtAuthorizationFilter(authenticationManager, userRepository));

를 통해서, 로그인 필터 그리고 인증 권한 필터를 Spring Security에 등록한다.