使用IdentityServer 4保护SpringBoot Web API

IdentityServer4是一个开源的OpenID Connect和OAuth 2.0框架。整合了基于Token的身份验证,单点登录和API访问控制。关于IdentityServer4的更多介绍请参阅官方文档

IdentityServer可作为中心授权服务器来实现SSO,在ASP.NET Core WebAPI环境下可以很方便地使用Microsoft.AspNetCore.Authentication.JwtBearer 这个nuget包来集成IdentityServer对WebAPI的访问认证。但是如果我们的WebAPI是用SpringBoot编写的,同时又想使用IdentityServer作为认证服务器呢?有两种解决方案:

  • 使用IdentityServer提供的introspect端点在线验证(不推荐)

    官方文档对introspect端点的解释是为那些没有提供相应解析JWT库的客户端提供Token验证。

    introspect端点的访问地址可以在IdentityServer的发现文档里面找到,如下: wechat_20191226143737

    introspect端点的使用比较简单,使用POST请求introspect端点的url,请求体里传递一个token参数,内容即为需要验证的token。需要注意的是,由于introspect端点验证的客户端是API,因此IdentityServer需要知道你是哪个API以及验证API的有效性,所以introspect端点需要在请求头里传递API身份认证。

    introspect端点使用HTTP基本认证,格式为:ApiName:ApiSecret再经过Base64编码后的字符串

    POST  /connect/introspect
    Authorization: Basic  Base64编码后的字符串
    
    token=<token>
    

    introspect端点返回一个json对象,token验证通过返回实例如下:

    {
        "active": true,
        "sub": "123"
    }
    

    无效或者过期的令牌将被标记为无效:

    {
       "active": false
    }
    

    未授权返回401状态码

    可以通过判断active的值来验证token的有效性

    introspect端点的使用比较简单,自行编码实现即可。这里使用 postman模拟一下introspect端点的使用流程。

    • 获取token

    wechat_20191226151036

    • 构造验证请求头

    wechat_20191226151650

    • 构造请求体

    wechat_20191226151744

    • 成功响应

    wechat_20191226151851

    • 无效的token响应

    wechat_20191226153721

    使用introspect端点在线验证token的问题主要是API的每次访问请求都要去访问授权服务器验证token的有效性,造成授权服务器压力过大。根据JWT的构造,完全可在API端本地完成验证,于是就有了第二种方案。

  • 使用JWKS端点请求公钥本地验证(推荐)

    JWKS

    JWKS(JSON Web Key Set) 是包含公共密钥的一组密钥,这些密钥应用于验证授权服务器发布并使用RS256 签名算法签名的任何JSON Web令牌(JWT)

    RS256是JWT支持的众多签名算法之一。RS256生成非对称签名,这意味着必须使用私钥对JWT进行签名,并且必须使用其他公钥来验证签名。IdentityServer4默认使用RS256对JWT进行签名。

    这里要明确一个概念,即HS256使用单个secret对数据加密,所以secret是不会公开的。RS256属于RSA系列,是一种非对称加密算法。RSA不仅可用来加密数据也可以用作签名,当RSA用作签名时私钥用于对数据进行签名,公钥用于对签名进行验证,所以可通过公开RSA公钥供客户端验证签名的有效性。关于这部分的原理详细请查看知乎 @刘巍然 的回答。

    IdentityServer JWKS端点

    IdentityServer的JWKS端点位于/.well-known/openid-configuration/jwks

    访问该端点获取的数据结构如下,其中包含一组密钥(JWKS)

    
    {
        "keys":[
            {
                "kty":"RSA",
                "use":"sig",
                "kid":"4FEAB00B111400503471A30946E43A64250AFF04",
                "x5t":"T-qwCxEUAFA0caMJRuQ6ZCUK_wQ",
                "e":"AQAB",
                "n":"t8CIhXZvPLL7200YLcJSHTHY6UYCM4OAUMaFzrWyVFJBkWXJGAreRRYGMOOV7n5uaNn5HOfXs4uzxEg_CWHPxE0yZ9m1J9rZPTlwow2tq2HS_eCLEStMq9l-QVNjCUpde5iOgfxx9eTeIqFFBoTWFUHTLXC20TF9bHq1tNQK5Is6_AiX6zPoF15QsJ6DL2xvSB4pNRZqg6PRhPOvcywWGEvzEF9lxePng7nzTVHC7lL1UyubmJfjcry9NciePFAFJ_SqiDvityJf7aSnP1nw1recFjvEAyE0ykgd4Dvx5Xp_4ZAcLczhHeavurAqSHeFr2Znsp-hgPJQDcMIPj9fDQ",
                "x5c":[
                    "MIID9zCCAt+gAwIBAgIJAIxwpjyB7+ldMA0GCSqGSIb3DQEBCwUAMIGRMQswCQYDVQQGEwJDTjERMA8GA1UECAwIU2hhbmdoYWkxETAPBgNVBAcMCFNoYW5naGFpMRAwDgYDVQQKDAdEZXZ6aG91MRAwDgYDVQQLDAdEZXZ6aG91MRswGQYDVQQDDBJhY2NvdW50LmRldnpob3UuY24xGzAZBgkqhkiG9w0BCQEWDHlAZGV2emhvdS5jbjAeFw0xOTA4MTYwNzMzMjlaFw0yOTA4MTMwNzMzMjlaMIGRMQswCQYDVQQGEwJDTjERMA8GA1UECAwIU2hhbmdoYWkxETAPBgNVBAcMCFNoYW5naGFpMRAwDgYDVQQKDAdEZXZ6aG91MRAwDgYDVQQLDAdEZXZ6aG91MRswGQYDVQQDDBJhY2NvdW50LmRldnpob3UuY24xGzAZBgkqhkiG9w0BCQEWDHlAZGV2emhvdS5jbjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALfAiIV2bzyy+9tNGC3CUh0x2OlGAjODgFDGhc61slRSQZFlyRgK3kUWBjDjle5+bmjZ+Rzn17OLs8RIPwlhz8RNMmfZtSfa2T05cKMNrath0v3gixErTKvZfkFTYwlKXXuYjoH8cfXk3iKhRQaE1hVB0y1wttExfWx6tbTUCuSLOvwIl+sz6BdeULCegy9sb0geKTUWaoOj0YTzr3MsFhhL8xBfZcXj54O5801Rwu5S9VMrm5iX43K8vTXInjxQBSf0qog74rciX+2kpz9Z8Na3nBY7xAMhNMpIHeA78eV6f+GQHC3M4R3mr7qwKkh3ha9mZ7KfoYDyUA3DCD4/Xw0CAwEAAaNQME4wHQYDVR0OBBYEFONPwnGYE1bN34Jx5ge9WjRpNJ/vMB8GA1UdIwQYMBaAFONPwnGYE1bN34Jx5ge9WjRpNJ/vMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAFUBbAG4DYC/ZpsSuJjMtZgBLxtTXxtk4mFFXkL9vCTfAi/yfPu0Ajnm+8l5La43XbRVjF+Zt/caKxMFaDSLjdHCcdIjH17N5SxcNXM73fuynmr1uH/gdYA6pumJVHWS55UobpD8/sxCJVPmgsuQy+MZZAAodoANKiZy79PYMyqjPIe9h9BOqeDyVosKqUro3Hi8gGS+LN28VdGvYybjzA2VXLzrrh7Ut+mWDcWu2KRxSRT7+jg2aV7WmB75Nli/aEp6lOTL0LRB03ATFoXF/TnMA694i5UWAa1+/AXCCeRD/AdtvTidTFQq2hNlUcfYMSe2jdgSd9DbonWN3zTXPEI="
                ],
                "alg":"RS256"
            }
        ]}
    

    其中单个JWK中属性含义如下:

    属性含义
    alg与密钥一起使用的特定加密算法
    kty与密钥一起使用的加密算法家族
    use密钥的用途。sig代表用作签名
    x5cx.509证书链
    nRSA公钥的模数
    eRSA公钥的指数
    kid密钥的唯一标识符
    x5tx.509证书的指纹
    JWKS端点验证Token流程

    在弄清楚验证流程之前先来看一下IdentityServer颁发的JWT Token里面都有哪些信息 wechat_20191227160043

    可以看到header部分声明的签名算法为RS256,并且相比使用HS256算法的JWT多了kid和x5t这两个属性,JWT里的kid和x5t的值对应IdentityServer JWKS端点中的kid和x5t的值。 所以在API端验证Token的流程如下:

    1. 客户端携带Token请求API
    2. API端从请求头取出Token并解析
    3. 请求IdentityServer的JWKS端点获取到用于验证签名的RSA公钥集并缓存在本地
    4. 通过Token中的kid查找到JWKS对应的JWK的公钥(上面的n和e)
    5. 根据JWK中的公钥验证JWT签名
    代码实现
    • 新建SpringBoot Maven项目
    • 在pom.xml中添加依赖
      <dependency>
              <groupId>org.springframework.boot</groupId>
              <artifactId>spring-boot-starter-web</artifactId>
       </dependency>
      
       <dependency>
              <groupId>com.auth0</groupId>
              <artifactId>jwks-rsa</artifactId>
              <version>0.9.0</version>
       </dependency>
      
      <dependency>
              <groupId>com.auth0</groupId>
              <artifactId>java-jwt</artifactId>
              <version>3.8.3</version>
      </dependency>
      

    spring-boot-starter-web包引入web开发的各项功能,jwks-rsa用于从远程jwks端点获取RSA公钥,java-jwt解析并验证token

    • application.properties文件中配置IdentityServer JWKS端点地址和颁发者
      ids4.jwks.url= https://account.devzhou.cn/.well-known/openid-configuration/jwks
      ids4.issuer= https://account.devzhou.cn
      
    • 新建Authorize注解,用于标记需要执行Token验证的方法
      @Target({ElementType.METHOD, ElementType.TYPE})
      @Retention(RetentionPolicy.RUNTIME)
      public @interface Authorize {}
      
    • 编写一个拦截器,用于对Token进行验证
      public class AuthenticationInterceptor implements HandlerInterceptor {
      
      @Value("${ids4.jwks.url}")
      private String jwksUrl;
      
      @Value("${ids4.issuer}")
      private String issuer;
      
      @Override
      public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
          if(!(handler instanceof HandlerMethod)){
              return true;
          }
      
          // 判断执行的目标方法是否有Authorize注解,有Authorize注解的才进行token验证
          HandlerMethod handlerMethod=(HandlerMethod)handler;
          Method method=handlerMethod.getMethod();
          if (method.isAnnotationPresent(Authorize.class)) {
              String originToken = request.getHeader("Authorization");
              if (originToken == null || originToken.isEmpty()){
                  // token为空返回401状态码
                  response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                  return false;
              }
              try {
                  String token = getToken(originToken);
                  // 解析token
                  DecodedJWT jwt = JWT.decode(token);
                  JwkProvider http = new UrlJwkProvider(new URL(jwksUrl));
                  // 缓存jkws,默认缓存5个key,10小时,可自定义缓存策略
                  JwkProvider provider = new GuavaCachedJwkProvider(http);
                  Jwk jwk = provider.get(jwt.getId());
                  Algorithm algorithm = Algorithm.RSA256((RSAPublicKey) jwk.getPublicKey(),null);
                  // 验证token
                  JWTVerifier verifier = JWT.require(algorithm)
                          //验证颁发者
                          .withIssuer(issuer)
                          // 验证scope
                          .withArrayClaim("scope","testapi")
                          .build();
                  verifier.verify(token);
              } catch (JWTVerificationException exception){
                  // token验证失败返回401
                  PrintWriter writer = response.getWriter();
                  writer.write(exception.getMessage());
                  response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                  return false;
              }
          }
          return true;
      }
      
      @Override
      public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
      
      }
      
      @Override
      public void afterCompletion(HttpServletRequest request,  HttpServletResponse response, Object handler, Exception ex) throws Exception {
      
      }
      
      private String getToken(String originToken) {
          String[] arr = originToken.split(" ");
          return arr[1];
      
          }
      	}
      
      
    • 配置拦截器
      @Configuration
      public class InterceptorConfig implements WebMvcConfigurer {
          @Override
          public void addInterceptors(InterceptorRegistry registry) {
              registry.addInterceptor(authenticationInterceptor())
                      .addPathPatterns("/**");
          }
          @Bean
          public AuthenticationInterceptor authenticationInterceptor() {
              return new AuthenticationInterceptor();
          }
      }
      
      
    • Controller测试
       @RestController
       public class TestController {
          @GetMapping("/")
          public String index(){
              return "OK";
          }
          @Authorize
          @GetMapping("/admin")
          public String admin(){
              return "admin";
          }
      }
      

    离线验证可以根据策略合理缓存授权服务器的jwks,API每次请求无需频繁访问授权服务器,减少了API响应时间和授权服务器压力。 完整源代码托管在Github

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×