본문 바로가기

개발

[SpringBoot] JWT구현하기

build.gradle

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
	implementation 'org.springframework.boot:spring-boot-starter-data-redis'
	runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
	runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.mysql:mysql-connector-j'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
}

SecurityConfig 파일

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {

    private final JwtProvider jwtProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                // ID, Password 문자열을 Base64로 인코딩하여 전달하는 구조
                .httpBasic().disable()
                // 쿠키 기반이 아닌 JWT 기반이므로 사용하지 않음
                .csrf().disable()

Security Filter?

 SecurityFilterAutoConfiguration에서 DelegatingFilterProxyRegistrationBean을 만들고 여기서  DelegatingFilterProxy라는 필터를 만들어 준다.

DelegatingFilterProxy의 내부에 FilterChainProxy라는 위임대상을 가지고 있다. FilterChainProxy는 SpringSecurity에서 제공되는 특수 필터로 SpringSecurityFilterChain이라는 이름을 가진 Bean을 호출하여 SecurityFilter의 역할을 수행한다.

 

httpBasic.disable() → httpBasic방식 대신 Jwt를 사용하기 때문에 disable()

 

csrf.disable() → csrf(Cross site Request forgery) protection을 적용했을 때, html에서 csrf토큰이 포함되어야 요청을 받아들임으로써 위조 요청을 방지한다. 하지만 rest api를 이용한 서버라면, session 기반 인증과는 다르게 stateless하기 때문에 서버에 인증정보를 보관하지 않는다. rest api에서 client는 권한이 필요한 요청을 하기위해서는 요청에 필요한 인증 정보(jwt, OAuth)를 포함시켜야한다. 그래서 csrf를 disable()한다.

 

.cors().configurationSource(corsConfigurationSource())
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowCredentials(true);
        configuration.addAllowedOriginPattern("*");
//        configuration.addAllowedOrigin("https://lolonoa.site");
        configuration.addAllowedHeader("*");
        configuration.addAllowedMethod("*");

        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

 

cors →  브라우저에서 보안적인 이유로 HTTP 요청들을 제한한다. 그래서 cors 요청을 하려면 서버의 동의가 필요하다. 만약 서버가 동의하면 브라우저에서 요청을 허락하고, 동의하지않으면 브라우저에서 거절한다. CORS가 없이 모든 곳에서 데이터를 요청할 수 있으면, 다른 사이트에서 악의적으로 세션을 탈취하여 정보를 추출할 수 있다. 그래서 필요한 경우에만 서버와 협의하여 요청할 수 있도록 하기 위해서 필요하다. 여기서는 jwt방식을 사용할거라 모든 요청들을 허용해준다.

 

 

SessionCreationPolicy.STATELESS → 세션을 사용하지않고 Jwt를 사용하기 때문에 stateless로 설정한다. stateless로 설정 시 spring security는 세션을 사용하지않는다.

 

	.and()
                .authorizeRequests()
                .requestMatchers("/register", "/login", "/refresh").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .requestMatchers("/user/**").hasRole("USER")
                .anyRequest().denyAll()
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class)

 

authorizeRequest() → 요청 url별 인증 및 Role을 설정할 수 있다.

 

requestMatchers() → 특정한 경로를 지정한다.

      permitAll(), 모든 사용자가 접근할 수 있다. hasRole(), 시스템 상에서 특정 권한을 가진 사람만이 접근할 수 있다.

      denyAll(), 모든 사용자의 접근을 거부한다.

 

addFilterBefore() → 필터 추가 위치를 지정

 JwtAuthenticationFilter 는 응답 받은 JWT Token 을 올바른지 검증하고 올바르다면 Spring Security 인증 객체를 생성해서 SecurityContextHolder 에 저장한다.

 

 UsernamePasswordAuthenticationFilter 는 Spring Security 의 인증 처리 하는 대표적인 Filter다. 즉 입력된 ID/PASSWORD 를 통해 올바른 회원인지 체크 하는 단계다.

해당 소스는 UsernamePasswordAuthenticationFilter를 거치기 전에 자체적으로 만든 JwtAuthenticationFilter 클래스를 거치도록 하겠다는 의미다.

 

           .exceptionHandling()
           .accessDeniedHandler(new AccessDeniedHandler() {
               @Override
               public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
                   response.setStatus(403);
                   response.setCharacterEncoding("utf-8");
                   response.setContentType("text/html; charset=UTF-8");
                   response.getWriter().write("권한이 없는 사용자입니다.");
               }
           })

 

accessDeniedHandler → 서버에 요청을 할 때 액세스가 가능한지 권한을 체크한 후 액세스 할 수 없는 요청을 했을 시 동작한다.

 

.authenticationEntryPoint(new AuthenticationEntryPoint() {
               @Override
               public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
                    response.setStatus(401);
                    response.setCharacterEncoding("utf-8");
                    response.setContentType("text/html; charset=UTF-8");
                    response.getWriter().write("인증되지 않은 사용자입니다.");
                }
           });

    return http.build();
}

 

authenticationEntryPoint → 인증이 되지않은 유저가 요청을 했을 때 동작한다.


등록

@PostMapping(value = "/register")
    public ResponseEntity<Boolean> signup(@RequestBody SignRequest request) throws Exception {
        return new ResponseEntity<>(memberService.register(request), HttpStatus.OK);
    }
public boolean register(SignRequest request) throws Exception {
        try {
            Member member = Member.builder()
                    .account(request.getAccount())
                    .password(passwordEncoder.encode(request.getPassword()))
                    .nickname(request.getNickname())
                    .name(request.getName())
                    .email(request.getEmail())
                    .build();

            member.setRoles(Collections.singletonList(Authority.builder().name("ROLE_USER").build()));

            memberRepository.save(member);
        } catch (Exception e) {
            System.out.println(e.getMessage());
            throw new Exception("잘못된 요청입니다.");
        }
        return true;
    }
Collections.singletonList?

- 한 개의 객체만 있는 리스트 반환

- immutable (불변)

- 사이즈가 1로 고정됨 

- 값 및 구조 변경 시 UnsupportedOperationException 발생

 


로그인 

@PostMapping(value = "/login")
    public ResponseEntity<SignResponse> login(@RequestBody SignRequest request) throws Exception {
        return new ResponseEntity<>(memberService.login(request), HttpStatus.OK);
    }
   public SignResponse login(SignRequest request) throws Exception {
        Member member = memberRepository.findByAccount(request.getAccount()).orElseThrow(() ->
                new BadCredentialsException("잘못된 계정정보입니다."));

        if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) {
            throw new BadCredentialsException("잘못된 계정정보입니다.");
        }

        member.setRefreshToken(createRefreshToken(member));

        return SignResponse.builder()
                .id(member.getId())
                .account(member.getAccount())
                .name(member.getName())
                .email(member.getEmail())
                .nickname(member.getNickname())
                .roles(member.getRoles())
                .token(TokenDto.builder()
                        .access_token(jwtProvider.createToken(member.getAccount(), member.getRoles()))
                        .refresh_token(member.getRefreshToken())
                        .build())
                .build();
    }
// Access 토큰 생성
    public String createToken(String account, List<Authority> roles) {
        Claims claims = Jwts.claims().setSubject(account);
        claims.put("roles", roles);
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + exp))
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();
    }
public String createRefreshToken(Member member){
        Token token = tokenRepository.save(
                Token.builder()
                        .id(member.getId())
                        .refresh_token(UUID.randomUUID().toString())
                        .expiration(60*60*24*14) // 14일
                        .build()
        );
        return token.getRefresh_token();
    }

액세스 토큰과 다르게 리프레시 토큰은 jwt가 아닌 UUID형식으로 만든다.

Token은 @RedisHash를 사용해서 redis에 저장된다.

 

 

포스트맨 로그인 시

 

redis에 저장된 토큰

"refresh:3" &rarr; 아까 로그인한 멤버의 id가 3이여서 refresh:3으로 키 값이 설정된다. 자세히보면 시간과 리프레시 토큰값을 볼 수 있다.


리프레시

@GetMapping("/refresh")
    public ResponseEntity<TokenDto> refresh(@RequestBody TokenDto token) throws Exception {
        return new ResponseEntity<>( memberService.refreshAccessToken(token), HttpStatus.OK);
    }
public TokenDto refreshAccessToken(TokenDto token) throws Exception {
        String account = jwtProvider.getAccount(token.getAccess_token());
        Member member = memberRepository.findByAccount(account).orElseThrow(() ->
                new BadCredentialsException("잘못된 계정정보입니다."));
        Token refreshToken = validRefreshToken(member, token.getRefresh_token());

        if (refreshToken != null) {

            /*리프레시 토큰 값 변경*/
            Token findToken = tokenRepository.findById(member.getId()).get();
            findToken.setRefresh_token(UUID.randomUUID().toString());
            findToken.setExpiration(60*60*2);
            tokenRepository.save(findToken);

            return TokenDto.builder()
                    .access_token(jwtProvider.createToken(account, member.getRoles()))
                    .refresh_token(findToken.getRefresh_token())
                    .build();
        } else {
            throw new Exception("로그인을 해주세요");
        }
    }

리프레시 토큰의 경우, 액세스 토큰을 재발급 받으면 보안을 위해 리프레시 토큰도 재발행되도록 만들었다. 테스트를 위해 액세스 토큰은 1분, 리프레시 토큰의 시간은 2분으로 설정했다.