[SpringBoot+Vue.js] 정리 (4) JWT Token

이전글 : https://kora1492.tistory.com/83

 

[SpringBoot+Vue.js] 정리 (3) Vue router 추가 및 로그인

https://kora1492.tistory.com/75 [SpringBoot+Vue.js] 정리 (2) MariaDB 설치 및 JPA 추가하여 프론트 에 객체 데이터 출력 DB(MariaDB) 및 JPA를 사용하기 위해, build.gradle 추가 implementation 'org.springframework.boot:spring-boot-st

kora1492.tistory.com

 

JWT

JWT(Jason Web Token)는 웹 표준으로 JSON 형식의 데이터를 사용하여 정보를 안전하게 전송하는 방식을 정의

 

Header : JWT가 어떤 알고리즘으로 서명되었는지에 대한 정보를 포함. 

alg : 암호화 알고리즘은 어떤 건지

typ : 데이터, JWT 를 쓸거다.

{
  "alg": "HS256",
  "typ": "JWT"
}

 

Payload : 실제 전송할 데이터인 Claim(요구사항) 정보가 들어가 있다.

Signature : Header와 Payload에 대한 서명값이 포함, 서명값은 Header에서 지정한 알고리즘과 시크릿키를 이용하여 생성

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

* JWT의 "Claims"는 크게 세 가지 유형

1. Registered Claims

2. Public Claims

3. Private Claims

 

JWT에서 정의된 기본적인 Claim 구조

iss (Issuer) : JWT를 발급한 개체(보통 URL 또는 이메일 주소)
sub (Subject) : JWT가 제공하는 서비스의 주체(사용자 ID 등)
aud (Audience) : JWT를 받아들일 대상(보통 URL 또는 이메일 주소)
exp (Expiration Time) : JWT의 만료 시간
nbf (Not Before) : JWT의 유효 시작 시간
iat (Issued At) : JWT의 발급 시간
jti (JWT ID) : JWT의 고유 ID

 

또한 사용자가 직접 정의할 수 있는 Claim을 추가 가능

JWT를 생성할 때는 JwtBuilder를 사용하여 Claim 정보를 설정하고, setClaims() 메소드를 통해 Claim을 설정.

Map<String, Object> claims = new HashMap<>();
claims.put("name", "John Doe");
claims.put("email", "john.doe@example.com");

JwtBuilder builder = Jwts.builder().setClaims(claims).signWith(signKey, SignatureAlgorithm.HS256);
Claims claims = Jwts.parserBuilder().setSigningKey(signKey).build().parseClaimsJws(token).getBody();
String name = claims.get("name", String.class);

JWT를 통한 로그인 추가

- 유튜브 강의(https://www.youtube.com/watch?v=htYYSszfzv0&t=5431s)에선 리프레시 토큰은 별도로 설정하지 않는 Token 발행하여 쿠키에 저장하여 사용함.

- jwt 리프래쉬나 axios 인터셉트를 통하여 리프래쉬 토큰 관리를 하면서 acess token 관리 유지가 되는 플로우가 있어야 한다는 의견이 있음.

 

build.gradle 의존성 추가 (jjwt)

implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'

 

Service 

Jwt 서비스를 추가한다.

secretkey 의 경우, 길이가 부족하면 signing key 에 충분하지 않다는 오류가 발생되므로 key 값은 충분히 길게 작성할 것. 

The signing key's size is 250미만 bits which is not secure enough for the HS256 algorithm
import io.jsonwebtoken.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@Slf4j
@Service
public class JwtServiceimpl implements JwtService {

    private final String secretKey = "dfadfksfjadfaflsdfnklbnlknklfdfdlff123213123";

    @Override
    public String getToken(String key, Object value) {

        Date expireTime = new Date();

        expireTime.setTime(expireTime.getTime() * 1000 * 60 * 5);


        byte[] secretByteKey = DatatypeConverter.parseBase64Binary(secretKey);
        Key signKey = new SecretKeySpec(secretByteKey, SignatureAlgorithm.HS256.getJcaName());

        Map<String, Object> headerMap = new HashMap<>();
        headerMap.put("typ", "JWT");
        headerMap.put("alg", "HS256");

        Map<String, Object> map = new HashMap<>();
        map.put(key, value);

        JwtBuilder builder = Jwts.builder().setHeader(headerMap)
                .setClaims(map)
                .setExpiration(expireTime)
                .signWith(signKey, SignatureAlgorithm.HS256);

        return builder.compact();

    }

    @Override
    public Claims getClaims(String token) {
        if (token != null && !"".equals(token)) {
            try {
                byte[] secretByteKey = DatatypeConverter.parseBase64Binary(secretKey);
                Key signKey = new SecretKeySpec(secretByteKey, SignatureAlgorithm.HS256.getJcaName());
                return Jwts.parserBuilder().setSigningKey(signKey).build().parseClaimsJws(token).getBody();
            } catch (ExpiredJwtException e) {
                // 만료됨
                log.error("토큰 만료");

            } catch (JwtException e) {
                // 유효하지 않음
                log.error("토큰 유효하지 않음");

            }
        }

        return null;
    }
}
  • getToken () :  주어진 key-value 쌍을 포함하는 JWT를 생성
  • 만료 시간 :  현재 시간으로부터 5분으로 설정
    secretKey를 byte 배열로 변환하여 SecretKeySpec 객체를 생성
    JWT의 헤더와 Claims를 설정
    signWith 메소드를 사용하여 서명 알고리즘과 함께 JWT를 서명, 생성된 JWT를 반환합니다.
  • getClaims() : 주어진 JWT에서 Claims를 추출
  • secretKey를 byte 배열로 변환하여 SecretKeySpec 객체를 생성
    Jwts.parserBuilder().setSigningKey(signKey).build().parseClaimsJws(token).getBody() 를 사용하여 JWT에서 추출된 Claims를 반환

 

LoginController

import com.example.study_springbootvue.entity.Member;
import com.example.study_springbootvue.repository.MemberRepository;
import com.example.study_springbootvue.service.JwtService;
import com.example.study_springbootvue.service.JwtServiceimpl;
import io.jsonwebtoken.Claims;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;

import java.util.List;
import java.util.Map;
import java.util.Optional;

@Slf4j
@RestController
public class LoginController {

    @Autowired
    MemberRepository memberRepository;

    @Autowired
    JwtService jwtService;

    @PostMapping("/api/member/login")
    public ResponseEntity login(@RequestBody Map<String, String> params
        , HttpServletResponse response
    ){
        Optional<Member> optionalMember = memberRepository.findByEmailAndAndPassword(params.get("email"), params.get("password"));
        Member member;
        if(optionalMember.isPresent()){
            member = optionalMember.get();
            log.info("멤버 로그인");
            int id = member.getId();
            String token = jwtService.getToken("id", id);

            Cookie cookie = new Cookie("token", token);
            cookie.setHttpOnly(true);
            cookie.setPath("/");

            response.addCookie(cookie);

            return new ResponseEntity<>(id, HttpStatus.OK);
        }

        throw new ResponseStatusException(HttpStatus.NOT_FOUND);
    }

    @PostMapping("/api/member/logout")
    public ResponseEntity logout(HttpServletResponse res) {
        Cookie cookie = new Cookie("token", null);
        cookie.setPath("/");
        cookie.setMaxAge(0);

        res.addCookie(cookie);
        return new ResponseEntity<>(HttpStatus.OK);
    }

    @GetMapping("/api/member/check")
    public ResponseEntity check(@CookieValue(value = "token", required = false) String token){
        Claims claims = jwtService.getClaims(token);

        if (claims != null) {
            log.info(claims+" : claims is not null");
            int id = Integer.parseInt(claims.get("id").toString());
            return new ResponseEntity<>(id, HttpStatus.OK);
        }

        return new ResponseEntity<>(null, HttpStatus.OK);
    }
}

 

 

 

App.Vue

<script>
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import store from "@/scripts/store";
import axios from "axios";
import {watch} from "vue";
import {useRoute} from "vue-router";
export default {
  name: 'App',
  components: {
    Footer,
    Header
  },
  setup() {
    const check = () => {
      axios.get("/api/member/check").then(({data}) => {
        console.log(data);
        store.commit("setAccount", data || 0);
      })
    };
    const route = useRoute();
    watch(route, () => {
      check();
    })
  }
}
</script>
  • check 함수는 axios 라이브러리를 사용하여 /api/member/check 경로로 GET 요청을 보내고, 이에 대한 응답으로 받은 데이터를 setAccount 뮤테이션을 호출하여 Vuex 상태 관리를 통해 저장
  • route 객체는 useRoute() 훅을 사용하여 현재 활성화된 라우트 정보를 얻어옵니다. 그리고 watch() 함수를 사용하여 route 객체가 변경될 때마다 check 함수를 호출하여 로그인한 사용자의 정보를 업데이트