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的发现文档里面找到,如下:
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
- 构造验证请求头
- 构造请求体
- 成功响应
- 无效的token响应
使用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代表用作签名 x5c x.509证书链 n RSA公钥的模数 e RSA公钥的指数 kid 密钥的唯一标识符 x5t x.509证书的指纹 JWKS端点验证Token流程
在弄清楚验证流程之前先来看一下IdentityServer颁发的JWT Token里面都有哪些信息
可以看到header部分声明的签名算法为RS256,并且相比使用HS256算法的JWT多了kid和x5t这两个属性,JWT里的kid和x5t的值对应IdentityServer JWKS端点中的kid和x5t的值。 所以在API端验证Token的流程如下:
- 客户端携带Token请求API
- API端从请求头取出Token并解析
- 请求IdentityServer的JWKS端点获取到用于验证签名的RSA公钥集并缓存在本地
- 通过Token中的kid查找到JWKS对应的JWK的公钥(上面的n和e)
- 根据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
Q.E.D.
Comments | 4 条评论