经验分享:使用SPQR和自定义注释的GraphQL实现权限授权 - Marcos Abel


Trabe,我们一直在使用GraphQL。我们开始使用纯JavaScript项目来 实现这项技术,但在意识到GraphQL可和React一起运行之后,我们决定采用它来处理涉及React UI的所有新项目。
由于我们的许多项目都基于服务器端Java堆栈,因此我们花了一些时间对Java生态系统的可用GraphQL库进行基准测试。我们最终决定采用GraphQL SPQR,因为我们认为它更适合我们的工作流程:它易于使用和配置,基于注释并可以很好地与Spring Boot配合使用
GraphQL SPQR为我们提供了一组注释,可以轻松生成我们的模式,但是,作为项目的早期阶段,它不支持我们可能拥有的所有授权和身份验证需求。 

授权与身份验证
这是不同的关注点,一个经典的问题,很多时候在互联网上得到了回答,但仍需要一个简短的提醒,以避免误解:
身份验证是确定某人确实是他们声称的那个人的过程。
授权是指确定允许谁做什么的规则。例如,Adam可能被授权创建和删除数据库,而Usama仅被授权阅读。

在我们的场景中,使用Spring Security处理身份验证,SPQR的Spring Boot程序创建了一个常规控制器,因此我们可以使用常规的Spring Security来管理身份验证。
我们也可以考虑使用 spring安全支持进行授权,但它不能满足我们的需求。我们需要向GraphQL客户端发送有关错误的大量信息,如果不使用自定义解决方案,则无法轻松完成。

GraphQL响应的解剖
每个GraphQL响应都是一个Map映射,包含规范中描述的一组键key。发生错误时,映射Map应包含一个键,指明是哪个Key发生错误,错误errors包含一些尝试完成请求时发生的错误列表。错误中可能存在的键也已经在规范中定义。

典型错误如下所示:

{
  "errors": [
    {
     
"message": "Name for character with ID 1002 could not be fetched.",
     
"locations": [ { "line": 6, "column": 7 } ],
     
"path": [ "hero", "heroFriends", 1, "name" ],
     
"extensions": {
       
"code": "CAN_NOT_FETCH_BY_ID",
       
"timestamp": "Fri Feb 9 14:33:09 UTC 2018"
      }
    }
  ]
}

GraphQL规范为我们提供了一种包含附加信息的机制:我们可以添加extensions键并在错误中包含自定义值的映射Map。

将GraphQL错误发送到客户端
现在我们知道规范允许服务器向GraphQL错误添加自定义字段。我们只需要知道如何使用SPQR来做到这一点。
SPQR使用GraphQL Java。SPQR为我们生成模式,允许我们使用代码优先方法并快速开发GraphQL API,但查询和变更的执行由GraphQL Java直接处理。因此,GraphQL Java也可以执行错误处理。
在解析器方法中抛出异常时,该异常将由ExecutionStrategy(GraphQL Java的一部分)处理。您可以完全自定义您的ExecutionStrategy任务以完成异常需要完成的任务,但GraphQL Java的作者在默认实现方面做得非常好,并且提供了以简单方式提供自定义错误的支持。
GraphQL Java提供了一个可以通过我们的异常实现的GraphQLError接口。
ExecutionStrategy(同步,异步和批处理)的所有默认实现都用SimpleDataFetcherExceptionHandler处理异常。
该类最终创建一个ExceptionWhileDataFetching,使用您包含在GraphQLError的信息来编写错误响应:

package graphql;

public class ExceptionWhileDataFetching implements GraphQLError {

    private final String message;
    private final List<Object> path;
    private final Throwable exception;
    private final List<SourceLocation> locations;
    private final Map<String, Object> extensions;

    public ExceptionWhileDataFetching(ExecutionPath path, 
        Throwable exception, SourceLocation sourceLocation) {
        this.path = assertNotNull(path).toList();
        this.exception = assertNotNull(exception);
        this.locations = Collections.singletonList(sourceLocation);
        this.extensions = mkExtensions(exception);
        this.message = mkMessage(path, exception);
    }

    private String mkMessage(ExecutionPath path, Throwable exception) {
        return format("Exception while fetching data (%s) : %s", path, 
                      exception.getMessage());
    }

   
/*
     * This allows a DataFetcher to throw a graphql error and have "extension data" 
     * be transferred from that exception into the ExceptionWhileDataFetching error 
     * and hence have custom "extension attributes" per error message.
     */

    private Map<String, Object> mkExtensions(Throwable exception) {
        Map<String, Object> extensions = null;
        if (exception instanceof GraphQLError) {
            Map<String, Object> map = ((GraphQLError) exception).getExtensions();
            if (map != null) {
                extensions = new LinkedHashMap<>();
                extensions.putAll(map);
            }
        }
        return extensions;
    }

    public Throwable getException() {
        return exception;
    }


    @Override
    public String getMessage() {
        return message;
    }

    @Override
    public List<SourceLocation> getLocations() {
        return locations;
    }

    @Override
    public List<Object> getPath() {
        return path;
    }

    @Override
    public Map<String, Object> getExtensions() {
        return extensions;
    }

    @Override
    public ErrorType getErrorType() {
        return ErrorType.DataFetchingException;
    }

    @Override
    public String toString() {
        return
"ExceptionWhileDataFetching{" +
               
"path=" + path +
               
"exception=" + exception +
               
"locations=" + locations +
                '}';
    }

    @SuppressWarnings(
"EqualsWhichDoesntCheckParameterClass")
    @Override
    public boolean equals(Object o) {
        return GraphqlErrorHelper.equals(this, o);
    }

    @Override
    public int hashCode() {
        return GraphqlErrorHelper.hashCode(this);
    }
}

这种行为允许我们生成自定义的,格式正确的GraphQL错误,你可能以为只是抛出某些异常,似乎不是什么大不了的事,但如果结合一些AOP魔术,它打开了一个错误管理可能性的世界。

用于授权的自定义注释
让我们从典型SPQR解析器的示例开始:

@GraphQLQuery
public SomeObject someQuery(@GraphQLId String someId, 
                            @GraphQLRootContext ContextObject context) {
    return someService.getSomeObject(someId);
}

我们在解析器中包含一个上下文对象。我们假设在项目中的ServletContextFactory为每个请求创建了一个上下文对象。此上下文对象包含有关应用程序当前用户的信息(例如,可以使用JWT令牌中包含的信息创建上下文对象)。
我们需要定义一个自定义注释,以便在需要执行授权逻辑时使用:

package io.trabe.blog.graphqldemo.aop.annotations;

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface CheckPrivileges {
    String value() default "";
}

并注释我们的示例解析器:

@GraphQLQuery 
@CheckPrivileges
public SomeObject someQuery(@GraphQLId String someId, 
                            @GraphQLRootContext ContextObject context) {
    return someService.getSomeObject(someId);
}

我们的解析器完成了。这个难题中唯一缺失的部分是AOP方面本身。

AOP授权
授权是一个复杂的主题。授权逻辑可以根据场景而变化。让我们假设这个故事在我们的场景中,我们需要检查使用服务方法检索的对象的某个字段的值。对于这种情况,我们可以使用Around建议执行授权:

package io.trabe.blog.graphqldemo.aop;

@Aspect
@Component
public class CheckPrivilegesAspect {

    private final ParameterNameDiscoverer parameterNameDiscoverer;

    @Autowired
    public CheckPrivilegesAspect(ParameterNameDiscoverer parameterNameDiscoverer) {
        this.parameterNameDiscoverer = parameterNameDiscoverer;
    }

    @Pointcut("execution(@io.trabe.blog.graphqldemo.aop.annotations.CheckPrivileges * *(..))")
    public void methodAnnotatedWithCheckPrivileges() {
    }

    @Pointcut(
"execution(public * *(..))")
    public void publicMethod() {
    }

    @Pointcut(
"execution(@io.leangen.graphql.annotations.GraphQLQuery * *(..))")
    public void methodAnnotatedWithGraphQLQuery() {
    }

    @Pointcut(
"execution(@io.leangen.graphql.annotations.GraphQLMutation * *(..))")
    public void methodAnnotatedWithGraphQLMutation() {
    }

    @Around(
"publicMethod() && methodAnnotatedWithCheckPrivileges() &&" +
           
"(methodAnnotatedWithGraphQLQuery() || methodAnnotatedWithGraphQLMutation())")
    public Object requirePermission(ProceedingJoinPoint joinPoint) throws Throwable {
        Optional<ContextObject> contextOptional = 
            getParamByType(joinPoint, ContextObject.class);

        if (!contextOptional.isPresent()) {
            throw new RuntimeException(
"Cannot check privileges: missing parameters!!");
        }
       
// Execute the advised  method
        SomeObject someObject = (SomeObject) joinPoint.proceed();
       
// Check the required condition
        if (someObject.getSomeField().equals(contextOptional.get().getExpectedValue())) {
            return someObject;
        }
       
// Unauthorized Query, throw graphql error
        throw new GraphQLExceptionWithErrorInformation(
            ErrorInformation.UNAUTHORIZED_ACCESS,
"Error message");
    }

    private <T> Optional<T> getParamByType(JoinPoint joinPoint, Class<T> paramClass) {
        try {
            for (Map.Entry<String, Object> param : getAllParams(joinPoint).entrySet()) {
                if (paramClass.isInstance(param.getValue())) {
                    return Optional.of((T) param.getValue());
                }
            }
            return Optional.empty();
        } catch (NoSuchMethodException e) {
            return Optional.empty();
        }
    }

    private Map<String, Object> getAllParams(JoinPoint joinPoint) 
        throws NoSuchMethodException {
        Map<String, Object> params = new HashMap<>();
        for (String param : parameterNameDiscoverer.getParameterNames(
            getMethod(joinPoint))) {
            params.put(param, getParam(joinPoint, param).orElse(null));
        }
        return params;
    }

    private Optional<Object> getParam(JoinPoint joinPoint, String paramName) {
        try {
            String[] paramNames = 
                parameterNameDiscoverer.getParameterNames(getMethod(joinPoint));
            Object[] values = joinPoint.getArgs();
            for (int i = 0; i < paramNames.length; i++) {
                if (paramName.equals(paramNames[i])) {
                    if (values[i] != null) {
                        return Optional.of(values[i]);
                    } else {
                        return Optional.empty();
                    }
                }
            }
            return Optional.empty();
        } catch (NoSuchMethodException e) {
            return Optional.empty();
        }
    }
}

让我们来看看关键点:
  • 我们首先定义切入点Pointcuts,这是用于AOP方面的。
  • 然后,我们使用这些切入点Pointcuts来定义的Around,将建议标注的每一个公共方法@CheckPrivileges和@GraphQLQuery或@GraphQLMutation。
  • 在这种情况下,授权逻辑本身很简单:我们执行建议的方法,然后检查给定字段的值。

当然,我们仍然缺少一些。在我们的示例中,我们使用自定义异常:

package io.trabe.blog.graphqldemo.exceptions;

public class GraphQLExceptionWithErrorInformation extends RuntimeException 
implements GraphQLError {

    private final ErrorInformation errorInformation;

    public GraphQLExceptionWithErrorInformation(ErrorInformation errorInformation, 
    String message) {
        super(message);
        this.errorInformation = errorInformation;
    }

    @Override
    public List<SourceLocation> getLocations() {
        return null;
    }

    @Override
    public ErrorType getErrorType() {
        return null;
    }

    @Override
    public Map<String, Object> getExtensions() {
        Map<String, Object> errorsMap = new HashMap<>();
        errorsMap.put("errorType", errorInformation.toString());
        errorsMap.put(
"message", errorInformation.getMessageKey());
        return errorsMap;
    }
}

GraphQLError异常实现是允许我们使用自定义扩展自定义GraphQL错误的关键点。构造函数接收ErrorInformation枚举。这种设计允许我们对不同类型的错误使用相同的异常。在这种情况下,枚举的实现将是:

package  io.trabe.blog.graphqldemo.exceptions ;

public  enum  ErrorInformation {
    UNAUTHORIZED_ACCESS(“ errors.unauthorizedAccess ”);

    private  String messageKey;

    ErrorInformation(String  messageKey){
        这个。messageKey = messageKey;
    }

    public  String  getMessageKey(){
        return messageKey;
    }

}

有了所有部分,生成的错误响应SimpleDataFetcherExceptionHandler将包括extensions Map映射内的两个键:

  • 具有错误类型的字段。
  • 一个字段,其中包含我们的UI将在错误发生时向用户显示的消息的键。

总结
我们开发了一个基于AOP的解决方案,为SPQR解析器生成自定义GraphQL错误。此解决方案不仅限于授权,还可用于各种错误生成场景,使我们能够将代码集中在主要关注点上,并将错误生成提取到特定于目的的方面。