dev-course day34

2rlokr·2025년 4월 18일

dev-course

목록 보기
34/43
post-thumbnail

오늘 배운 것

🧱 JWT 방식 실습

1. Configuration Properties 바인딩

application.yml

custom :
  jwt :
    validation :
      access: 600000
      refresh : 86400000

    secrets:
      origin-key : 019646e0-992d-7cbb-bdca-11c72d6b1b2e # UUID 임의로 
      app-key : E965C3A4EB5733F83ADFFC7AFE80083FF87618F1E290A6B0AE0 # UUID를 SHA512로 암호화한 것

JwtConfiguration.java

@Getter
@RequiredArgsConstructor
@ConfigurationProperties(prefix = "custom.jwt")
public class JwtConfiguration {

    private final Validation validation;
    private final Secrets secrets;

    @Getter
    @RequiredArgsConstructor
    public static class Validation {
        private final Long access;
        private final Long refresh;
    }

    @Getter
    @RequiredArgsConstructor
    public static class Secrets {
        private final String originKey;
        private final String appKey;
    }

}

BackendApplication.java

@ConfigurationPropertiesScan
public class BackendApplication {

application.yml에 정의한 설정 값을 @ConfigurationProperties를 사용해서 자바 클래스에 자동으로 주입받는 방식이다.

  • 무턱대고 @Value("${...}") 쓰는 것보다 더 확장성과 안정성이 좋다.
  • @ConfigurationProperties를 활용해서 YML 설정값을 자바 객체에 매핑(바인딩)한 구조이다.
  • camel case -> kebab case는 알아서 잘 바꿔준다.
  • @ConfigurationProperties를 사용하기 위해 Application 파일에 @ConfigurationPropertiesScan 어노테이션을 붙여줘야 한다.
  • 사용할 때는, 의존성 주입을 받아서 사용하면 된다.

✚ @Value("${...}") 이용해서 사용하는 법

@Value("${custom.jwt.validation.exp}")
private Long exp;

위와 같이 @Value를 사용할 수도 있다.

🫨 하지만, 변수가 늘어날 때마다, 그리고 YML 설정 구조가 복잡해질수록, 지저분해지고 복잡해진다.

2. JWT 토큰 발급받기

private String issue(Long id, Role role, Long expTime) {

	return Jwts.builder()
    	.subject(id.toString())
        .claim("role", role)
        .issuedAt(new Date())
        .expiration(new Date(new Date().getTime() + expTime))
        .signWith(getSecretKey(), Jwts.SIG.HS256)
        .compact(); 
}

private SecretKey getSecretKey() {
	return Keys.hmacShaKeyFor(jwtConfiguration.getSecrets().getAppKey().getBytes());
}
  • Jwts.builder().compact() : JWT는 문자열로 되어있다. JWT를 만들어서 반환해준다.
  • subject() : sub : ~ 에 들어갈 내용을 지정해준다. 여기서는 Member의 id를 문자열로 넣어줬다.
  • claim() : Payload에 들어갈 Claim의 key, value을 넣어준다. (value는 Object 타입)
  • issuedAt(new Date()) : 발급 받은 시간을 현재 시간으로 넣어준다.
  • expiration() : 만료 기간을 설정해줄 수 있다.
    • new Date().getTime() + expTime : expTime 유효 기간을 Long 타입으로 new Date()에 넣어주어 발급한 이래로의 유효 기간을 설정해준다.
  • signWith() : 시그니처를 넣어줄 수 있다.
    • Jwts.SIG.HS256 : 헤더에 넣어줄 해싱 알고리즘을 적어준다. 해당 알고리즘으로 JWT 시그니처를 생성한다.
    • getBytes() : 문자열을 바이트 배열로 변환한다.
    • getSecretKey() : 시그니처에 포함시킬 키를 반환하는 메서드이다. 키 타입을 반환하며, Keys.hmacShaKeyFor() 메서드는 바이트 배열을 기반으로 HMAC 알고리즘을 적용한 HMAC-SHA 시크릿 키를 생성한다.

3. Token 유효성 검사

public boolean validate(String token) {
	try{
    		// JWT 토큰 분석
        JwtParser parser = Jwts.parser()
        	.verifyWith(getSecretKey())
            .build();
       	parser.parseSignedClaims(token); 
            
        return true;
    } catch ( JwtException e ) {
    	log.info("token = {}", token);
        log.info("토큰이 이상해요..");
    } catch ( IllegalArgumentException e){
    	log.error("token = {}", token);
        log.info("이상한 토큰이 검출되었습니다.");
    } catch (Exception e) {
    	log.error("token = {}", token);
        log.info(";;");
    }

	return false;
}
  • parser().build() : JWT를 분석하기 위해 사용한다.
  • verifyWith(getSecretKey()) : 어떤 키를 가지고 분석할지 정해준다. 우리는 getSecretKey()로 비밀키를 만들어 토큰을 만들었기 때문에 그 메서드를 넣어준다.
  • parseSignedClaims(token) : 토큰을 가지고 제대로 된 토큰인지, 서명이 맞는지, 문제가 있는지 등을 검사한다. 그런 후 문제가 없다면 토큰에 담긴 클레임을 반환하고, 문제가 있다면 예외를 던진다. -> 반환값은 다음과 같다. Jws<Claims> claimsJws

4. 토큰 분석하기

public TokenBody parseJwt(String token) {

    Jws<Claims> parsed = Jwts.parser()
    	.verifyWith(getSecretKey())
        .build()
        .parseClaimsJws(token);

	String sub = parsed.getPayload().getSubject();
    String role = parsed.getPayload().get("role").toString();

	return new TokenBody(Long.parseLong(sub), Role.valueOf(role));
}

위와 같은 메서드를 활용해서 이번엔 클레임을 받아온다. 이 상황에서는 예외 처리를 해주지 않아도 된다. 즉, 토큰이 제대로 된 토큰인지 확인해줄 필요가 없다.

왜냐, 이미 validate() 검사를 하고 분석을 하는 로직이기 때문에, validate()에서 안 터졌던 예외가 여기서 터지면 이상한 것...

  • parseClaimsJws()는 JWT를 파싱해서 JWS (헤더 + 페이로드 + 서명) 전체를 포함한 객체로 리턴해준다. 그렇기 때문에 Payload에 담긴 내용을 받아오기 위해서는 .getPayload()을 해야 한다.

  • parsed.getPayload() : 클레임에 담긴 값들을 가져온다. -> DTO에 담아주기 위해 TokenBody 클래스를 만들고, 거기에 값을 넣어준다.

5. 로그인 성공 시 사용자에게 토큰 발급해주기

⚠️ 현재 로그인이 안되는 문제점

현재 oauth2Login 설정으로는, 로그인을 해도, 계속 로그인이 되지 않고, 로그인 창이 뜬다.

🫥 세션을 사용하지 않는 (STATELESS) 설정이다. 즉, 로그인 상태를 서버가 기억하지 못한다 !

로그인 성공 -> redirect-url 찾음 -> 따로 지정해둔 게 없음 -> defaultTargetUrl로 설정된 /로 redirect됨 -> 새로운 요청 -> 너 누구니 ❓ 인증 정보가 없는데? 🤨 -> 로그인 다시 해. ㅎ 🪪😵‍💫

💡 해결 방법

  • 로그인 성공 시, JWT를 발급해서 클라이언트가 저장하게 해야 한다. (쿠키 or 로컬스토리지)
  • 그리고, 이후 요청에 JWT를 실어서 보내도록 해야 서버가 인증된 사용자인 것을 인식할 수 있다.

SecurityConfig.java

.oauth2Login( oauth -> {
	oauth.successHandler(oAuth2SuccessHandler);
})
  • 이제 OAuth2 인증 방식이 성공했을 때 (로그인에 성공했을 때) 실행될 로직을 처리해줘야 한다. 그러기 위해 successHandler() 를 등록해줘야 한다.

OAuth2SuccessHandler.java

@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final MemberService memberService;
    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {

        MemberDetails principal = (MemberDetails) authentication.getPrincipal();

        String token = jwtTokenProvider.issueAccessToken(principal.getId(), principal.getRole());
        log.info("token = {}", token);

    }

}
  • 핸들러도 의존성 주입을 위해 @Component를 붙여 빈으로 등록해준다.
  • onAuthenticationSuccess() : 로그인이 성공하고, 인증까지 끝난 직후에 호출된다.이 메서드는 인증 성공 후, 로그인 이후 로직을 직접 처리할 수 있도록 해주는 메서드이다. 이 메서드에서는 JWT 토큰을 발급해주고 있다. -> issueAccessToken() 로 토큰을 발급받는다.
  • authentication.getPrincipal() : SecurityContext에 저장되는 인증 객체에서 principal을 받아온다. 즉, MemberDetails 를 반환해준다.

6. AccessToken & RefreshToken 발급

RefreshToken.java

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RefreshToken {

    @Id
    @Column(name="refresh_token_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String refreshToken;

    @Setter
    @ManyToOne
    @JoinColumn(name="member_id")
    private Member member;

    private LocalDateTime createdAt = LocalDateTime.now();

    @Builder
    public RefreshToken(String refreshToken, Member member) {
        this.refreshToken = refreshToken;
        this.member = member;
    }

}
  • AccessToken이 만료됐을 때, 사용해주기 위해 RefreshToken은 DB에 저장해두어야 한다.
  • Member가 여러 개의 RefreshToken을 가질 수 있다. (로그인할 때마다)
  • 토큰 입장에서는 멤버의 정보를 알아야 하지만, 멤버의 입장에서는 내가 어떤 토큰을 가지고 있는지 참조를 해야 할 필요가 없다. -> ✅ 단방향 @ManyToOne 관계를 만들어주면 된다 !

generateTokenPair.java

public TokenPair generateTokenPair(Member member) {

	String accessToken = issueAccessToken(member.getId(), member.getRole());
    String refreshToken = issueRefreshToken(member.getId(), member.getRole());

	RefreshToken token = new RefreshToken(refreshToken, member);
    refreshTokenRepository.save(token);

	return TokenPair.builder()
    	.accessToken(accessToken)
        .refreshToken(refreshToken)
        .build();
}
  • 로그인을 하고, 인증 객체를 받아왔을 때, 토큰을 발급해줘야 한다. AccessToken과 RefreshToken을 모두 사용할 것이기 때문에, 둘 다 함께 발급해준다.
  • RefreshToken은 DB에 저장해줘야 하기 때문에 save()을 통해 DB에 저장해준다.
  • TokenPair이라는 DTO 클래스를 만들어, accessToken과 refreshToken을 담아 객체를 생성해준다.
public String issueAccessToken(Long id, Role role) {
	return issue(id, role, jwtConfiguration.getValidation().getAccess());
}

public String issueRefreshToken(Long id, Role role) {
    return issue(id, role, jwtConfiguration.getValidation().getRefresh());
}
  • AccessToken과 RefreshToken의 유효 기간이 다르기 때문에, 각각의 유효기간에 따라 Token을 만들어준다.
    • getAccess()getRefresh()application.yml 파일에서 지정해준 값들을 @ConfigurationProperties를 이용해 가져오는 것이다.

🧱 Access Token & Refresh Token

Access Token과 Refresh Token의 필요성

JWT는 Json 객체에 인증에 필요한 정보들을 담은 후 비밀키로 서명한 토큰으로, HTTP 헤더에 실려 서버가 클라이언트를 식별하는 데에 쓰이는 중요한 인증방식이다.

😏 하지만, 누군가.. JWT를 탈취했다면..?

그 사람은 토큰을 이용해서 신뢰할만한 사용자인 것 마냥 인증을 통과할 수 있고, 서비스를 악용할 가능성이 있다. 서버는 JWT로만 판단하기 때문에, 해당 사용자가 JWT을 해킹한 사람인지, 신뢰할만한 사용자인지 구분할 수 없다.

🗓️ 그래서 유효기간이 필요하다 !

⚡️ BUT! 유효기간을 너무 짧게 두면 로그인을 자주 해야 하기 때문에 사용자 경험이 좋지 않다. 그렇다고, 유효기간을 길게 두면 보안상 탈취 위험이 있다.

✅ 그래서 유효기간이 다른 두 개의 JWT 토큰을 두는 것이다 !
그것이 바로 Refresh TokenAccess Token

Access Token과 Refresh Token이란?

📕 Access Token

  • 유효기간이 짧은 토큰이다. (EX. 60일, 1시간)
  • 평소에 API 통신할 때 사용된다.

📚 Refresh Token

  • 유효기간이 긴 토큰이다. (EX. 1년)
  • Access Token이 만료되어 갱신할 때만 사용된다.

즉, 통신 과정에서 해킹당할 위험이 큰 Access Token의 유효기간은 짧게 두고, Refresh Token으로 주기적으로 재발급함으로써 피해를 최소화하려는 것이다.

👩‍💻 구체적인 통신 과정

  1. 로그인에 성공하면 (인증을 받으면) 클라이언트는 서버로부터 Access Token과 Refresh Token을 받는다.
  2. 클라이언트는 Access Token과 Refresh Token을 로컬에 저장해둔다.
  • 로컬 스토리지나 쿠키에 저장할 수 있다.
  1. 클라이언트는 HTTP 헤더에 Access Token을 넣고 API 통신을 한다. (Authorization)
  2. 일정 시간이 지나 Access Token의 유효기간이 만료되었다.
  • Access Token은 이제 유효하지 않으므로 권한이 없는 사용자가 된다.
  • 클라이언트로부터 유효기간이 지난 Access Token을 받은 서버는 401 (Unauthorized) 에러 코드로 응답한다.
  • 401 에러코드를 통해 클라이언트는 invalid_token (유효기간이 만료되었음)을 알 수 있다.
  1. 클라이언트는 HTTP 헤더에 Access Token 대신 Refresh Token을 넣어 API를 재요청한다.
  2. Refresh Token으로 사용자의 권한을 확인한 서버는 응답 쿼리 헤더에 새로운 Access Token을 넣어 응답한다.
  3. 만약, Refresh Token도 만료되었다면, 서버는 동일하게 401 에러 코드를 보내고, 이 때 클라이언트는 새로 로그인 해줘야 한다.

Access Token이 해킹됐을 때

통신에 잘 사용되는 Access Token은 탈취될 가능성이 높다. 하지만, 만약 운좋게 탈취했더라도 유효 기간이 짧기 때문에 다시 탈취를 시도해야 한다. 왜냐하면, JWT의 유효기간은 변경이 불가능하기 때문이다.

왜 토큰의 유효기간은 변경하지 못할까?

JWT는 Header, Payload, Signature로 구성되어 있다. 탈취자는 Payload에 있는 유효기간을 늘리려고 시도할 것이다. 하지만, Payload의 만료 기간을 변경한다 하더라도, Signature가 바뀌진 않는다.
Signature에서 복호화된 Payload와 변경된 Payload가 일치하지 않는 것을 비밀키를 가진 서버는 알 수 있고, 접근 권한을 내어주지 않게 되는 것이다.

😵 Refresh Token이 탈취되면..?

Refresh Token이 통신에 사용되는 빈도가 적긴 하지만, 탈취될 가능성이 없는 것은 아니다. Refresh Token이 유출되는 것을 방지하기 위한 방법도 여러가지 있다.

BlackList 방법, Refresh Token Rotation 방법 등이 있다.

간단히 설명하자면,

  • BlackList : Access Token이나 Refresh Token이 유효기간이 남았음에도 불구하고, 해당 토큰을 무효로 간주하자고하고 서버에서 따로 저장해두고 차단하는 방식이다.
    • access token 만료 전인데 로그아웃 시킬 때
    • 로그아웃 안 하고 브라우저 창을 닫았을 경우
  • Refresh Token Rotation : 클라이언트가 Access Token을 재요청할 때마다 Refresh Token도 새로 발급받는 것이다. 이렇게 되면 탈취자가 가지고 있는 Refresh Token이 더이상 만료 기간이 긴 토큰이 아니게 된다.

오늘 궁금했던 것 ❓

Q1. 왜 한 멤버가 여러 개의 토큰을 가질 수 있지? 한 사용자당 하나의 토큰만 가지는 거 아닌가? 한 사용자가 여러 토큰을 받으면 DB에 같은 멤버의 refreshToken이 엄청 많을 수도 있는 거 아닌가..?

A1. 현실적으로는 여러가지의 시나리오가 있다고 한다.

📌 1. 여러 디바이스에서 로그인

같은 사용자가:

  • 회사 컴퓨터에서 로그인
  • 집 노트북에서 로그인
  • 모바일 앱에서도 로그인
    → 이 때 각 디바이스마다 리프레시 토큰을 따로 관리해야 한다.

왜냐면:

  • 각각의 로그인 세션이 독립적이기 때문 (각기 다른 클라이언트에서 동작하니까)
  • 하나의 디바이스에서 로그아웃한다고 모든 기기에서 로그아웃되면 안 되기 때문이다.

👉 그래서 디바이스마다 리프레시 토큰이 따로 저장된다.

📌 2. 브라우저 or 앱을 닫고 다시 로그인

  • 사용자가 쿠키를 지우거나, 앱을 재설치하거나 하면, 새로 로그인하고, 새로운 리프레시 토큰을 발급받게 된다.

👉 이전 리프레시 토큰은 여전히 유효할 수도 있다.
→ 따라서 중복되는 리프레시 토큰이 생길 수 있다.

📌 3. 보안 측면에서 토큰 추적 및 관리

  • 한 사용자가 발급받은 모든 리프레시 토큰을 DB에 저장해두면 이상 행동 감지가 가능하다. (예: 토큰이 다른 지역에서 사용되었을 때)
  • 의심스러운 토큰만 개별적으로 차단할 수 있다.
  • 특정 디바이스에서만 로그아웃도 가능

Q2. 이렇게 계속 Refresh Token을 발급 받는다면, refresh token은 언제 지워지는 걸까?

이것도 여러 경우가 있을 수 있다.

1. 로그아웃할 때

  • 사용자가 명시적으로 로그아웃을 하면 해당 디바이스의 리프레시 토큰만 삭제된다.
  • 로그아웃을 하면 access token은 그냥 클라이언트에서 없애고 refresh token은 서버에서 DB에서 제거해서 무효화한다.

2. 리프레시 토큰이 만료됐을 때

  • 리프레시 토큰의 유효기간이 만료됐을 때, 토큰은 더 이상 사용할 수 없으므로 삭제 대상이 된다.

✔ 이건 보통 주기적으로 백그라운드에서 만료된 토큰을 정리하는 작업을 따로 돌린다. (ex. 스케줄러)

3. 리프레시 토큰이 탈취되었거나, 이상 접근이 감지됐을 때
예를 들어 같은 토큰이 다른 IP, 다른 지역에서 사용되었을 때, 보안상 위험이 의심되면 해당 토큰을 강제로 삭제한다. (또는 블랙리스트에 등록)

  • 이건 보안 정책에 따라 자동 또는 수동으로 처리할 수 있다.

4. 회원 탈퇴
회원 탈퇴 시, 해당 회원의 모든 리프레시 토큰을 DB에서 삭제한다.


Q3. 그럼 정리하자면, refresh Token을 삭제해줄만한 위의 경우가 아니라면, 로그인할 때마다 매번 access token과 refresh token을 발급해주는 건가?

A3. 그렇다 ! 보안 측면에서, 토큰은 결국 인증 수단이기 때문에, 오래된 토큰보다 최신 토큰을 사용하는 게 좋다. 로그인할 때마다 토큰을 발급 받으면, 이전 토큰 유출 위험이 낮아진다.

로그아웃하지 않고, 로그인 상태를 계속 유지하려면 access token이 만료돼도 refresh token으로 access token을 재발급 받을 수 있다. 이 경우 refresh token을 새로 발급받지 않고, 이걸 이용해서 access token을 갱신한다.

팀 활동 후기

오랜만에 쓰는 팀 활동 후기..! 사실 쓸 생각은 없었는데,,, 느낀 점 쓰다가 갑자기 너무 웃긴 게 생각이 나서 적어본다. 난 우리 팀원분들이 좀 웃기다. 난 대놓고 웃긴 유머보다 약간 이상하게 웃긴 유머 코드가 웃긴데.. 우리 팀원분들이 좀 그런 분들인 것 같다. (이상하다는 게 아니라..) 스크럼 때 노션에 남기는 워딩이나, 말씀하실 때 좀 웃참하게 되는 부분들이 있다.. ㅎ 내가 너무 정많아 인간이라 팀원들한테 또 혼자만 엄청난 내적 친밀감을 느끼고 혼자 웃참하고 있다... ㅋㅎㅋㅎㅋㅎ 아무튼.. 웃긴 거 하나도 안 하시고,, 되게 진지하시고,, 되게 침묵이신데,, 한 번씩 웃기다. (positive)

느낀 점

워후.. 어제 분명히 '오늘은 괜찮았지만, 내일 JWT 들어가면,, 어렵겠지~?' 라고 했는데,, 나의 상상은 현실이 된다? ㅎ .. 맞습니다 하하 오후 돼서 refresh, access 나오니까 너무 헷갈려서 이해할 수 없었다... 그치만 열심히 이해하려고 찾아가면서 수업을 들었다. 그러니까 좀 잘 따라갈 수 있었다. 그래도 뭔가 완벽히 이해된 것 같진 않고,, 강사님이 "아~ 이러면 다음주 월요일에 리셋돼서 오는 거죠?" 이러셨을 때 너무 웃겼다. 난 홍홍홍 웃었다 ㅎㅎ... 안 까먹으려고 열심히 정리는 했다. ㅎ...!

또! 오늘 프로젝트 공지가 올라왔다..! 와우, 너무 많은 양의 정보에 머리가 어지러운 나. ㅎ 정말 데브코스는 산 넘어 산이다 하하 그치만 이미 많은 산을 넘었으니,,, 프로젝트라는 산도 잘 넘어보자.. 이런 너낌스로 열심히 해야지 !!! 팀원 분들이랑 잠깐 얘기를 나눠봤는데 재미있을 것 같기도 하지만,, 또 무섭기도 하고,, 겁나기도 하고!! 아악 !! 잘 헤쳐나갈 수 있기를... !

금요일이닷 ! 근데 어째서 벌써 11시? :) 내 소중한 금요일 밤 어디갔지.. 소중한 주말 잘 지켜야지..

0개의 댓글