반응형

JWT란?

  • JSON Web Token의 줄임말로 JSON 객체로 정보를 주고 받을 때, 안전하게 전송하기 위한 방식
  • HMAC, RSA 등의 암호화 방식을 사용해 서명함
  • 로그인 기능 구현 등에 사용

JWT 구조

  • JWT는 Header, Payload, Signature로 이루어져 있으며 각각 점으로 구분
    • ex) xxxxxxx.yyyyyyy.zzzzzzzzz

Header

  • Header은 일반적으로 토큰 유형(JWT)과 사용중인 서명 알고리즘(HMAC, SHA256 등)이 포함됨
  • 예시
{
    "typ": "JWT",
    "alg": "HS256"
}

Payload

  • Payload는 등록된 클레임과 개인 클레임 등으로 이루어짐
    • 등록된 클레임 : iss(발행자), exp(만료시간), sub(제목), aud(대상) 등이 있음 => 권장되긴 하지만 필수는 아님
    • 개인 클레임 : 서로 정보를 공유하기 위해 생성된 사용자 지정 클레임 => 원하는 정보들을 넣으면 됨
  • 예시
{
    // 등록된 클레임
    "iss": "chb2005.tistory.com",
    "sub": "123456789",
    "exp": "1659002265",
    // 개인 클레임
    "userName": "changbum",
    "isAdmin": false
}

Signature

  • Signature은 Header, Payload, Secret Key를 합쳐 암호화한 결과값
    • HS256( base64UrlEncode(header) + "." + base64UrlEncode(payload), Secret key)

JWT 인증 진행 방식

  • https://jwt.io 이 사이트를 이용해 인증 진행 방식을 정리해 봄

  • 오른쪽이 Encoding값, 왼쪽이 Decoding 값
  • Header와 Payload 값은 Decoding을 통해 누구나 정보를 알아낼 수 있음
  • 하지만 Signatur 값은 우리가 지정한 비밀키(ex) my-secret-key-123123)를 알아야만 구할 수 있음

예시

  • 유저 A가 B 사이트에 로그인 하는 상황
  1. A가 id, password를 B 서버에 전송
  2. B 서버가 DB에서 확인 후 id, password가 맞다면 B만 아는 비밀키를 사용해 토큰을 만들어 A에게 전송해 줌
  3. A는 이 토큰을 들고있다가 다른 요청시 이 토큰을 헤더에 담아서 보내줌
    • ex) 발급받은 Jwt Token이 'xxxx.yyyy.zzzzz'라면
    • A가 B에 요청 전송시 Request Header의 'Authorization'에 'Bearer xxxx.yyyy.zzzzz'를 담아 전송
  4. 요청과 토큰을 받은 B는 토큰을 통해 사용자를 인증하고 요청에 대한 응답을 진행
  5. 만약 유저 A가 Payload에 유저 정보를 바꿔 다른 유저인 것 처럼 접근하려 해도 비밀키를 모르기 때문에 정확한 Signature을 만들 수 없음

JWT의 장단점

장점

  • 서버는 비밀키만 알고 있으면 되기 때문에 세션 방식과 같이 별도의 인증 저장소가 필요하지 않음 => 서버측 부하 감소
  • 여러개의 서버를 사용하는 대형 서비스 같은 경우에 접근 권한 관리가 매우 효율적임 => 확장성이 좋음
  • Refresh Token까지 활용한다면 더 높은 보안성을 가질 수 있음

단점

  • Payload의 정보(Claim)가 많아질 수록 토큰이 커짐
  • 중요한 데이터는 넣을 수 없음
  • 토큰 자체를 탈취당하면 대처가 어려움
  • 로그아웃 시 JWT 방식은 세션이 없는 stateless 방식이기 때문에 토큰 관리가 어려움

JWT Token 로그인 구현 예제

라이브러리 추가

  • Spring Security 관련 라이브러리와 JWT Token 라이브러리 설치 필요
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'

// JWT Token
implementation 'io.jsonwebtoken:jjwt:0.9.1'

JwtTokenUtil 생성

  • Jwt Token 방식을 사용할 때 필요한 기능들을 정리해놓은 클래스
  • 새로운 Jwt Token 발급, Jwt Token의 Claim에서 "loginId" 꺼내기, 만료 시간 체크 기능 수행
public class JwtTokenUtil {

    // JWT Token 발급
    public static String createToken(String loginId, String key, long expireTimeMs) {
        // Claim = Jwt Token에 들어갈 정보
        // Claim에 loginId를 넣어 줌으로써 나중에 loginId를 꺼낼 수 있음
        Claims claims = Jwts.claims();
        claims.put("loginId", loginId);

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

    // Claims에서 loginId 꺼내기
    public static String getLoginId(String token, String secretKey) {
        return extractClaims(token, secretKey).get("loginId").toString();
    }

    // 밝급된 Token이 만료 시간이 지났는지 체크
    public static boolean isExpired(String token, String secretKey) {
        Date expiredDate = extractClaims(token, secretKey).getExpiration();
        // Token의 만료 날짜가 지금보다 이전인지 check
        return expiredDate.before(new Date());
    }

    // SecretKey를 사용해 Token Parsing
    private static Claims extractClaims(String token, String secretKey) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
    }
}

SecurityConfig 생성

  • Spring Security에 대한 설정
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final UserService userService;
    private static String secretKey = "my-secret-key-123123";

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .httpBasic().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .addFilterBefore(new JwtTokenFilter(userService, secretKey), UsernamePasswordAuthenticationFilter.class)
                .authorizeRequests()
                .antMatchers("/jwt-login/info").authenticated()
                .antMatchers("/jwt-login/admin/**").hasAuthority(UserRole.ADMIN.name())
                .and().build();
    }
}
  • [Spring Boot] Spring Security를 사용한 로그인 구현 (Form Login)에서 진행했던 Form Login과는 달리 sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) 을 사용
    • Token 로그인 방식에서는 session이 필요없기 때문
  • Spring Security에서 로그인을 진행해주는 Filter(UsernamePasswordAuthenticationFilter)에 가기 전에 JwtTokenFilter을 거치게 함
    • JwtTokenFilter에서는 사용자의 요청에서 Jwt Token을 추출한 후 해당 Token이 유효한지 체크 => 유효하다면 UsernamePasswordAuthenticationFilter를 통과할 수 있게끔 권한 부여

JwtTokenFilter

  • 위에서 언급한 바와 같이 사용자의 요청에서 Jwt Token을 추출해 통과하면 권한을 부여하고 실패하면 권한을 부여하지 않고 다음 필터로 진행시킴
// OncePerRequestFilter : 매번 들어갈 때 마다 체크 해주는 필터
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {

    private final UserService userService;
    private final String secretKey;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

        // Header의 Authorization 값이 비어있으면 => Jwt Token을 전송하지 않음 => 로그인 하지 않음
        if(authorizationHeader == null) {
            filterChain.doFilter(request, response);
            return;
        }

        // Header의 Authorization 값이 'Bearer '로 시작하지 않으면 => 잘못된 토큰
        if(!authorizationHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        // 전송받은 값에서 'Bearer ' 뒷부분(Jwt Token) 추출
        String token = authorizationHeader.split(" ")[1];

        // 전송받은 Jwt Token이 만료되었으면 => 다음 필터 진행(인증 X)
        if(JwtTokenUtil.isExpired(token, secretKey)) {
            filterChain.doFilter(request, response);
            return;
        }

        // Jwt Token에서 loginId 추출
        String loginId = JwtTokenUtil.getLoginId(token, secretKey);

        // 추출한 loginId로 User 찾아오기
        User loginUser = userService.getLoginUserByLoginId(loginId);

        // loginUser 정보로 UsernamePasswordAuthenticationToken 발급
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                loginUser.getLoginId(), null, List.of(new SimpleGrantedAuthority(loginUser.getRole().name())));
        authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

        // 권한 부여
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        filterChain.doFilter(request, response);
    }
}

JwtLoginApiController 구현

  • Entity, DTO, Repository, Service는 미리 만들어 놓음
  • 전에 구현했던 쿠키, 세션, Security Form 로그인과는 달리 화면을 사용하지 않고 API로 로그인 진행
@RestController
@RequiredArgsConstructor
@RequestMapping("/jwt-login")
public class JwtLoginApiController {

    private final UserService userService;

    @PostMapping("/join")
    public String join(@RequestBody JoinRequest joinRequest) {

        // loginId 중복 체크
        if(userService.checkLoginIdDuplicate(joinRequest.getLoginId())) {
            return "로그인 아이디가 중복됩니다.";
        }
        // 닉네임 중복 체크
        if(userService.checkNicknameDuplicate(joinRequest.getNickname())) {
            return "닉네임이 중복됩니다.";
        }
        // password와 passwordCheck가 같은지 체크
        if(!joinRequest.getPassword().equals(joinRequest.getPasswordCheck())) {
            return"바밀번호가 일치하지 않습니다.";
        }

        userService.join2(joinRequest);
        return "회원가입 성공";
    }

    @PostMapping("/login")
    public String login(@RequestBody LoginRequest loginRequest) {

        User user = userService.login(loginRequest);

        // 로그인 아이디나 비밀번호가 틀린 경우 global error return
        if(user == null) {
            return"로그인 아이디 또는 비밀번호가 틀렸습니다.";
        }

        // 로그인 성공 => Jwt Token 발급

        String secretKey = "my-secret-key-123123";
        long expireTimeMs = 1000 * 60 * 60;     // Token 유효 시간 = 60분

        String jwtToken = JwtTokenUtil.createToken(user.getLoginId(), secretKey, expireTimeMs);

        return jwtToken;
    }

    @GetMapping("/info")
    public String userInfo(Authentication auth) {
        User loginUser = userService.getLoginUserByLoginId(auth.getName());

        return String.format("loginId : %s\nnickname : %s\nrole : %s",
                loginUser.getLoginId(), loginUser.getNickname(), loginUser.getRole().name());
    }

    @GetMapping("/admin")
    public String adminPage() {
        return "관리자 페이지 접근 성공";
    }
}

결과

  • 로그인 실패

  • 로그인 성공 (토큰 발급)

  • 인증 실패 1. Header에 "Authorization"이 없는 경우(403 에러)

  • 인증 실패 2. Jwt Token이 잘못된 경우(500 에러 => 에러 처리를 통해 403 에러로 만들어 줘야 함)

  • 인증 실패 3. Header의 "Authorization"이 "Bearer "로 시작하지 않는 경우 (403 에러)

  • 인증 성공

  • 인가 실패 (권한이 ADMIN이 아니기 때문에 접근 불가)

  • 인가 성공 (권한이 ADMIN인 유저로 로그인 후 접근한 경우)

반응형

↓ 클릭시 이동

복사했습니다!