Framework/Spring

[SpringSecurity] JWT (Json Web Token) 기반의 인증 인가 구현하기

MINGYUM 2023. 7. 9. 14:22

JWT란 ? 

Json web token의 줄임말로, JSON 포맷으로 인증 정보를 저장하는 Claims 기반의 Web Token이다.

구조는 Header, Payload, Signature로 이루어져있으며 각각의 특징은 아래와 같다. 

(1) Header : 타입을 저장하는 typ, HS256과 같이 문자열을 해싱하는 알고리즘을 저장하는 alg로 이루어져 있다. 
(2) Payload : 정보의 단위인 Claim이 저장되며, 토큰의 발급자, 제목, 만료 시간 등의 정보가 Claim 단위로 저장된다.
(3) Signature : 토큰 암호화 코드가 저장된다.

Token  기반의 인증은 Session 인증과 달리 서버에 로그인 요청 시 헤더에 토큰을 포함하여 함께 보내고, 서버가 아닌 클라이언트에 저장되어 URL 요청마다 유효성 검사를 수행하는 특징이 있다. 클라이언트에서 토큰을 저장하는 것을 "Statless"라고 표현한다.

서버에 인증 정보를 저장하지 않기 때문에 부하가 적고, 분산 처리와 같이 서버를 클라이언트와 분리해야하는 상황에서 클라이언트와 서버 간의 의존도가 낮기 때문에 확장성(Scalability)가 높다고 할 수 있다. 

추가로, 쿠키 사용으로 인한 취약점이 낮아지는 보안 관련 장점과 도메인이나 플랫폼의 확장으로 CORS 문제를 해결하는 등의 장점이 있다. 

 

JWT는 인가(Authorization)을 위한 도구이며, 이 도구를 사용하는 주체는 SpringSecurity이다. 

Spring Security는 아래 두가지 역할을 한다.

  • 인증 (Authentication) : 회원가입/로그인과 같이 현재 서비스를 사용 중인 유저가 누구인지 확인하는 과정
  • 인가 (Authorization) : 확인된 유저에 대해서 특정 URL에 대한 접근이 가능한지 검사하는 과정

 

JWT를 이용한 인증 인가 구현하기

 

(1) 의존성 추가

    implementation 'io.jsonwebtoken:jjwt:0.9.1'
    implementation 'org.springframework.boot:spring-boot-starter-security'

JWT 사용을 위한 jjwt모듈과 인증 인가를 위한 Spring Security 모듈을 추가해준다.

 

(2) Spring Security Config 추가

config 폴더에 아래와 같이 SecurityConfig 클래스를 생성한다. 

이 클래스는 모든 URL 요청에 대해 JWT 인증 없이 접근을 허용할 지, 아니면 JWT 인증을 거쳐야만 접근이 가능할 지 분리해주는 역할을 한다. 기존에는 WebSecurityConfigurerAdapter 클래스를 상속하여 구현하였지만 공식문서를 참고해보면, 스프링 5.4부터 HttpSecurity를 사용한 filterChain을 구현하는 방식으로 변경되었다.

 

따라서 @Configuration으로 설정 파일 등록 후 @EnableWebSecurity로 Security 관련 설정 파일을 명시한 후 filterChain 메소드를 오버라이딩 하여 구현한다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final MemberService memberService;

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

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .httpBasic().disable()
                .csrf().disable()
                .cors().and()
                .authorizeHttpRequests(request -> request
                        .requestMatchers("/api/v1/members/join").permitAll()
                        .requestMatchers("/api/v1/members/login").permitAll()
                        .requestMatchers("/api/v1/members/join/confirm").permitAll()
                        .requestMatchers("/api/v1/members/join/verify").permitAll()
                        .anyRequest().authenticated()
                )

                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .addFilterBefore(new JwtFilter(memberService, secretKey), UsernamePasswordAuthenticationFilter.class)
                .build();
    }
}

JWT 인증 없이 허용할 URL(회원가입, 로그인 .. ) 을 requestMatchers().permitAll()로 설정하고 그 이외의 URL은 허용하지 않기 위해 anyRequest().authenticated() 코드를 추가한다.

 

이 때, @Value로 지정한 secretKey는 application.yml에서 아래와 같이 작동한다. 토큰을 생성할 때 중요한 키가 되기 때문에, 토큰 값이 탈취되는 경우를 막기 위해 설정한다.

jwt:
  token:
    secret: "secret"

 

(3) JwtUtil 추가

토큰을 생성하고 유효시간의 만료 여부를 체크하는 기능을 하는 util 클래스를 작성한다. 

추가로, 토큰에서 Claims에 담긴 사용자의 mail 정보를 추출하는 메소드도 작성하여 인증 시 사용한다. 

public class JwtTokenUtil {

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

    @Value("${jwt.token.expiration}")
    private Long expiration;

    public static String getMail(String token, String secretKey){
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token)
                .getBody().get("mail", String.class);
    }

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

    public static String createToken(String mail, String key, long expireTimeMs) {
        Claims claims = Jwts.claims();
        claims.put("mail", mail);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + expireTimeMs))
                .signWith(SignatureAlgorithm.HS256, key)
                .compact();
    }
}

 

(4) MemberController, MemberService 생성

MemberController

    @PostMapping("/login")
    @ResponseBody
    public ResponseEntity<String> login(@RequestBody LoginDTO loginDTO) {
        return ResponseEntity.ok(memberService.login(loginDTO));
    }

사용자의 이메일과 비밀번호를 받는 loginDTO를 구현하여, @RequestBody로 클라이언트로부터 요청을 받도록 설정한다. 

 

MemberService

    public String login(LoginDTO loginDTO) {
        Member member = memberRepository.findByMail(loginDTO.getMail())
                .orElseThrow(() -> new AppException(ErrorCode.MEMBER_NOT_FOUND));

        if (bCryptPasswordEncoder.matches(loginDTO.getPassword(), member.getPassword())) {
            String token = JwtTokenUtil.createToken(member.getMail(), key, expireTimeMs);
            return token;
        } else {
            throw new AppException(ErrorCode.INVALID_PASSWORD);
        }
    }

서비스 단에서 로그인이 성공하면 (입력한 이메일이 존재하며 비밀번호가 일치하는 경우) createToken 메소드를 사용해 토큰을 생성해 반환한다.

 

(4) JwtFilter 추가

@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    private final MemberService memberService;

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

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        final String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
        log.info("----authorization : {}", authorization);

        if(authorization == null || !authorization.startsWith("Bearer ")){
            log.error("authorization is wrong");
            filterChain.doFilter(request, response);
            return;
        }

        String token = authorization.split(" ")[1];
        if(JwtTokenUtil.isExpired(token, secretKey)){
            log.error("Token is expired");
            filterChain.doFilter(request, response);
            return ;
        }

       String mail = JwtTokenUtil.getMail(token, secretKey);

        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(mail, null, List.of(new SimpleGrantedAuthority("USER")));
        authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        filterChain.doFilter(request, response);
    }
}

헤더에 포함된 토큰을 가져와 인증을 처리하고 있다.

 

이 과정을 통해, 로그인 후 생성된 토큰으로 permitAll() 처리되지 않은 URL에 접근할 때 JWT 유효성 검사를 실시하게 된다. 만약 토큰이 없는 사용자가 접근한 경우 접근을 막을 수 있다. 

Postman 테스트

이렇게 미리 넣어둔 회원 정보로 로그인을 시도하면 JWT 토큰이 반환된다. 

 

그리고 JWT 인증이 있어야만 접근 가능한 URL을 입력 후 Authorization 탭에 들어가 Bearer Token 타입으로 설정한 뒤, 로그인 시 반환된 JWT 토큰을 복사해서 붙여 넣는다.

헤더에 수동으로 추가하는 경우에는 Bearer eyJhbGci .. 이런 식으로 입력하여야 JWT 토큰이 인식된다.

그리고 Send 버튼을 누르면 회원을 찾는 API의 결과가 정상적으로 호출된 것을 확인할 수 있다.