5.배달플랫폼 - User : JWT 인증, Interceptor

ys·2024년 3월 3일

배달플랫폼

목록 보기
6/8
  • 이제 JWT 기능을 추가해보자
  • 로그인에 성공을 하면 -> jwt token을 만들고
  • 토큰이 만료되면, 재발급을 하는 기능
  • 그리고 마지막으로 발급된 토근을 가지고 -> validation하는 기능 총 3가지 기능을 만들자
  • 우리는 지금 JWT Token을 쓰기로 했지만, 언제든지 다른 인증방법으로 바뀔 수 있다
  • 그렇기에 아까 말했던 3가지 기능을 가진 인터페이스를 만들고
  • 각각의 인증방법에 따라 구현을 해, 확장성을 높여 SRP원칙을 지켜보자!
  • 다음과 같이 TokenHelperIfs를 먼저 만든다

TokenDto

@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class TokenDto {

    private String token;
    private LocalDateTime expiredAt;
}
  • String으로 token을 가지고 있고
  • LocalDateTime으로 만료시간을 가지고 있다

TokenHelperIfs

public interface TokenHelperIfs {

    TokenDto issueAccessToken(Map<String,Object> data);

    TokenDto issueRefreshToken(Map<String,Object> data);

    Map<String,Object> validationTokenWithThrow(String Token);

}
  • 그 다음 TokenHelperIfs를 구현한, JWTTokenHelper 클래스를 만들자

JWTTokenHelper

application.yml에 token정보 입력

token:
  secret:
    key: -------------------------------------------
  access-token:
    plus-hour: 1
  refresh-token:
    plus-hour: 12
  • @Value("${}") 안에 application.yml의 정보를 넣어서
  • 설정정보를 사용할 수 있다

TokenResponse

@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class TokenResponse {

    private String accessToken;
    private LocalDateTime accessTokenExpiredAt;
    private String refreshToken;
    private LocalDateTime refreshTokenExpiredAt;
}
@Component
public class JwtTokenHelper implements TokenHelperIfs {

    @Value("${token.secret.key}")
    private String secretKey;
    @Value("${token.access-token.plus-hour}")
    private Long accessTokenPlusHour;
    @Value("${token.refresh-token.plus-hour}")
    private Long refreshTokenPlusHour;

    @Override
    public TokenDto issueAccessToken(Map<String, Object> data) {
        LocalDateTime expiredLocalDateTime = LocalDateTime.now().plusHours(accessTokenPlusHour);

        java.util.Date expiredAt = Date.from(expiredLocalDateTime.atZone(ZoneId.systemDefault()).toInstant());

        SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes());

        String jwtToken = Jwts.builder()
                .signWith(key, SignatureAlgorithm.HS256)
                .setClaims(data)
                .setExpiration(expiredAt)
                .compact();

        return TokenDto.builder()
                .token(jwtToken)
                .expiredAt(expiredLocalDateTime)
                .build();
    }

    @Override
    public TokenDto issueRefreshToken(Map<String, Object> data) {
        var expiredLocalDateTime = LocalDateTime.now().plusHours(refreshTokenPlusHour);

        var expiredAt = Date.from(
                expiredLocalDateTime.atZone(
                        ZoneId.systemDefault()
                ).toInstant()
        );

        var key = Keys.hmacShaKeyFor(secretKey.getBytes());

        var jwtToken = Jwts.builder()
                .signWith(key, SignatureAlgorithm.HS256)
                .setClaims(data)
                .setExpiration(expiredAt)
                .compact();

        return TokenDto.builder()
                .token(jwtToken)
                .expiredAt(expiredLocalDateTime)
                .build();
    }

    @Override
    public Map<String, Object> validationTokenWithThrow(String token) {
        SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes());

        JwtParser parser = Jwts.parserBuilder()
                .setSigningKey(key)
                .build();

        try {
            Jws<Claims> result = parser.parseClaimsJws(token);
            return new HashMap<String, Object>(result.getBody());

        }catch (Exception e){
            if (e instanceof SignatureException){
                // 토큰이 유효하지 않을 때
                throw new ApiException(TokenErrorCode.INVALID_TOKEN, e);
            }
            else if (e instanceof ExpiredJwtException) {
                // 만료된 토큰
                throw new ApiException(TokenErrorCode.EXPIRED_TOKEN,e);
            }
            else {
                // 그 외 처리
                throw new ApiException(TokenErrorCode.INVALID_TOKEN,e);
            }
        }

    }
}
  • 먼저 issueAccessToken 토큰을 만다는 메서드이다
  • data를 HashMap<String,Object>로 받은 후, 이부분을 JWT의 claims부분으로 사용한다
  • 우리는 JWT 외부 라이브러리를 가져온 후, 이미 구현된 내용을 사용한다
  • 그렇기 때문에 setExpirationDate타입으로 변경해서 넣어줘야 한다
  • issueRefreshToken은 토큰을 다시 받는 부분이다
  • @Value를 통해 시간을 재설정한후, 다시 token을 받는다
  • 마지막으로, issueRefreshToken부분이다
    • token을 검증하는 부분이다
    • 먼저 token을 파싱할 parser부분을 만들고, 결과를 HashMap<String,Object>에 넣어서 반환한다
    • 파싱해서 나온 결과 값이 유효하지 않거나, 만료되었다면 Error을 만들고, ApiException에 넣어서 보내준다
    • 이때 Error에 대한 확장이 인터페이스로 구현했기 때문에 OCP부분을 잘 만족하는 코드라고 할 수 있다

추가된 TokenErrorCode

@AllArgsConstructor
@Getter
public enum TokenErrorCode implements ErrorCodeIfs {

    INVALID_TOKEN(400,2000,"유효하지 않은 토큰입니다"),
    EXPIRED_TOKEN(400,2001,"만료된 토큰입니다"),
    TOKEN_EXCEPTION(400,2002,"트큰 -> 알 수 없는 에러입니다"),
    AUTHORIZATION_TOKEN_NOT_FOUND(400,2003,"인증 헤더 토큰이 없습니다");

    private final Integer httpStatusCode; // 상응하는 http status 코드
    private final Integer errorCode;  // 인터널 에러 코드
    private final String description; //설명

}

로그인 성공

  • 클라이언트가 email,password를 잘 보냈다면, 서버에서는 로그인이 성공된다
  • 이 때, 서버는 JWT Token을 생성해서 header에 넣어서 보내준다

UserOpenApiController

@PostMapping("/login")
    public Api<TokenResponse> login(@Valid @RequestBody Api<UserLoginRequest> request){
        TokenResponse response = userBusiness.login(request.getBody());
        return Api.OK(response);
    }

UserBusiness

@Transactional
    public TokenResponse login(UserLoginRequest request) {
        UserEntity userEntity = userService.login(request.getEmail(), request.getPassword());

        TokenResponse tokenResponse = tokenBusiness.issueToken(userEntity);

        return tokenResponse;
    }

TokenBusiness

public TokenResponse issueToken(UserEntity userEntity){
        return Optional.ofNullable(userEntity)
                .map(ue->{
                    return ue.getId();
                })
                .map(userId->{
                    TokenDto accessToken = tokenService.issueAccessToken(userId);
                    TokenDto refreshToken = tokenService.issueRefreshToken(userId);
                    return tokenConverter.toResponse(accessToken,refreshToken);

                })
                .orElseThrow(()->new ApiException(ErrorCode.NULL_POINT));
    }
  • UserBusiness에서 로그인이 성공했다면
  • tokenBusniness에서 issueToken을 실행하는데,
  • Optional을 이용해, 로그인된 UserEntity의 id를 가져오고,
  • id를 가지고 토큰 발행,토큰 재발행을 한 값을 컨버터에 넣어서, TokenResponse로 만들어서 반환한다
  • 이때 만약 빈값이라면,,, -> null_point 에러를 내준다
  • 다시 UserOpenApiController을 본다면
  • Api<TokenConverter>을 반환해준다

  • 이제 token을 클라이언트가 http message를 통해 받게 된다
  • 받은 클라이언트는, 서버에 로그인된 정보를 사용해서 request을 한다
  • 이 때, 인증받은 token을 다시 서버에 포함한채로 request를 하게 된다
  • 서버는 Interceptor부분을 통해서 다음 부분을 처리한다

이제 token을 validation!

  • 아까 TokenHelperIfs를 구현한, JWTTokenHelper의 validationTokenWithThrow을 이용하자

TokenService

public Long validationToken(String token){
        Map<String, Object> map = tokenHelperIfs.validationTokenWithThrow(token);
        Object userId = map.get("userId");

        Objects.requireNonNull(userId, ()->{throw new ApiException(ErrorCode.NULL_POINT);
        });

        return Long.parseLong(userId.toString());
    }
  • 파라미터로 String token을 받았다고 생각하고,
  • tokenHelperIfs의 validationTokenWithThrow를 이용해 "userid"라고 HashMap에 집어넣은 후 반환한다
  • map에서 get을 이용해 찾는다
  • 이 때, userId는 JWT token 검증 로직이 만족된 id이다
  • Objects.requiredNonNull을 통해 검증을하고, ull이면 ullpointException을 낸다
  • obejct로 반환된 userId를 Long타입으로 parseLong해서 반환한다

TokenBusiness

public Long validationAccessToken(String accessToken){
        Long userId = tokenService.validationToken(accessToken);
        return userId;
    }
  • business영역에서 tokenService의 validationToken을 이용해, 검증을 완료하고 검증이 완료된 userId를 반환한다

지금까지, String token을 받아서 검증에 성공했다면, 성공한 userId를 반환하는 로직을 만들었다
그럼 어떻게, request에서 token을 헤더로 넣은 정보를 검증할까??


Interceptor 영역에서 token 검증

  • 음 filter에서 token을 검증해도 되지 않을까??
  • uri,header,cookie,session부분들을 filter에서 검증해도 좋다
  • 하지만, filter영역에서는 handeler Mapping이 아직되지 않았기 때문에, mapping 되는 url이 인증권한이 필요한 곳인지 모른다!
  • 그렇기 때문에, Handler Mapping이 완료되어서, 어느 controller로 이동하는지 아는 Handler Interceptior영역에서 token을 검증한다
  • 여기서 token을 검증한 후, 통과시킬지 말지를 결정한다

Interceptor

@Slf4j
@RequiredArgsConstructor
@Component
public class AuthorizationInterceptor implements HandlerInterceptor {

    private final TokenBusiness tokenBusiness;
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        log.info("Authorization Interceptor url : {}",request.getRequestURI());

        // WEB, chrome의 경우 GET, POST OPTION 이라는 api를 요청해서, 해당 메서드를 지원하는지 체크하는 api -> pass
        if (HttpMethod.OPTIONS.matches(request.getMethod())){
            return true;
        }

        // js, htm, png같은 resources를 요청하는 경우 -> pass
        if (handler instanceof ResourceHttpRequestHandler){
            return true;
        }

        // TODO header 검증
        String accessToken = request.getHeader("authorization-token");
        if (accessToken == null){
            throw new ApiException(TokenErrorCode.AUTHORIZATION_TOKEN_NOT_FOUND);
        }

        Long userId = tokenBusiness.validationAccessToken(accessToken);

        if (userId !=null){
            RequestAttributes requestAttributes = Objects.requireNonNull(RequestContextHolder.getRequestAttributes());
            requestAttributes.setAttribute("userId", userId,RequestAttributes.SCOPE_REQUEST);
            return true;
        }
        throw new ApiException(ErrorCode.SERVER_ERROR,"인증실패");
    }
}
  • Interceptor 영역은 request에서 getHeader("authorization-token")을 찾는다
  • 클라이언트가 token을 request header에 "authorization-token"이라는 이름으로 태워서 보내준다고 약속!!!
  • 먼저 비었는지 확인하고 -> 비었으면 Authorization_token_not_found 에러를 내준다
  • 이렇게 필요한 오류를 확장해서 추가해준다!!

🤔 RequestAttributes

  • RequestAttributesSpring에서 요청 단위의 속성을 제어하기 위한 인터페이스이다
  • HTTP 요청의 범위 내에서 속성을 저장하고 검색하는 메서드를 제공합니다.
  • ServletRequestAttributes는 이 인터페이스를 구현한 클래스 중 하나로, HTTP Servlet 요청에서 속성을 관리하는 데 사용됩니다.
  • response가 나가기전 까지 글로벌하게 유지되는, 쓰레드 로컬이다
  • SCOPE_REQUEST같이 범위를 설정할 수 있다
  • 이러한 글로벌한 영역이므로, Interceptor에서 setAttribute을 통해 userId를 저장해놨으므로
  • 컨트롤러에서 getAttribute를 통해 찾아서 사용할 수 있다
  • 이제 컨트롤러에서
    • 다음과 같이, requestAttributes를 다시 부르고
    • getAttribute("userId", RequestAttributes.SCOPE_REQUEST)를 통해 인증을 받은 userId값을 가져올 수 있다

UserBusiness

public UserResponse me(Long userId) {

        UserEntity userEntity = userService.getUserWithThrow(userId);
        UserResponse response = userConverter.toResponse(userEntity);
        return response;
    }
  • 인증이 된 id를 가지고 userService에서 userEntity를 찾아오고
  • converter을 이용해, response로 변환 후, 반환한다

결국 Interceptor영역에서, 오류가 나지 않았다면 -> token이 인증된 후
RequestAttributesuserId라고 인증이 된 이용자의 pk값을 넣어둔다

  • 로그인이 필요한 페이지에서는 이 부분을 이용해, 이 값이 비어있지 않다면 -> 그 값을 가지고 요청에 대한 응답을 내려준다

그런데,,, 항상 이렇게 RequestAttributes을 복잡하게 찾아서 값을 꺼내와야 할까???

Resolver와 어노테이션을 이용

UserSession 어노테이션

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface UserSession {
}
  • 인증이 확인됬다는 어노테이션을 하나 만든다

User

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {

    private Long id;

    private String name;

    private String email;

    private String password;

    private UserStatus status;

    private String address;

    private LocalDateTime registeredAt;
    private LocalDateTime unregisteredAt;
    private LocalDateTime lastLoginAt;
}
  • 인증확인을 위한 어노테이션의 타입을 확인하기 위한 User 클래스

UserSessionReolver

@Component
@RequiredArgsConstructor
public class UserSessionResolver implements HandlerMethodArgumentResolver {

    private final UserService userService;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        // 지원하는 파라미터 체크, 어노테이션 체크
        //1. 어노테이션이 있는지 체크
        boolean annotation = parameter.hasParameterAnnotation(UserSession.class);
        //2. 파라미터 타입 체크
        boolean parameterType = parameter.getParameterType().equals(User.class);
        return (annotation && parameterType);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        // support parameter에서  true 반환시 여기 실행

        // request context holder에서 찾아오기
        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
        Object userId = requestAttributes.getAttribute("userId", RequestAttributes.SCOPE_REQUEST);

        UserEntity userEntity = userService.getUserWithThrow(Long.parseLong(userId.toString()));

        // 사용자 정보 세팅
        return User.builder()
                .id(userEntity.getId())
                .name(userEntity.getName())
                .email(userEntity.getEmail())
                .status(userEntity.getStatus())
                .password(userEntity.getPassword())
                .address(userEntity.getAddress())
                .registeredAt(userEntity.getRegisteredAt())
                .unregisteredAt(userEntity.getUnregisteredAt())
                .lastLoginAt(userEntity.getLastLoginAt())
                .build();
    }
}
  • HandlerMethodArgumentResolver 를 구현한다
  • supportsParameter를 통해 파라미터이고, 타입이 User타입인지 확인한다
  • 이 둘이 모두 true이면 resolveArgument가 실행된다
  • requestAttributes에서 id를 찾아오고 이를 통해 UserEntity를 찾고
  • 이를 User로 builder패턴을 이용해 만든 후 반환해준다

UserApiController

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/user")
public class UserApiController {

    private final UserBusiness userBusiness;

    @GetMapping("/me")
    public Api<UserResponse> me(@UserSession User user){

        UserResponse response = userBusiness.me(user.getId());
        return Api.OK(response);
    }
}
  • 이제 @UserSession 어노테이션과 User클래스를 파라미터에 넣어주면
  • supportsParameter조건이 모두 만족되므로, resolveArgument가 실행된다
  • User에 값이 잘 들어간 채로 실행되기 때문에
  • user에서 pk를 꺼내서 -> UserResponse를 찾을 수 있고
  • Ap.Ok(response)를 넣어서 실행할 수 있다
  • 이렇게 어노테이션과 handelr을 이용하면, 코드를 간략하게 만들 수 있다

WebConfig

@Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(userSessionResolver);
    }
  • WebConfig에 다음 userSeesionResolver을 꼭 등록해줘야 한다!!
  • 당연히 UserSessionResolver에도 @Component를 이용해 스프링 빈으로 등록을 해줘야 한다
  • 지금까지 User 엔티티에 대한 로그인 기능을 성공했을 때
  • token을 생성하고, 클라이언트에게 token을 보내고
  • 클라이언트가 그 token을 다시 가지고 요청했을 때,
  • Interceptor을 이용해, 검증 후 검증이 성공했다면
  • pk를 RequestAttribute에 담아서, 인증이 필요한 컨트롤러에서 사용할 수 있게 하였다
  • 이부분도 handelr을 이용해, 어노테이션을 이용해 RequestAttribute에서 값을 찾아오는 과정을 줄여서 코드의 불필요한 중복을 줄여줄 수 있다
  • 만약, RequestAttribute에서 값을 가져오지 못한다면, 비지니스 로직에 따라, jwtToken을 인증하지 못하였다고 생각한다!
profile
개발 공부,정리

0개의 댓글