개발/Spring Security(Hodol)

Spring - JWT / 토큰 생성, 암호화 키 분리 및 개선

잇(IT) 2023. 10. 6. 21:15
728x90

- JWT란 JSON Web Token의 약자로, 웹 및 애플리케이션 보안에서 사용되는 토큰 기반의 인증 방식 중 하나이다. 주로 사용자 인증 및 데이터 교환에 쓰이며, 웹 애플리케이션, 모바일 애플리케이션 및 웹 API에서 널리 사용된다.

 

- JWT 구성

1. Header : JWT의 유형과 해싱 알고리즘 정보를 포함한다.

2. Payload : JWT에 저장되는 클레임(claim) 정보를 포함한다. 클레임은 토큰에 관련된 데이터를 설명하며, 세 가지 종류가 있다.

 2.1. Registered claims : JWT에서 미리 정의된 클레임으로, "iss"(발급자), "sub"(주체), "exp"(만료 시간)등이 있다.

 2.2. Public claims : JWT 사용자 정의 클레임으로, 공객적으로 사용할 수 있다.

 2.3. Private claims : JWT 사용자 정의 클레임으로, 충동을 피하기 위해 사용자 간 협의에 따라 정의된다.

3. Signature : 헤더와 페이로드의 조합을 비밀 키로 해싱하여 생성되며, 이를 통해 통큰이 변경되지 않았고 신뢰할 수 있는 소스에서 발급되었는지 확인 할 수 있다.

 

- JWT 동작 방식

1. 인증 : 사용자가 로그인하거나 인증 프로세스를 통과할 때, 인증 서버는 JWT를 생성한다. 이 JWT에는 사용자의 정보와 기타 클레임 정보가 포함된다.

2. 토큰 발급 : 생성된 JWT는 사용자에게 반환되고, 클라이언트(브라우저 또는 모바일 앱)에 저장된다. 이 토큰은 일반적으로 HTTP 헤더의 Authorization 헤더나 쿠키 등을 통해 서버로 전송된다.

3. 서버 검증 : 클라이언트가 요청을 서버에 보낼 때, JWT는 일반적으로 HTTP 요청 헤더나 다른 방식으로 서버로 전송된다. 서버는 이 JWT를 받고 검증하는 프로세스를 수행한다.

4. 검증 단계

 4.1. 헤더 검증 : JWT의 헤더 부분은 해싱 알고리즘과 토큰 유형을 포함하고 있다. 서버는 이 정보를 사용하여 토큰을 검증한다.

 4.2 : 서명 검증 : JWT의 서명 부분은 비밀 키를 사용하여 헤더와 페이로드를 해싱한 결과다. 서버는 같은 키를 사용하여 서명을 다시 생성하고 이를 토큰의 서명과 비교하여 무결성을 검증한다 

5. 클레임 검증 : 토큰의 페이로드에는 클레임 정보가 포함되어 있다. 이 클레임 정보를 사용하여 사용자의 권한, 만료 시간 등을 확인하고 검증한다.

6. 인가 및 응답 : JWT가 검증되면, 서버는 클라이언트 요청을 처리하고 필요한 작업을 수행한다. 클라이언트에게 응답을 반환하거나 요청한 작업을 수행한다.


- JWT을 이용한 API 인증

- AuthController.java

@Slf4j
@RestController
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;

    private static final String KEY = "2M3upKjAHLWa9Rx1vo/ozgimEYTZqk1OjK7fWZzORoM=";
    private static final String KEY2 = "thisIsSecretKeythisIsSecretKeythisIsSecretKeythisIsSecretKeythisIsSecretKey";

    @PostMapping("/auth/login")
    public SessionResponse login(@RequestBody Login login){
    
        Long memberId = authService.signin(login);

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

        return new SessionResponse(jws);
    }
}

1. 로그인을 할 때, JWT 토큰을 생성하여 클라이언트에게 JSON 형태로 전달 할 것이다.

2. JWT JSON WEB TOKEN을 생성

 2.1. 토큰을 생성하기 위해선 서버에서만 알고 있는 시크릿 키가 필요하다. (해당 시크릿 키는 암호 알고리즘에 따라 내용이 달라진다. 현재 코드에서는 HMAC SHA-256 암호 알고리즘을 사용할 것이기 때문에, 32비트 16진수 4비트 코드 64개 이상의 키를 생성하는 것이 좋다.

 2.2. JWT을 생성하는 방법은 builder 패턴을 이용해서 토큰 문자열을 생성한다.

 2.3. signWith() 메서드를 통해 암호화 알고리즘과, 시크릿 키를 파라미터로 넘긴다.

 2.4. compact()를 통해 최종적으로 JWT을 생성한다. 아래는 builder를 통해 생성된 JWT 문자열 결과다.

3. 생성한 Token을 응답 body에 담아서 전달한다.


- auth.http

POST http://localhost:8080/auth/login
Content-Type: application/json
#헤더와 본문을 구분하기 위해 한 줄 띄어야 한다.

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

1. auth/login 경로로 body에 로그인 정보를 담아서 post 요청을 보낸다.

위와 같이 SessionResponse 클래스에 의해 accessToken : JWT의 객체가 body로 전달된다.


- auth.http

### 인증 페이지 요청
GET http://localhost:8080/test2
Content-Type: application/json
Authorization: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.rGLGnTX4JfbHU93oSDts1SoUoOYUjDERHPI4WsATk-4

1. /test2 경로로 Header의 Authorization 속성에 서버로부터 전달받은 JWT을 담아서 서버로 get 요청을 보낸다.

2. /test2 Controller를 보게 되면 파라미터로 UserSession을 가지고 있고 해당 클래스는 Resolver에 포함되는 클래스이기 때문에 인증을 거쳐야 한다.

@GetMapping("/test2")
    public Long test2(UserSession userSession){
        log.info(">>> {}", userSession.id);
        return userSession.id;
    }

* Jws

1. JSON Web Singature의 약자로, JWT의 서명 부분을 나타낸다.

2. JWT는 클레임(claim)정보와 함께 데이터를 안전하게 전송하고 검증하기 위한 표준화된 방법 중 하나다.

3. JWS는 JWT의 무결성을 검증하기 위해 사용한다. 클라이언트는 JWS의 헤더와 페이로드를 해독한 후, 서버의 공개 키를 사용하여 서명을 확인하여 토큰이 변경되지 않았으며, 신뢰할 수 있는 발급자에서 왔음을 확인한다.

4. 서버 측에서 JWT를 생성할 때는 JWS를 생성하여 토큰에 서명하고, 클라이언트 측에서는 JWS를 사용하여 토큰을 검증한다. 이를 통해 데이터의 무결성을 보호하고, JWT의 신뢰성을 확보 할 수 있다.

 

- AuthResolver.java

@Slf4j
@RequiredArgsConstructor
public class AuthResolver implements HandlerMethodArgumentResolver {

    private final SessionRepository sessionRepository;

    private static final String KEY = "2M3upKjAHLWa9Rx1vo/ozgimEYTZqk1OjK7fWZzORoM=";
    private static final String KEY2 = "fuckinSecretfuckinSecretfuckinSecretfuckinSecretfuckinSecretfuckinSecret";

    @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(jws == null || jws.equals("")){
            throw new Unauthorized();
        }
        try {
            Jws<Claims> claims = Jwts.parser()
                    .setSigningKey(KEY2)
                    .build()
                    .parseClaimsJws(jws);
            String memberId = claims.getBody().getSubject();
            return new UserSession(Long.parseLong(memberId));
        } catch (JwtException e) {
            throw new Unauthorized();
        }
    }
}

1. /test2 Controller의 UserSession 파라미터에 의해 Resolver의 resolveArgument가 실행된다.

2. JWT에 포함된 정보를 추출하기 위해 파싱한 값을 Jws의 claims에 담는다.

3. setSigningKey를 통해 JWT의 서명을 검증하기 위한 키를 입력한다.(서버에 저장된 시크릿 키를 전달했다.)

4. parseClaimsJws를 통해 전달된 JWT를 파싱하고 검증한다. -> 서명이 유요하면 JWT의 payload (Claims)를 반환한다.

5. Claims에는 payload에 저장된 정보 (이전의 setSubject를 통해 입력한 데이터의 값) 등 JWT를 생성할 때 입력한 정보들이 들어가게 된다.

6. 즉, payload를 통해 넘어온 memberId 값을 꺼내어 파라미터에 전달하게 된다.

- /test2 경로로 요청을 보냈을 때 Header와 memberId가 반환된 것을 확인 할 수 있다.


- application.yml 파일 활용

1. 환경 분리를 위해 JWT 발급을 위해 여러개의 SecretKey를 사용할 수 있다.

2. 환경 분리와 마찬가지로 서비스 분리를 위해서도 SecretKey를 분리할 수 있다.

-> SecretKey를 Class에 저장하게 되면 매번 바뀔때마다 코드를 변경해야 하는 번거로움이 발생한다. 이를 해결하기 위해 yml 파일에 SecretKey를 저장하고 사용하는 방법이 있다.

 

- application.yml

(* 스프링에서 - 하이픈을 이용한 jwt-key와 camelcase를 이용한 jwtKey를 연결시켜준다.)

bis:
  jwtKey: "thisIsSecretKeythisIsSecretKeythisIsSecretKeythisIsSecretKeythisIsSecretKey"

1. yml 파일에 위와 같이 key, value를 통해 jwtKey에 사용될 SecretKey를 설정한다.

 

- AppConfig.java

@ConfigurationProperties(prefix = "bis")
@Data
public class AppConfig {

    public String jwtKey;
}

1. @ConfigurationProperties(prefix = "bis")를 통해 외부 설정 값을 자동으로 주입 받는다.

 

- IslogApplication.java

package com.islog;

import com.islog.api.config.AppConfig;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;

@EnableConfigurationProperties(AppConfig.class) //스프링이 뜰 때 해당 클래스 등록
@SpringBootApplication
public class IslogApplication {

    public static void main(String[] args) {
        SpringApplication.run(IslogApplication.class, args);
    }
}

1. @EnableConfigurationProperties(Appconfig.class)는 @ConfigurationProperties로 정의된 클래스를 활성화하는 어노테이션이다.

2. 위 어노테이션에 의해 설정 값을 정의하고 사용하는 클래스를 스프링 애플리케이션 컨텍스트에 등록할 수 있고, 해당 클래스의 필드에설정 값을 주입받을 수 있게 된다.

 

- AuthController.java

...........

String jws = Jwts.builder()
                .setSubject(String.valueOf(memberId))
                .signWith(SignatureAlgorithm.HS256, appConfig.jwtKey)
                .setIssuedAt(new Date())
                // 계속 토큰을 바뀌게 한다.
//                .signWith(SignatureAlgorithm.HS256, KEY2)
                .compact();


...................

1. 이전에 signWith의 파라미터로 직접 SecretKey를 주입하였는데, Yml 파일에 등록한 값을 통해 해당 환경에 필효안 SecretKey를 주입하여 JWT를 생성 할 수 있다.

 

- AuthResolver.java

try {
            Jws<Claims> claims = Jwts.parser()
                    .setSigningKey(appConfig.jwtKey)
                    .build()
//                    .parseSignedClaims(jws);
                    .parseClaimsJws(jws);
            String memberId = claims.getBody().getSubject();
            return new UserSession(Long.parseLong(memberId));
        } catch (JwtException e) {
            throw new Unauthorized();
        }

1. 서명 검증을 하는 로직에서도 setSigningKey 부분에 yml 파일에 저장한 SecretKey를 활용할 수 있다.

728x90