Post

커스텀 JWT 인증 시스템 개발

스프링 시큐리티를 사용하지 않고 JWT를 활용한 인증시스템을 개발한 과정에 대해서 설명합니다. 아이디나 이메일, 그리고 패스워드로 로그인 하는 환경에서 개발하였습니다.

이 글에서는 아이디(ID)와 비밀번호(Password)로 로그인하는 경우에 대해서 설명합니다.

로그인

로그인 요청에서는 아래와 같은 요구사항이 존재합니다.

  • 회원가입 된 사용자인지 확인
  • 로그인 정보 데이터베이스에 저장
  • 액세스 토큰을 발급
  • 리프레시 토큰을 발급
  • 액세스 토큰을 Authorization 헤더로, 리프레시 토큰은 응답 메세지 바디로 전달

image

1. Member 엔티티 조회

회원가입된 유저의 정보는 member 테이블에 저장되고 Member 엔티티에 매핑되어 있습니다. JWT 토큰의 페이로드에는 유저의 식별자(member_id)가 담겨질 것이기 때문에 사용자가 전달한 아이디와 패스워드를 가지고 있는 유저가 있는지 조회합니다.

2. 액세스 토큰 생성

조회한 Member 엔티티의 식별자(member_id)를 사용해서 JWT 액세스 토큰을 만듭니다. 액세스 토큰이라는 것을 식별하기 위해서 애플리케이션에서 원하는 식별자를 하나 붙여줄 겁니다. 여기서는 STUDY 라는 접두사를 붙여 액세스 토큰이라는 것을 구분할 것입니다. 붙여진 접두사는 나중에 사용자가 요청을 보냈을 때 액세스 토큰이라는 것을 구분한 이후 다시 지워질 겁니다.

1
2
3
public String createAccessToken(Member member) {
    return TOKEN_TYPE.concat(tokenProvider.createToken(member.getId()));
}

TOKEN_TYPESTUDY 라는 문자열을 담고 있는 상수입니다. 아까도 말했듯이 액세스 토큰의 접두사로 사용됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Component
@RequiredArgsConstructor
@EnableConfigurationProperties(TokenProperties.class)
public class TokenProvider {

    private final TokenProperties tokenProperties;

    public String createToken(final Long memberId) {
        Map<String, Long> claims = createClaimByMemberId(memberId);
        Date now = new Date();
        Date expiration = new Date(now.getTime() + tokenProperties.expiration().access());
        return Jwts.builder()
            .claims(claims)
            .expiration(expiration)
            .issuedAt(now)
            .signWith(getSecretKey())
            .compact();
    }

    public Map<String, Long> createClaimByMemberId(final Long memberId) {
        Map<String, Long> claims = new HashMap<>();
        claims.put("id", memberId);
        return claims;
    }

    private SecretKey getSecretKey() {
        return Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(tokenProperties.secretKey()));
    }

}

TokenProvidermemberId와 함께 JWT토큰생성을 요청합니다. 토큰의 페이로드에 식별자 정보를 담고 나중에 토큰과 함께 온 요청에서 식별자를 추출해, 해당 member_id를 가지는 유저가 존재하는지 확인하는 용도로 사용됩니다.

3. 리프레시 토큰 생성

리프레시 토큰은 액세스 토큰이 만료되었을 경우 갱신할 수 있도록 해주는 토큰입니다. 로그인 정보를 저장하는 테이블인 login_info 테이블에 유저의 식별자(member_id)를 외래키로 가집니다.

login_info테이블에 조회한 유저의 member_id 값을 가지는 튜플이 있는지 조회한 후, 이미 로그인 정보가 존재한다면 그 엔티티의 리프레시 토큰을 사용하고, 그렇지 않다면 새로운 엔티티를 만들어 테이블에 저장합니다. 리프레시 토큰을 생성할 때는 액세스 토큰을 생성하는 것과 동일하게 createToken() 메서드를 사용합니다. 액세스토큰을 만들 때와의 차이점은 접두사의 여부 뿐입니다.

1
2
3
4
5
6
7
8
9
10
11
12
// 리프레시 토큰 생성
public String publishRefreshToken(Member member) {
    return loginInfoRepository.findByMemberId(member.getId())
        .orElseGet(() -> saveLoginInfo(member))
        .getRefreshToken();

}

// 새로운 로그인 정보(LoginInfo) 엔티티 생성 -> 튜플 저장
private LoginInfo saveLoginInfo(Member member) {
    return loginInfoRepository.save(new LoginInfo(member, tokenProvider.createToken(member.getId())));
}

4. 로그인 응답 반환

액세스 토큰과 리프레시 토큰을 어떻게 전달할지는 요구사항에 따라 다르다. 여기서는 액세스 토큰은 Authorization 헤더에 담아, 그리고 리프레시 토큰은 응답 바디에 담아 제공하였다.

1
2
3
4
5
6
7
8
9
10
11
12
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest loginRequest) {

    Member member = loginService.login(loginRequest.id(), loginRequest.password());

    String accessToken = loginService.createAccessToken(member);
    String refreshToken = loginService.publishRefreshToken(member);

    return ResponseEntity.ok()
        .header(Constant.AUTHORIZATION, accessToken)
        .body(new LoginResponse(refreshToken));
}

5. 예외 상황 처리

유저가 제출한 아이디와 패스워드로 가입된 유저 엔티티(Member)가 조회되지 않는 경우 예외를 던지도록 구현하였습니다. 던져진 예외는 컨트롤러 어드바이스try-catch 문으로 감싸 원하는 예외처리를 해주면 됩니다.

인증

클라이언트는 액세스 토큰과 리프레시 토큰을 받은 후, 요청 시마다 토큰을 함께 전달한다. 인증 방식은 구현 방법에 따라 다를 수 있다. 지금은 특정 애노테이션이 붙은 파라미터를 처리하는 ArgumentResolver의 내부에서 인증을 처리할 것이다.

먼저 애노테이션을 하나 정의하자. 인증이 필요하거나, 멤버정보가 필요할 때 컨트롤러 파라미터에 붙일 애노테이션이다.

1
2
3
4
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginMember {
}

이 LoginMember 애노테이션이 붙은 파라미터를 전달해줄 ArgumentResolver를 정의한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Component
@RequiredArgsConstructor
@Slf4j
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {

    public static final String ID = "id";

    private final MemberRepository memberRepository;
    private final TokenProvider tokenProvider;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(LoginMember.class);
    }

    @Override
    public AuthInfo resolveArgument(
        final MethodParameter parameter,
        final ModelAndViewContainer mavContainer,
        final NativeWebRequest webRequest,
        final WebDataBinderFactory binderFactory) throws Exception {

        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
        String accessToken = extractAccessToken(request);

        if (accessToken.equals(GUEST)) {
            throw new RuntimeException("로그인한 유저만 접근할 수 있습니다.");
        }

        Long memberId = tokenProvider.getPayLoad(accessToken).get(ID, Long.class);
        Member member = memberRepository.findById(memberId)
            .orElseThrow(() -> new RuntimeException(String.format("존재하지 않는 유저 식별자 입니다. (id=[%d])", memberId)));

        return AuthInfo.from(member);
    }

    private String extractAccessToken(final HttpServletRequest request) {
        if (request.getHeader(AUTHORIZATION) != null && request.getHeader(AUTHORIZATION).startsWith(TOKEN_TYPE)) {
            return request.getHeader(AUTHORIZATION).replace(TOKEN_TYPE, "");
        }
        return GUEST;
    }
}
  1. LoginMember 애노테이션이 붙은 파라미터네 대해서만 적용되며, TokenProvider에서 페이로드를 파싱한다. 이 과정에서 토큰의 구조가 올바른지, 만료시간이 지나지는 않았는지 등도 검사하게 된다.
  2. 찾아온 유저의 아이디(member_id)를 사용해 유저 엔티티를 찾아온다.
  3. 만약 엔티티가 존재하지 않는다면 예외를 반환한다.
  4. 엔티티가 존재한다면 엔티티정보를 기반으로 인증 객체를 반들어 반환한다.

LoginMemberArgumentResolver에 의해 만들어진 다음과 같이 컨트롤러 파라미터에 전달된다.

1
2
3
4
5
@GetMapping("/auth")
public ResponseEntity<Void> auth(@LoginMember AuthInfo authInfo) {
    log.info("인증된 사용자 아이디=[{}]", authInfo.id());
    return null;
}

토큰 갱신

리프레시 토큰마저 만료된다면 다시 로그인 해야한다.

This post is licensed under CC BY 4.0 by the author.