[Spring Security] 버전 업 된 스프링 시큐리티에서 JWT 인증 인가 구현하기.

박제현·2023년 10월 17일
2
post-thumbnail

수 많은 강의자료와, 유튜브 영상들을 보면서..
똑같이 코드를 작성해도 스프링 시큐리티와 jjwt 의 버전 업이 진행됨에 따라 수많은, 여러가지 오류들이 발생했다.

이 글은 https://www.youtube.com/watch?v=DCKE-bWYFxg 해당 강의 영상을 토대로 작성하였다.

그러한 오류들을 해결하고, 드디어 jwt 발행에 성공했다.
나처럼 비슷한 고난을 겪고 있을 사람들에게 도움이 되고자, 그리고 내 머리속에 남기고자 글을 남긴다.

1. 환경 설정 및 토큰 발행

build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
    implementation 'org.springframework.boot:spring-boot-starter-security'

    compileOnly 'org.projectlombok:lombok'

    runtimeOnly 'com.mysql:mysql-connector-j'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'

    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

우선, 위와 같이 의존성을 추가한다.
유의할 점은 jjwt 의 가장 최신 버전이 0.12.3 버전의 api를 사용한 다는 것.
이는 jjwt 깃헙의 installation을 보고 의존성을 추가했다.
https://github.com/jwtk/jjwt#quickstart

WebSecurityConfig.java

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {

        return httpSecurity
                .httpBasic(AbstractHttpConfigurer::disable)
                .csrf(AbstractHttpConfigurer::disable)
                .cors(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(requests -> {
                    requests.requestMatchers("/api/users/login", "/api/users/join").permitAll();
                    requests.requestMatchers(HttpMethod.POST, "/api/articles").authenticated();
                })
                .sessionManagement(
                        sessionManagement ->
                                sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .build();
    }

}

SecurityFilterChain 이 가장 많은 에러를 아마 뿜어 냈으리라 생각한다.
SpringSecurity 가 버전 업 되면서, 기존 코드들과 변한점이 많다.
그것도 미세하게...
아무튼 보통 httpBasic, csrf, cors 를 disable 시킬 텐데, 저런식으로 코드를 작성해야 에러가 발생하지 않는다.

JwtUtil.java

 public class JwtUtil {
    public static String createJwt(String userName, String secretKey, Long expiredMs){
//        Claims claims = Jwts.claims();
//        claims.put("userName", userName);
>
        return Jwts.builder()
                .claim("userName", userName)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + expiredMs))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }
}

jjwt 의 버전이 업데이트 됨에 따라, 더 이상 Claims를 따로 생성하지 않는 듯 하다..
(Claims claims = Jwts.claims(); 에서 오류가 발생했다.)

공식 문서의 가이드를 참고하여

위 코드와 같이 claim을 추가해준다.

UserService.java

@Service
public class UserService {

    @Value("${jwt.secret}")
    private String secretKey;

    private Long expiredMs = 1000 * 60 * 60L;
    public String login(String userName, String password) {

        return JwtUtil.createJwt(userName, secretKey, expiredMs);
    }
}

서비스로 사용할 UserService 클래스는 강의 영상과 동일하게 작성해도 무방했다.
반드시 기억해야할 점은 secretKey를 절대로 절대로 공개해서는 안된다는 점!!
그렇기에 application.yaml에 선언해준다.

너무 짧은 키를 선언하면

io.jsonwebtoken.security.WeakKeyException: The signing key's size is 96 bits which is not secure enough for the HS256 algorithm.
해당 에러를 보게 될 테니 충분히 긴 키를 선언하도록 하자.

또한, 특수문자가 포함되면

io.jsonwebtoken.io.DecodingException: Illegal base64 character: '!'
에러를 뿜뿜하니.. 이것도 빼자

JWT 발행


모든 오류들을 방지하면, 비로소 jwt 를 발행 받을 수 있다!!

2. JwtFilter 로 인증 계층 추가하기.

WebSecurityConfig.java

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {

    private final UserService userService;

    @Value("${jwt.secret}")
    private String secretKey;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {

        return httpSecurity
                .httpBasic(AbstractHttpConfigurer::disable)
                .csrf(AbstractHttpConfigurer::disable)
                .cors(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(requests -> {
                    requests.requestMatchers("/api/users/login", "/api/users/join").permitAll();
                    requests.requestMatchers(HttpMethod.POST, "/api/**").authenticated();
                })
                .sessionManagement(
                        sessionManagement ->
                                sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .addFilterBefore(new JwtFilter(userService, secretKey), UsernamePasswordAuthenticationFilter.class)
                .build();
    }

}

/api/users/login, /api/users/join 을 제외한 모든 /api/** 요청은 발행된 토큰의 인증이 이루어진 경우에만 접근 할 수 있도록 변경한다.

그리고, addFilterBefore 를 추가하여, 우리가 원하는 jwt 필터를 적용함으로써, 유효 토큰인지 확인한다.

토큰이 없거나, 유효하지 않은 경우 아래와 같이 요정이 정상적으로 제한된다.

헤더에 발급 받은 토큰을 넣어 보내면, 아래와 같이 정상적으로 접근이 가능한 걸 확인 할 수 있다.

JwtFilter.java

@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    private final UserService userService;
    private final String secretKey;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        final String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);

        logger.info("authorization = " + authorization);

        if(authorization == null || !authorization.startsWith("Bearer ")){
            logger.error("authorization 이 없습니다.");
            filterChain.doFilter(request, response);
            return;
        }

        // Token 꺼내기
        String token = authorization.split(" ")[1];

        // Token Expired 되었는지 여부
        if(JwtUtil.isExpired(token, secretKey)){
            logger.error("Token 이 만료되었습니다.");
            filterChain.doFilter(request, response);
            return;
        }

        // UserName Token에서 꺼내기
        String userName = "";

        // 권한 부여
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(userName, null, List.of(new SimpleGrantedAuthority("USER")));

        // Detail을 넣어준다.
        authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        filterChain.doFilter(request, response);

    }
}

요청이 있을 때마다 동작하는 필터를 만들기 위해서 OncePerRequestFilter 를 상속받는다.

내부적으로 동작할 doFilterInternal 메소드를 위 코드와 같이 오버라이드한다.

요청의 헤더에서 AUTHORIZATION 을 받아와 해당 authorization 이 토큰의 시작인 Bearer 로 시작하는지 판단하고, 해당하지 않는 경우 에러를 발생시킨다.

또는, 토큰의 만료시간이 지난 경우 역시 에러를 발생 시킨다.

모든 경우에 만족을 하는 경우 토큰에서 userName을 꺼내와 할당하고, 새로운 권한과 디테일들을 넣어준 뒤 SecurityContextHoldercontext 에 디테일들이 추가된 authentication 값을 설정해준다.

JwtUtil.java

public class JwtUtil {
    public static String createJwt(String userName, String secretKey, Long expiredMs) {

        return Jwts.builder()
                .claim("userName", userName)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + expiredMs))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

    }

    public static boolean isExpired(String token, String secretKey) {
        return Jwts.parser()
                .setSigningKey(secretKey)
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getExpiration()
                .before(new Date());
    }
}

해당 토큰의 유효기간을 검사하는 isExpired(String token, String secretKey) 메소드를 추가한다.

JwtParserBuilder 를 이용하여 동일한 암호 키로 빌드하여, 해당 JwtParser 에서 토큰의 claim 들을 받아와 해당 토큰의 유효기간을 현재 시간과 비교하여 결과 값을 리턴해준다.

profile
닷넷 새싹

0개의 댓글