본문 바로가기

개발

[Java] 토큰 인증 처리에 대한 정리

문제 제기

토큰 인증 관련 글들을 보면 jwt의 만료 기간만 보고 인증을 결정하는 내용들을 자주 봤다.

이 경우 인증을 stateless 하게 관리해 성능상의 이점이 있지만

 

로그아웃된 토큰 or 토큰이 탈취됐을 때에 대해선 대응이 부족한 것 같단 생각이 들었다.

서비스 신뢰도나 최악의 상황을 고려하면 인증 관련 정보는 stateful로 관리하는 게 맞는다고 생각해 여기에 맞춰 토큰 인증을 만들었다.

 

2가지 토큰으로 인증을 처리한다.

access 토큰: 실제 인증에 사용되는 토큰. 기간이 짧다.

refresh 토큰: access 토큰 만료 시 갱신에 사용되는 토큰. 기간이 길다.

 

인증 Entity

Entity를 생성하고 access, refresh 토큰과 memberId를 넣는다.

디테일을 위해 넣은 거지만 @PrePersist를 적용한 이유는 시간 관리를 DB에 적용한 시점부터 관리하기 위해서다.

@Entity
public class AuthToken {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private Long memberId;

    @Column(nullable = false)
    private String accessToken;

    @Column(nullable = false)
    private String refreshToken;

    @Column(nullable = false)
    private LocalDateTime createdAt;

    @Column(nullable = false)
    private LocalDateTime expiredAt;

    public AuthToken(final Long memberId) {
        this.memberId = memberId;
        this.accessToken = UUID.randomUUID().toString();
        this.refreshToken = UUID.randomUUID().toString();
    }

    @PrePersist
    public void onPrePersist() {
        this.createdAt = LocalDateTime.now();
        this.expiredAt = this.createdAt.plusYears(1);  // refresh 토큰 만료
    }

    public void reIssuance() {
        this.accessToken = UUID.randomUUID().toString();
        this.refreshToken = UUID.randomUUID().toString();
        this.createdAt = LocalDateTime.now();
        this.expiredAt = this.createdAt.plusYears(1);
    }

    public void setExpiredAt(final LocalDateTime expiredAt) {
        this.expiredAt = expiredAt;
    }

    protected AuthToken() {
    }
}

로그인 성공

로그인 시퀀스

 

이미 인증 토큰이 존재하는 상황이면 만료기간에 상관없이 이전 토큰을 파기하고 새로 만들었다.

DB 저장이 끝나면 응답 속도를 높이기 위해 <access 토큰, 사용자 정보 객체> 쌍으로 저장했다.

redis를 써도 됐지만, WAS 메모리가 부족한 상황까지 가지는 않아, 로컬 캐시로 대체했다.

 

로그인 로직이 성공적으로 끝나면 사용자에게 access, refresh 토큰을 준다.

access 토큰 만료시

access 토큰 만료 시퀀스

    @Transactional
    public AuthToken reIssueAuthToken(final AuthToken authToken) {
        // 기존 인증 캐시 삭제
        accessTokenMap.remove(authToken.getAccessToken());

        final Member member = memberService.findMember(authToken.getMemberId());
        AuthMember authMember = new AuthMember(member);

        // 새로운 인증 토큰 재발급 및 DB 갱신
        authToken.reIssuance();
        accessTokenMap.put(authToken.getAccessToken(), authMember);
        authMember.setCreatedAt(authToken.getCreatedAt());
        return authToken;
    }

 

access, refresh 토큰과 매칭되는 인증 정보를 찾는다.

두 토큰을 가지고 찾는 이유는 만료된 access 토큰이 탈취된 상황에서

access 토큰 하나로 인증을 갱신하는 것을 막기 위함이다.

 

refresh 토큰이 만료되지 않았어도, 기존 토큰 정보를 파기하고 새로 발급한다.

만료 기간을 연장하지 않고 새로 만드는 이유는 다음 순서로 발생하는 일을 막기 위해서다.

 

  1. 사용자 A가 한 컴퓨터에서 작업
  2. access 토큰이 만료될 만큼의 시간이 지나고 사용자 B가 해당 컴퓨터에서 사용자 A로 인증
  3. 서버는 DB에 매칭되는 정보가 있으니 같은 사용자 A로 간주
  4. 사용자 B는 만료된 access 토큰만으로 추가 인증 없이 사용자 A 계정을 사용할 수 있음

refresh 토큰 만료 시

오랫동안 로그인하지 않은 것으로 간주해 다른 사용자로 판정해 로그인 페이지로 보낸다.

인증 캐시가 초기화될 때

로컬 캐시라면 WAS, 리모트 캐시라면 redis 가 재시작 되면서 초기화되는 경우가 있다.

인증 시, DB 에서 access, refresh 토큰과 매칭되는 정보를 찾고 만료되지 않았다면 캐시를 새로 등록한다.

    private AuthMember getAuthMember(final String accessToken, final String refreshToken) {
        final AuthMember authMember = accessTokenMap.get(accessToken);
        // WAS 재시작 등으로 인해 로컬 캐시가 날아간 이후에 발생하는 인증 처리
        if (authMember == null) {
            AuthToken authToken = authTokenService.getAuthToken(accessToken, refreshToken);
            authTokenService.reIssueAuthToken(authToken);
            return accessTokenMap.get(authToken.getAccessToken());
        }
        // access 토큰 검증
        if (authTokenService.isAccessTokenValid(authMember.getCreatedAt())) {
            return authMember;
        }
        // access 토큰이 만료됐다면 refresh 토큰 검증
        AuthToken authToken = authTokenService.getAuthToken(accessToken, refreshToken);
        if (authTokenService.isRefreshTokenValid(authToken.getExpiredAt())) {
            authToken = authTokenService.reIssueAuthToken(authToken);
            return accessTokenMap.get(authToken.getAccessToken());
        }
        throw new UnauthorizedException("로그인 필요");
    }

마치면서

학부 초기에 보안을 공부하면서 자주 접해왔던 사례들을 이렇게 구성한다면 인증 문제를 예방할 수 있을 것이라 생각했다.

구성 과정에서 보안과 성능의 트레이드 관계를 어떻게 최소화할 수 있을지에 대해 고민이 많았지만, 명확한 답을 찾는 것이 어려웠다.

업무 경험이 쌓인다면 이런 부분에서 나름의 근거를 찾아 적용할 수 있을 것이다.