본문 바로가기
Spring/Spring Security

JWT를 이용한 인증

by 제우제우 2024. 8. 26.

JWT 공식 홈페이지

 

JWT.IO

JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.

jwt.io

JWT 자바 라이브러리(jjwt-root)

 

GitHub - jwtk/jjwt: Java JWT: JSON Web Token for Java and Android

Java JWT: JSON Web Token for Java and Android. Contribute to jwtk/jjwt development by creating an account on GitHub.

github.com

Inpa Dev JWT 토큰 인증 이란? (쿠키 vs 세션 vs 토큰)

 

🌐 JWT 토큰 인증 이란? (쿠키 vs 세션 vs 토큰)

Cookie / Session / Token 인증 방식 종류 보통 서버가 클라이언트 인증을 확인하는 방식은 대표적으로 쿠키, 세션, 토큰 3가지 방식이 있다. JWT를 배우기 앞서 우선 쿠키와 세션의 통신 방식을 복습해

inpa.tistory.com

호돌맨의 요절복통 개발쇼 (SpringBoot, Vue.JS, AWS) 강의 | 호돌맨 - 인프런

 

호돌맨의 요절복통 개발쇼 (SpringBoot, Vue.JS, AWS) 강의 | 호돌맨 - 인프런

호돌맨 | 단순히 애플리케이션 하나를 만드는데 끝나지 않습니다. Spring Boot를 활용한 백엔드부터 Vue.js 모던 프론트엔드 스택을 연동한 서비스 완성 A-Z를 보여드립니다., 🐯이제는 실전이다!🐯

www.inflearn.com

의존성 Build.gradle 추가 

dependencies {
    implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' 
}

인코딩과 디코딩 

  • 인코딩(Encoding)은 데이터를 특정 형식으로 변환하는 과정. 주로 사람이 이해하기 쉬운 형식(텍스트 등)을 컴퓨터가 이해할 수 있는 형식(이진수, 바이너리 데이터 등)으로 변환하는 것을 의미
  • 디코딩(Decoding)은 인코딩된 데이터를 원래의 형식으로 변환하는 과정. 이 과정의 주요 목적은 인코딩 과정에서 변환된 데이터를 원래의 형태로 복원하여 인간이나 다른 시스템이 이해할 수 있도록 하는 것

JWT의 기본 구조 

JWT 구조 

1. 헤더(Header)

JWT 타입과 사용된 서명 알고리즘을 지정한다. 

 

2. 페이로드(Payload)

클레임(claims)이라고 불리는 데이터가 포함되어 있으며, 토큰의 주체(subject)와 만료 시간(expiration) 등의 정보를 담는다. 

 

3. 서명(Signature)

헤더와 페이로드를 기반으로 시크릿 키를 사용하여 생성된 서명

 

서명의 구조: JWT = base64UrlEncode(header) + "." + base64UrlEncode(payload) + "." + base64UrlEncode(signature)

시크릿 키의 역할

서명(Signature) 생성: 시크릿 키는 JWT의 헤더와 페이로드를 조합하여 해싱 알고리즘과 함께 사용되어 서명을 생성 이 서명은 JWT가 발급된 이후에 내용이 변조되지 않았음을 보장하는 역할을 한다. 

 

서명 검증: JWT를 수신하는 서버는 시크릿 키를 사용하여 서명을 다시 생성하고, 이 서명이 JWT에 포함된 서명과 일치하는지 확인함으로써 토큰이 변조되지 않았는지 검증

시크릿 키의 관리 

시크릿 키를 인코딩하고 문자열로 변환하여 저장

이 문자열은 파일, 데이터베이스, 환경 변수 등 안전한 위치에 저장한다. 

JWT SecretKey 생성 

SecretKey secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
byte[] encodedKey = secretKey.getEncoded();
String secretStrKey = Base64.getEncoder().encodeToString(encodedKey);

 

시크릿 키 생성

SecretKey secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);

HS256 알고리즘에 사용할 수 있는 시크릿 키를 생성

 

시크릿 키를 바이트 배열로 인코딩

byte[] encodedKey = secretKey.getEncoded();

 

바이트 배열을 Base64 인코딩 문자열로 변환 (서버에서 저장할 수 있는 형식)

String secretStrKey = Base64.getEncoder().encodeToString(encodedKey);

 

로그인 ~ JWT 검증 과정 

로그인 

@Slf4j
@RestController
@RequiredArgsConstructor
public class AuthController {
    private final AuthService authService;
    public static final String KEY = "kUJBh4N8N7jI7WnaA5YsI0qbIx/EgNNvlmpjgorIWPQ=";
    @PostMapping("/auth/login")
    public SessionResponse login(@RequestBody Login login){
        Long userId = authService.login(login);

        byte[] decodeKey = Base64.getDecoder().decode(KEY);

        String jws = Jwts.builder()
                .setSubject(String.valueOf(userId))
                .signWith(SignatureAlgorithm.HS256, decodeKey)
                .compact();

        return new SessionResponse(jws);
    }
}

 

KEY 보관 

public static final String KEY = "kUJBh4N8N7jI7WnaA5YsI0qbIx/EgNNvlmpjgorIWPQ=";

절대로 이렇게 보관하면 안 된다. (공부를 위해서 간단하게 했다.) 

 

AuthService

Long userId = authService.login(login);

회원 테이블에서 Login(DTO)에서 넘어온 이메일, 비밀번호를 바탕으로 

이메일 비밀번호가 맞으면 해당 회원의 ID(PK)를 넘겨주고 아니면 예외를 던진다. 

 

KEY 디코딩 

byte[] decodeKey = Base64.getDecoder().decode(KEY);

인코딩된 KEY를 다시 디코딩한다. (토큰 발급에 필요)

 

 JWT 토큰 생성

String jws = Jwts.builder()
            .setSubject(String.valueOf(userId))
            .signWith(SignatureAlgorithm.HS256, decodeKey)
            .compact();

 

응답

return new SessionResponse(jws);

SessionResponse 

간단한 응답용 DTO이다. 내부에 멤버변수로 AccessToken을 가진다. 

 

이렇게 응답한 JWT는 클라이언트의 쿠키나 스토리지에 저장을 한다(보통) 

 

JWT 검증 

/test API는 인증이 필요한 API다. 

@Slf4j
@RestController
@RequiredArgsConstructor
public class PostController {
    private final PostService postService;
    @GetMapping("/test")  // 인증 필요
    public Long test(UserSession userSession){
        log.info(">>>{}", userSession.id);
        return userSession.id;
    }
    
    // 생략 ...
}

인증은 UserSession 객체를 파라미터로 가지는 메서드인 경우 동작하도록 HandlerMethodArgumentResolver를 구현해서 해당 구현체에서 하도록 하였다. 

 

HandlerMethodArgumentResolver를 통한 방법 말고 스프링 인터셉터나 서블릿 필터를 사용해서도 충분히 구현이 가능하다. 

 

AuthResolver

@Slf4j
@RequiredArgsConstructor
public class AuthResolver implements HandlerMethodArgumentResolver {
    private final SessionRepository sessionRepository;
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterType().equals(UserSession.class);
    }
    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        String jws = webRequest.getHeader("Authorization");
        if(!StringUtils.hasText(jws)){
            throw new Unauthorized();
        }
        byte[] decodeKey = Base64.getDecoder().decode(AuthController.KEY);
        try {
            Jws<Claims> claims = Jwts.parser()
                    .setSigningKey(decodeKey)
                    .build()
                    .parseClaimsJws(jws);
            String userId = claims.getPayload().getSubject();
            return new UserSession(Long.parseLong(userId));
        }
        catch (JwtException e) {
            throw new Unauthorized();
        }
    }
}

 

HandlerMethodArgumentResolver를 구현 

 

supportParameter()

해당 메서드가 UserSession 타입을 가지는지 확인, 가지면 해당 핸들러(컨트롤러) 호출 이전에 AuthResolver가 호출이 된다. 

 

resolveArgument() 동작 

요청 헤더(Authorization)에서 JWT를 꺼낸다. JWT가 없으면 미리 정의한 예외(Unauthorized)를 던진다.

static 변수인 KEY를 받아와서 디코딩 

 

parser()를 사용해서 페이로드에 있는 클레임을 꺼낸다. 

subject 클레임에 유저 id를 저장했었다. 꺼내준다. 

UserSession에 담아서 return 

 

해당 과정에서 JWT 예외가 터지면 catch 해서 미리 정의한 예외(Unauthorized)를 던진다.

 

인텔리제이 HttpClient  테스트 

로그인 

### 로그인

POST http://localhost:8080/auth/login
Content-Type: application/json

{
  "email": "baejeu@naver.com",
  "password": "1234"
}

 

 응답 받은 JWT 

{
  "accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.EdBaut8ISNPbP4_e7KGrQS62LIHua6fHwB9aB1obqto"
}

 

발급 받은 토큰 없이 요청

 

### 인증이 필요한 페이지 요청

GET http://localhost:8080/test
Content-Type: application/json
Authorization:

 

 

응답

{
  "code": "401",
  "message": "인증이 필요합니다.",
  "validations": []
}

 

발급 받은 토큰을 넣어서 다시 요청 

### 인증이 필요한 페이지 요청

GET http://localhost:8080/test
Content-Type: application/json
Authorization: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.EdBaut8ISNPbP4_e7KGrQS62LIHua6fHwB9aB1obqto

 

응답 

HTTP/1.1 200 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Tue, 27 Aug 2024 09:33:09 GMT
Keep-Alive: timeout=60
Connection: keep-alive

1

 

정상적으로 잘 동작한다!!