My-Books-projects / etc

0 stars 1 forks source link

Key Manager 등록 Front AOP 수정 , my-books.store 인증/인가처리 flow 정리 #167

Open masiljangajji opened 8 months ago

masiljangajji commented 8 months ago

기술공유 issue https://github.com/nhnacademy-be4-My-Books/etc/issues/161

Auth , Gateway 토큰 Secret Key Manager 등록 Front Payco Oauth2.0 부를 떄 사용하는 Client Id , Client Secret 등록

AOP 스펙 수정 메서드에 Request , Response 를 인자로 받지 않도록 (기존에는 aop적용 메서드에 인자로 request, response 필요했음)

또한 Annotation 이름을 Trace -> RequiredAuthorization 변경

my-books.store 인증/인가처리

로그인 요청 -> 로그인 정보 확인 -> 로그인 완료 -> 토큰 발행 -> Identity-Cookie 라는 이름의 쿠키에 토큰을 넣음

-> 서버는 사용자 인증과 관련된 정보를 갖고있지 않음

-> 사용자가 요청을 줌

1. 권한이 필요없다면? (장바구니,물건정보) 그대로 게이트웨이가 요청보냄

2. 권한이 필요하다면? (유저페이지,관리자페이지) Identity-Cookie 에 있는 토큰 값을 Authorization 헤더에 등록 후 요청보냄

게이트웨이에서 헤더에있는 토큰을 검증한 후 권한이있는지 인가처리함




에러 발생 시 HttpStatus Code

  1. 권한없는경우 403 , 인덱스 페이지로 이동시킴
  2. 토큰위조된경우 401 , 쿠키 삭제한 뒤 로그인페이지로 이동
  3. 토큰 만료된경우 401 , 리프래시토큰을 확인한 후 유효하면 토큰 재발급 후 요청 다시보내기 , 아니라면 로그인페이지로
  4. 유저가 활성상태가 아님 401 , 유저인증 페이지로 이동

에러메시지

TOKEN_EXPIRED("Token has expired"),
INVALID_TOKEN("Invalid token"),
INVALID_ACCESS("Access forbidden"),
INACTIVE_USER("User is inactive");

인가처리 완료시

X-User-Id 헤더에 유저정보를 담아 보냄

권한이 필요할시 AOP 를 적용해 위의 절차를 진행 함


GateWay

@Bean
    public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
        return builder.routes()
                .route("auth", r -> r.path("/auth/**") // 전부 허용 할 것
                        .uri(urlProperties.getAuth()))
                .route("api_user", p -> p.path("/api/member/**") // 유저 권한이 필요 한 경우
                        .filters(f -> f.filter(new UserAuthFilter().apply(new UserAuthFilter.Config())))
                        .uri("lb://RESOURCE-SERVICE")
                )
                .route("api_admin", p -> p.path("/api/admin/**") // 어드민 권한이 필요 한 경우
                        .filters(f->f.filter(new AdminAuthFilter().apply(new AdminAuthFilter.Config())))
                        .uri("lb://RESOURCE-SERVICE")
                )
                .route("api_all", p -> p.path("/api/**") // 권한이 필요 없는 경우
                        .uri("lb://RESOURCE-SERVICE")
                )
                .build();
    }

GateWayFilter

public UserAuthFilter() {
        super(Config.class);
    }

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {

            String token = HttpUtils.getAuthorizationHeaderValue(exchange); // auth 헤더값 찾기
            String originalPath = HttpUtils.getPath(exchange); // 원래 url

            DecodedJWT jwt;

            try {
                jwt = TokenValidator.isValidToken(token); // 토큰 만료시간 , 위조여부 검증
                TokenValidator.isValidAuthority(jwt, Config.STATUS_ACTIVE, Config.ROLE_USER, Config.ROLE_ADMIN); // 올바른 권한 갖고있는지 , 활성상태인지 검증

            } catch (InvalidStatusException e) {
                return ErrorResponseHandler.handleInvalidToken(exchange, HttpStatus.FORBIDDEN,
                        ErrorMessage.INACTIVE_USER.getMessage()); //  토큰은 유효한데 활성 상태 아님
            } catch (ForbiddenAccessException e) {
                return ErrorResponseHandler.handleInvalidToken(exchange, HttpStatus.FORBIDDEN,
                        ErrorMessage.INVALID_ACCESS.getMessage()); //  토큰은 유효한데 권한 없음 403
            } catch (TokenExpiredException e) {
                return ErrorResponseHandler.handleInvalidToken(exchange, HttpStatus.UNAUTHORIZED,
                        ErrorMessage.TOKEN_EXPIRED.getMessage()); // 토큰 만료됐음 인증 필요 401
            } catch (JWTVerificationException e) {
                return ErrorResponseHandler.handleInvalidToken(exchange, HttpStatus.UNAUTHORIZED,
                        ErrorMessage.INVALID_TOKEN.getMessage()); // 토큰이 조작됐음 올바르지 않은 요청 401
            }

            ServerHttpRequest modifiedRequest = exchange.getRequest().mutate()
                    .path(originalPath.replace("/api/member/", "/api/")) // 새로운 URL 경로 설정
                    .header("X-User-Id", jwt.getSubject()) // 유저 정보 보내기
                    .build();

            ServerWebExchange modifiedExchange = exchange.mutate()
                    .request(modifiedRequest)
                    .build();

            return chain.filter(modifiedExchange);
        };
    }

    public static class Config { // // 필요한 전달할 설정
        private static final String ROLE_USER = "ROLE_USER";
        private static final String ROLE_ADMIN = "ROLE_ADMIN";
        private static final String STATUS_ACTIVE = "활성";
    }

AOP

@Around(value = "@annotation(store.mybooks.front.auth.Annotation.RequiredAuthorization)")
    public Object afterMethod(ProceedingJoinPoint joinPoint)
            throws Throwable {

        HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(
                RequestContextHolder.getRequestAttributes())).getRequest();
        HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();

        // RequestContextHolder = 스프링이 제공하는 RequestAttributes 의 구현체
        // Spring Context 가 관리하는 것이 아님 , 현재 요청과 관련된 정보를 ThreadLocal에 저장해서 사용
        // 요청이 처리되는 동안만 유효하며 , 요청이 끝난다면 정보는 자동으로 삭제됨
        RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
        RequestContextHolder.currentRequestAttributes()
                .setAttribute("authHeader", Utils.addAuthHeader(request), RequestAttributes.SCOPE_REQUEST);

        try {
            return joinPoint.proceed();
        } catch (RuntimeException e) {

            String error = e.getMessage();

            log.warn(error);

            if (error.contains(ErrorMessage.INVALID_ACCESS.getMessage())) { // 권한이 없음 -> ControllerAdvice 가 잡아서 index 페이지로
                throw new AccessIdForbiddenException(); // 인덱스로 보내기
            } else if (error.contains(ErrorMessage.TOKEN_EXPIRED.getMessage())) { // 토큰만료 재발급 받고 다시 부르기
                // 리프래시 토큰이 만료면 throw Authentication -> 로그인하세요
                // 리프래시 토큰 만료 아니면 토큰은 멀쩡하니 다시부르면 원래 요청대로 가짐
                joinPoint.proceed();
            } else if (error.contains(ErrorMessage.INVALID_TOKEN.getMessage())) { // 토큰위조됨 쿠키삭제하기 -> ControllerAdvice 가 잡아서 로그인페이지로 
                Utils.deleteJwtCookie(Objects.requireNonNull(response));
                throw new AuthenticationIsNotValidException();
            } else if(error.contains(ErrorMessage.INACTIVE_USER.getMessage())){ // 활성상태가 아님 -> ControllerAdvice 가 잡아서 인증사이트로
                throw new StatusIsNotActiveException();
            }
        }

        throw new RuntimeException();
    }

AOP 는 RequiredAutorization 어노테이션이 있는 경우만 적용됨

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RequiredAuthorization {
}

권한이 필요함을 마킹해주기 위한 어노테이션 , 다른 기능은 하지 않음


메서드 적용 방법

@RequiredAuthorization
    public void modifyUserPassword(UserPasswordModifyRequest modifyRequest) {

        ResponseEntity<UserPasswordModifyResponse> responseEntity =
                restTemplate.exchange(gatewayAdaptorProperties.getAddress() + URL_MEMBER + "/password",
                        HttpMethod.PUT,
                        new HttpEntity<>(modifyRequest,Utils.getAuthHeader()),
                        new ParameterizedTypeReference<>() {
                        });

        if (responseEntity.getStatusCode() != HttpStatus.OK) {
            throw new RuntimeException();
        }
    }

유저의 비밀번호를 변경하는 기능 (인가처리가 필요함)

URL 은 /api/member/user/password 게이트웨이에서는 /api/member 를 보고 유저권한이 필요하구나! 생각하고 인가처리 후에 api서버로 요청을 보낼때는 /api/user/password 로 변경하여 보냄

만약 어드민 권한이 필요하다면? /api/admin/user/password 식으로 보내야 함