스프링부트 JWT 인증

NSW2500·2023년 8월 5일


스프링 부트 프로젝트를 만들어준다.
자바 버전: 11
스프링 부트 버전: 2.7.14


JWT에 대해 정리를 해보자면,

1. 클라이언트가 username과 password로 로그인을 한다.
2. Application에서 토큰을 발행한다.
3. 클라이언트가 어떠한 요청을 보낼 때, 토큰과 함께 보낸다.
4. Application에서는 요청을 처리하기 전, 리소스에 대해 접근 권한이 있는 토큰인지를 확인하고 요청을 처리할지 말지 결정한다.

Access Token (액세스 토큰):

Access Token은 사용자의 인증이 성공적으로 이루어진 후에 발급되는 토큰으로, 
애플리케이션이 사용자를 대신하여 API에 접근하는 데 사용된다.
사용자의 권한과 인증 정보를 담고 있으며, 보통 짧은 유효 기간을 가진다. 
(예: 몇 분, 몇 시간. 필자의 코드에서는 10분으로 설정)
API를 호출할 때, 클라이언트는 발급받은 Access Token을 사용하여 
보호된 리소스에 접근하며 API 요청에 인증 정보를 제공한다.

Refresh Token (리프레시 토큰):

Refresh Token은 Access Token을 갱신하기 위해 사용되는 토큰으로, 
Access Token의 유효 기간이 만료되면 Refresh Token을 사용하여 
새로운 Access Token을 얻을 수 있다. 
보통 Access Token보다 더 긴 유효 기간을 가진다. 
(예: 몇 주, 몇 달. 필자는 30분으로 설정) 
Refresh Token을 사용하여 Access Token을 갱신하면, 
사용자는 다시 로그인하지 않아도 되고 
애플리케이션이 지속적으로 사용자의 권한을 유지할 수 있다.
사용자가 로그아웃하거나 Refresh Token의 유효 기간이 만료되면 다시 로그인해야 한다.

Token Refresh

@PostMapping("/token/refresh")
    public void refreshToken(HttpServletRequest request, HttpServletResponse response) throws IOException {
        String authorizationHeader = request.getHeader(AUTHORIZATION);
        if(authorizationHeader != null && authorizationHeader.startsWith("Bearer ")){
            try{
                String refresh_token = authorizationHeader.substring("Bearer ".length());
                Algorithm algorithm = Algorithm.HMAC256("secret".getBytes());
                JWTVerifier verifier = JWT.require(algorithm).build();
                DecodedJWT decodedJWT = verifier.verify(refresh_token);
                String email = decodedJWT.getSubject();
                Member member = memberService.getMember(email);
                //accessToken 생성
                String access_token= JWT.create()
                        .withSubject(member.getEmailAccount())
                        .withExpiresAt(new Date(System.currentTimeMillis() + 10*60*1000))
                        .withIssuer(request.getRequestURL().toString())
                        .withClaim("roles", member.getRoles().stream().map(Role::getName).collect(Collectors.toList()))
                        .sign(algorithm);
                Map<String, String> tokens = new HashMap<>();
                tokens.put("access_token",access_token);
                tokens.put("refresh_token",refresh_token);
                response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                new ObjectMapper().writeValue(response.getOutputStream(), tokens);
            }catch (Exception exception){
                response.setHeader("error", exception.getMessage());
                response.setStatus(FORBIDDEN.value());
                //response.sendError(FORBIDDEN.value());
                Map<String, String> error = new HashMap<>();
                error.put("error_message ", exception.getMessage());
                response.setContentType(MimeTypeUtils.APPLICATION_JSON_VALUE);
                new ObjectMapper().writeValue(response.getOutputStream(), error);
            }
        }else {
            throw new RuntimeException("Refresh token is missing");
        }
    }

CustomAuthenticationFilter

@Slf4j
public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
	//AuthenticationManager는 Spring Security에서 인증을 처리하는 핵심 인터페이스
    private final AuthenticationManager authenticationManager;
    public CustomAuthenticationFilter(AuthenticationManager authenticationManager){
        this.authenticationManager = authenticationManager;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        String email = request.getParameter("email");
        String password = request.getParameter("password");
        log.info("Email is: {}",email); log.info("Password is: {}",password);
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(email, password);
        return authenticationManager.authenticate(authenticationToken);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
        User user = (User)authentication.getPrincipal();
        //토큰을 만들기 위해 사용한 암호화 기법: HMAC256
        Algorithm algorithm = Algorithm.HMAC256("secret".getBytes());
        String access_token= JWT.create()
                .withSubject(user.getUsername())
                .withExpiresAt(new Date(System.currentTimeMillis() + 10*60*1000))
                .withIssuer(request.getRequestURL().toString())
                .withClaim("roles", user.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList()))
                .sign(algorithm);
        String refresh_token= JWT.create()
                .withSubject(user.getUsername())
                .withExpiresAt(new Date(System.currentTimeMillis() + 30*60*1000))
                .withIssuer(request.getRequestURL().toString())
                 .sign(algorithm);
        /*
        response.setHeader("access_token",access_token);
        response.setHeader("refresh_token",refresh_token);*/

        Map<String, String> tokens = new HashMap<>();
        tokens.put("accessToken",access_token);
        tokens.put("refreshToken",refresh_token);
        response.setContentType(APPLICATION_JSON_VALUE);
        new ObjectMapper().writeValue(response.getOutputStream(), tokens);
    }
}

사용자 인증 단계:

1. 사용자가 로그인 등의 인증을 시도하면, 인증 요청이 AuthenticationManager에 전달됨
2. AuthenticationManager는 전달받은 인증 요청을 처리하고, 해당 요청에 대한 인증 처리를 담당할 AuthenticationProvider들을 순차적으로 호출
3. 각각의 AuthenticationProvider는 실제로 사용자 인증을 처리하는 역할을 하며, 데이터베이스나 LDAP, OAuth 등을 통해 사용자 정보를 확인하고 사용자의 인증 여부를 판단
4. AuthenticationProvider는 인증에 성공한 경우 Authentication 객체를 반환하며, 이는 사용자의 인증 정보와 권한 정보를 포함
5. AuthenticationManager는 Authentication 객체를 받아서 사용자가 인증되었다는 것을 알 수 있으며, 이후의 인증과정에서 사용자의 권한 부여를 적용
6. 인증에 성공한 후, Authentication 객체는 SecurityContextHolder에 저장됨 (SecurityContextHolder는 Spring Security에서 인증 정보를 보관하는 역할을 함)
7. 이후 보호된 리소스에 접근하는 요청이 올 때, SecurityContextHolder에서 인증 정보를 확인하여 사용자의 권한을 검사하고 리소스에 접근을 허용하거나 거부

HMAC256을 사용하여 토큰을 생성하고 검증

토큰 생성:

1. 서버는 사용자 정보와 비밀 키를 조합하여 HMAC256으로 인증 코드를 생성
2. 이 인증 코드를 토큰에 추가하여 최종적인 토큰을 생성
3. 토큰은 클라이언트에게 전달되어 사용자 인증 등에 사용됨

토큰 검증:

1. 클라이언트가 서버에 토큰을 제출
2. 서버는 토큰에서 사용자 정보와 인증 코드를 추출
3. 서버는 같은 비밀 키를 사용하여 토큰에서 생성된 HMAC256 인증 코드를 다시 계산
4. 토큰에 있는 인증 코드와 새로 계산된 인증 코드를 비교하여 토큰의 무결성을 검증
5. HMAC256을 사용하여 토큰을 생성하고 검증함으로써 토큰의 안전성과 무결성을 보장할 수 있으며, 인증 정보의 안전한 전송과 확인에 도움이 됨

CustomAuthorizationFilter

@Slf4j
public class CustomAuthorizationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    	//로그인할 때에는 접근 권한이 있는지 확인할 필요가 없다. 
        if(request.getServletPath().equals("/api/login") || request.getServletPath().equals("/api/token/refresh")){
            filterChain.doFilter(request, response);
        } else {
            String authorizationHeader = request.getHeader(AUTHORIZATION);
            //'Bearer 토큰' 형태
            if(authorizationHeader != null && authorizationHeader.startsWith("Bearer ")){
            	//접근 권한이 있는 토큰인지 검증
                try{
                    String token = authorizationHeader.substring("Bearer ".length());
                    Algorithm algorithm = Algorithm.HMAC256("secret".getBytes());
                    JWTVerifier verifier = JWT.require(algorithm).build();
                    DecodedJWT decodedJWT = verifier.verify(token);
                    String email = decodedJWT.getSubject();
                    String[] roles = decodedJWT.getClaim("roles").asArray(String.class);
                    Collection<SimpleGrantedAuthority> authorities = new ArrayList<>();
                    stream(roles).forEach(role -> {
                            authorities.add(new SimpleGrantedAuthority(role));
                    });
                    UsernamePasswordAuthenticationToken authenticationToken =
                            new UsernamePasswordAuthenticationToken(email, null, authorities);
                    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
                    filterChain.doFilter(request, response);
                }catch (Exception exception){
                    log.error("Error logging in: {}", exception.getMessage());
                    response.setHeader("error", exception.getMessage());
                    response.setStatus(FORBIDDEN.value());
                    //response.sendError(FORBIDDEN.value());
                    Map<String, String> error = new HashMap<>();
                    error.put("error_message ", exception.getMessage());
                    response.setContentType(APPLICATION_JSON_VALUE);
                    new ObjectMapper().writeValue(response.getOutputStream(), error);
                }
            }else {
                filterChain.doFilter(request, response);
            }
        }
    }
}

토큰 없이 모든 사용자에 대한 정보를 가져오고자 할 때 접근 권한이 없어 403 Forbidden이 나오는 것을 확인할 수 있다.

SecurityConfig

@Configuration @EnableWebSecurity @RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	//spring security에서 제공되는 UserDetailsService
    private final UserDetailsService userDetailsService;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        CustomAuthenticationFilter customAuthenticationFilter = new CustomAuthenticationFilter(authenticationManagerBean());
        customAuthenticationFilter.setFilterProcessesUrl("/api/login");
        http.csrf().disable(); //Spring Security에서 CSRF 공격 방지 기능을 비활성화
        http.sessionManagement().sessionCreationPolicy(STATELESS);
        //spring security에 의해 막히지 않도록 허용할 url들을 작성해준다
        http.authorizeRequests().antMatchers( "/api/user/save","/api/login/**","/api/token/refresh/**", "/api/email",
                "/v2/api-docs",
                "/swagger-resources/**",
                "/swagger-ui.html",
                "/swagger-ui/**",
                "/webjars/**" ,
                /*Probably not needed*/ "/swagger.json").permitAll();
        http.authorizeRequests().antMatchers(GET, "/api/user/**").hasAnyAuthority("ROLE_USER");
        http.authorizeRequests().antMatchers(POST, "/api/user/save").hasAnyAuthority("ROLE_USER");
        //http.authorizeRequests().antMatchers(POST, "/api/user/save/**").hasAnyAuthority("ROLE_ADMIN");
        http.authorizeRequests().anyRequest().authenticated();
        http.addFilter(customAuthenticationFilter);
        http.addFilterBefore(new CustomAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class);
    }

    @Bean @Override
    public AuthenticationManager authenticationManagerBean() throws Exception{
        return super.authenticationManagerBean();
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/static/css/**, /static/js/**, *.ico");
        // swagger
        web.ignoring().antMatchers(
                "/v3/api-docs",  "/configuration/ui",
                "/swagger-resources", "/configuration/security",
                "/swagger-ui.html", "/webjars/**","/swagger/**");
    }
}
@SpringBootApplication
@EnableSwagger2
public class IntermissionApplication {

	public static void main(String[] args) {
		SpringApplication.run(IntermissionApplication.class, args);
	}

//passwordEncoder bean 등록
	@Bean
	PasswordEncoder passwordEncoder(){
		return new BCryptPasswordEncoder();
	}
 }

MemberService

@Service @RequiredArgsConstructor @Transactional @Slf4j
public class MemberServiceImpl implements MemberService, UserDetailsService {
    private final MemberRepo memberRepo;
    private final RoleRepo roleRepo;
    private final PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String emailAccount) throws UsernameNotFoundException {
        Member member = memberRepo.findByEmailAccount(emailAccount);
        if(member == null){
        	//사용자 정보가 없을 경우
            log.error("User not found in the database");
            throw new UsernameNotFoundException("User not found in the database");
        } else{
        	//사용자 정보가 있을 경우
            log.info("User found in the database: {}", emailAccount);
        }
        //사용자가 가지고 있는 Role을 전부 SimpleGrantedAuthority에 전달한다. 
        //SimpleGrantedAuthority는 Spring Security에서 사용되는 클래스로, 사용자의 권한 정보를 담는다.
        Collection<SimpleGrantedAuthority> authorities = new ArrayList<>();
        member.getRoles().forEach(role -> {
            authorities.add(new SimpleGrantedAuthority(role.getName()));
        });
        return new org.springframework.security.core.userdetails.User(member.getEmailAccount(),member.getPassword(),authorities);
    }
 }

포스트맨을 사용하여 accessToken과 refreshToken이 잘 반환되는 것을 확인할 수 있었다.

accessToken을 decode한 결과

새로운 토큰 발급도 잘 되는 것을 확인할 수 있다.

2개의 댓글

comment-user-thumbnail
2023년 8월 5일

좋은 글이네요. 공유해주셔서 감사합니다.

1개의 답글