目录
RestTemplate 调用前统一申请 Token 传递到调用的服务中
基于 JWT 创建统一的认证服务
JWT(Json Web Token)是什么
JWT(Json Web Token)是为了在网络应用环境间传递声明而执行的一种基于 Json 的开放标准。JWT 的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源。
比如用在用户登录上时,基本思路就是用户提供用户名和密码给认证服务器,服务器验证用户提交信息的合法性;如果验证成功,会产生并返回一个 Token,用户可以使用这个 Token 访问服务器上受保护的资源。
JWT 由三部分构成,第一部分称为头部(Header),第二部分称为消息体(Payload),第三部分是签名(Signature)。一个 JWT 生成的 Token 格式为:
token = encodeBase64(header) + '.' + encodeBase64(payload) + '.' + encodeBase64(signature)
头部的信息通常由两部分内容组成,令牌的类型和使用的签名算法,比如下面的代码:
{
"alg": "HS256",
"typ": "JWT"
}
消息体中可以携带一些你需要的信息,比如用户 ID。因为你得知道这个 Token 是哪个用户的,比如下面的代码:
{
"id": "123",
"name": "zhangsan",
"admin": true
}
签名是用来判断消息在传递的路上是否被篡改,从而保证数据的安全性,格式如下:
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
通过这三部分就组成了我们的 Json Web Token。更多介绍可以查看 JWT 官网 https://jwt.io/introduction/。
认证服务肯定要有用户信息,不然怎么认证是否为合法用户?因为是内部的调用认证,可以简单一点,用数据库管理就是一种方式。或者可以配置用户信息,然后集成分布式配置管理就完美了。
数据库表结构:
create table blog_auth_app(
id int(4) not null,
appid varchar(100) not null,
appsecret varchar(100) not null,
Primary key (id)
);
Alter table auth_user comment '认证应用信息表';
这里只有简单的几个字段,若大家有别的需求可以自行去扩展。代码中的 appid 和 appsecret 是用户身份的标识。
创建一个 module 模块 blog-jwt-auth-service,并添加依赖:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<!-- or jjwt-gson if Gson is preferred -->
<artifactId>jjwt-jackson</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
JWT 工具类封装
用工具类进行认证主要有以下几个方法:
-
生成 Token。
-
检查 Token 是否合法。
生成 Token 是在进行用户身份认证之后,通过用户的 ID 来生成一个 Token,这个 Token 采用 RSA 加密的方式进行加密,Token 的内容包括用户的 ID 和过期时间。
检查 Token 则是根据调用方带来的 Token 检查是否为合法用户,就是对 Token 进行解密操作,能解密并且在有效期内表示合法,合法则返回用户 ID。
完整代码如下所示:
**
* API调用认证工具类,公私钥
*/
public class JWTUtils {
private static PrivateKey privateKey;
private static PublicKey publicKey;
public synchronized static JWTUtils getInstance() {
JWTUtils jwtUtils = new JWTUtils();
KeyPair keyPair = Keys.keyPairFor(SignatureAlgorithm.RS256);
privateKey = keyPair.getPrivate();
publicKey = keyPair.getPublic();
return jwtUtils;
}
/**
* 获取Token
*
* @param uid 用户ID
* @param exp 失效时间,单位秒
* @return
*/
public String getToken(String uid, long exp) {
long endTime = System.currentTimeMillis() + 1000 * exp;
return Base64Utils.encodeToUrlSafeString(
Jwts.builder().setSubject(uid).setExpiration(new Date(endTime))
.signWith(privateKey).compact().getBytes());
}
/**
* 获取Token
*
* @param uid 用户ID
* @return
*/
public String getToken(String uid) {
long endTime = System.currentTimeMillis() + 1000 * 60 * 1440;
return Base64Utils.encodeToUrlSafeString(
Jwts.builder().setSubject(uid).setExpiration(new Date(endTime))
.signWith(privateKey).compact().getBytes());
}
/**
* 检查Token是否合法
*
* @param token
* @return JWTResult
*/
public JWTResult checkToken(String token) {
JWTResult result = new JWTResult();
try {
Claims claims = Jwts.parserBuilder().setSigningKey(publicKey).build()
.parseClaimsJws(new String(Base64Utils.decodeFromString(token))).getBody();
String sub = claims.get("sub", String.class);
result.setCode(200);
result.setMsg("succcess");
result.setUid(sub);
result.setStatus(true);
} catch (ExpiredJwtException e) {
// 在解析JWT字符串时,如果‘过期时间字段’已经早于当前时间,将会抛出ExpiredJwtException异常,说明本次请求已经失效
result.setCode(403);
result.setMsg("token已过期");
result.setStatus(false);
}catch (Exception e) {
result.setCode(403);
result.setMsg(e.getMessage());
result.setStatus(false);
}
return result;
}
public static class JWTResult {
private boolean status;
private String uid;
private String msg;
private int code;
public JWTResult() {
super();
}
public JWTResult(boolean status, String uid, String msg, int code) {
super();
this.status = status;
this.uid = uid;
this.msg = msg;
this.code = code;
}
public int getCode() {
return code;
}
public void setCode(int code) {
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public boolean isStatus() {
return status;
}
public void setStatus(boolean status) {
this.status = status;
}
public String getUid() {
return uid;
}
public void setUid(String uid) {
this.uid = uid;
}
@Override
public String toString() {
return "{" +
"status=" + status +
", uid='" + uid + '\'' +
", msg='" + msg + '\'' +
", code=" + code +
'}';
}
}
写个 main 方法:
public static void main(String[] args) {
JWTUtils jwtUtils = JWTUtils.getInstance();
String token = jwtUtils.getToken("zhansan");
System.out.println("getToken:" + token);
JWTResult result = jwtUtils.checkToken(token);
System.out.println("checkToken:" + result);
}
运行结果:
getToken:ZXlKaGJHY2lPaUpTVXpJMU5pSjkuZXlKemRXSWlPaUl4TWpNaUxDSmxlSEFpT2pFMk1USTROREV4TkRaOS5jblNJMDdSSXVoeXJfLXBLRUlCSmp4MURqMll1QmJVZkI4N01ma09XUTh1eFFjQ2tYQVQzZkRlNzNkbnRuYUtVVjBwRDVxdFFyMzJBaFFDbWVCZjAwbUJtU25ZbmJUenVicjhxVllyaFFDVTJVSHBENDhmSXJrSzRDWnJPQi1DXzNBZ1BaNXhLeUdCQjVoalJldHJSaXZNQXJMZ1NDdGxzZnNRTkdlelJEcmF3NnRmelphZ0pDSHZ1MExSRlVRbDdiSm5UbmpZWGtHR3l0eUdib3BsbTdXdThVTUgxdnBYekpXZ0lEX1NIX1BoSHhpX0VaN0otdklrYXVCSElodVhaQkhHVnRMT0h0NFh0Rk4wand1RU1zZkp5MDRjSnA2Z3ZRbGVldEk5Y0RMeUVVQUZXZlhDVWRzeHpOSGFON2xkeEh1R3B3eV94NkNoNzdCaUlEc0dha1E=
checkToken:{msg=合法请求, uid=zhansan, code=200, status=true}
认证接口
认证接口用于调用方进行认证时,认证通过则返回一个加密的 Token 给对方,对方就可以用这个 Token 去请求别的服务了,认证获取 Token 代码如下所示:
@RestController
@RequestMapping("/jwt")
public class JWTAuthController {
private JWTUtils jwtUtils = JWTUtils.getInstance();
@Autowired
private AuthAppService authAppService;
// 有效期2小时
private static final long exp_ss = 7200L;
@GetMapping("/auth")
public ResponseEntity<Object> getToken(AuthApp authApp) {
TokenResult result = new TokenResult();
if (StringUtils.isEmpty(authApp.getAppid()) || StringUtils.isEmpty(authApp.getAppsecret())) {
result.setErrcode(4999);
result.setErrmsg("appid or appsecret not null.");
return new ResponseEntity<>(result, HttpStatus.OK);
}
boolean auth = authAppService.auth(authApp);
if (auth) {
String token = jwtUtils.getToken(authApp.getAppid(), exp_ss);
result.setExpireIn(exp_ss);
result.setAccessToken(token);
return new ResponseEntity<>(result, HttpStatus.OK);
}
result.setErrcode(4033);
result.setErrmsg("认证失败");
return new ResponseEntity<>(result, HttpStatus.OK);
}
}
认证参数代码如下所示:
public class AuthApp {
private int id;
private String appid;
private String appsecret;
// getter/setter...
}
认证结果代码如下所示:
public class TokenResult {
private int errcode;
private String errmsg;
private String accessToken;
private long expireIn;
// getter/setter...
}
服务提供方进行调用认证
服务提供方就是 provider。服务消费方消费接口时,provider 需要对其进行身份验证,验证通过才可以让它消费接口。这个过程中用到的过滤器可以写在 Common 包中,凡是服务提供方都需要用到。
认证过滤器的代码如下所示:
/**
* API 调用权限控制
*/
@Component
public class HttpBasicAuthorizeFilter implements Filter {
JWTUtils jwtUtils = JWTUtils.getInstance();
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
httpResponse.setCharacterEncoding("UTF-8");
httpResponse.setContentType("application/json; charset=utf-8");
String auth = httpRequest.getHeader("Authorization");
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> data = new HashMap<>(16);
// 验证 Token
if (!StringUtils.hasText(auth)) {
PrintWriter print = httpResponse.getWriter();
data.put("code", 403);
data.put("msg", "非法请求");
print.write(mapper.writeValueAsString(data));
return;
}
JWTUtils.JWTResult jwt = jwtUtils.checkToken(auth);
if (!jwt.isStatus()) {
PrintWriter print = httpResponse.getWriter();
data.put("code", 999);
data.put("msg", jwt.getMsg());
print.write(mapper.writeValueAsString(data));
return;
}
chain.doFilter(httpRequest, response);
}
@Override
public void destroy() {
}
}
在上述 Filter 类中对所有请求进行拦截,其调用之前写好的 JwtUtils 来检查 Token 是否合法,合法则放行,不合法则拦截并给出友好提示。
服务消费方申请 Token
目前服务提供方已经开启了调用认证,这意味着如果现在直接调用接口会被拦截,所以在调用之前需要进行认证,即获取 Token 并将其放到请求头中与请求头一起传输才可以调用接口。
获取 Token 前我们先定义一个 Feign 的客户端,代码如下所示:
/**
* 认证服务 API 调用客户端
**/
@FeignClient(value = "blog-jwt-auth-service", path = "/jwt")
public interface JwtAuthClient {
/**
* 调用认证, 获取 token
*
* @param appid 应用 ID
* @param appsecret 应用秘钥
* @return
*/
@GetMapping("/token")
TokenResult getToken(@Param("appid") String appid, @Param("appsecret") String appsecret);
}
通过 JwtAuthClient
就可以获取 Token。
如果每次调用接口之前都去认证一次,肯定是不行的,因为这样会导致性能降低,而且 Token 是可以设置过期时间的,完全没必要每次都去重新申请。可以将 Token 缓存在本地或者 Redis 中。需要注意的是缓存时间必须小于 Token 的过期时间。
就算获取的 Token 采用缓存来降低申请次数,这种方式也不是最优的方案。如果我们用的是 Feign 来消费接口,那么以下两种方式更好一些:一种方式就是在所有业务代码中调用接口前获取 Token,然后再进行相关设置。另一种是利用 Feign 提供的请求拦截器直接获取 Token,然后再进行相关设置。
采用定时器刷新 Token 是我认为最优的方案,其耦合程度很低,只需要添加一个定时任务即可。需要注意的是,定时的时间间隔必须小于 Token 的失效时间,如果 Token 是 2 小时过期,那么你可以 1.5 个小时定时刷新一次来保证调用的正确性。
定时刷新 Token 的代码如下所示:
/**
* 定时刷新 token
**/
@Component
public class TokenScheduledTask {
private static final Logger logger = LoggerFactory.getLogger(TokenScheduledTask.class);
public final static long excute_ss = 5400L;
public final static String appid = "admin";
public final static String appsecret = "admin";
@Autowired
private JwtAuthClient jwtAuthClient;
@Autowired
private RedisTemplate redisTemplate;
/**
* 刷新 Token
*/
@Scheduled(fixedDelay = excute_ss)
public void reloadToken() {
TokenResult tokenResult = this.getToken();
while (tokenResult == null || StringUtils.isEmpty(tokenResult.getAccessToken())) {
try {
Thread.sleep(1000);
tokenResult = this.getToken();
} catch (InterruptedException e) {
logger.error("", e);
}
}
redisTemplate.opsForValue().set("token:" + appid, tokenResult);
}
public TokenResult getToken() {
return jwtAuthClient.auth(appid, appsecret);
}
}
Feign 调用前统一申请 Token 传递到调用的服务中
如果项目中用的是 HttpClient
或者 RestTemplate
之类的调用接口,则可以在调用之前申请 Token,然后将其塞到请求头中。
在 Spring Cloud 中消费接口肯定是用 Feign 来做的,这意味着我们需要对 Feign 进行改造,需要往请求头中塞上我们申请好的 Token。
对于 Token 的传递操作,最好在框架层面进行封装,对使用者透明,这样不影响业务代码,但要求通用性一定要强。我们可以定义一个 Feign 的请求拦截器来统一添加请求头信息,代码如下所示:
@Component
public class FeignBasicAuthRequestInterceptor implements RequestInterceptor {
@Autowired
private RedisTemplate redisTemplate;
public FeignBasicAuthRequestInterceptor() {
}
@Override
public void apply(RequestTemplate template) {
TokenResult tokenResult =
(TokenResult) redisTemplate.opsForValue().get("token:admin");
template.header("Authorization", tokenResult.getAccessToken());
}
}
上面的准备好之后,我们只需要在调用业务接口之前先调用认证接口,然后将获取到的 Token 缓存到 Redis里,通过 redisTemplate.opsForValue().set("token:" + appid, token)
设置值,或者通过定时任务刷新设置。这样我们就可以通过 redisTemplate.opsForValue().get("token:" + appid)
获取到需要传递的 Token。
RestTemplate 调用前统一申请 Token 传递到调用的服务中
如果项目中用的 RestTemplate 来调用服务提供的接口,可以利用 RestTemplate
的拦截器来传递 Token,代码如下所示:
@Component
public class HttpTokenInterceptor implements ClientHttpRequestInterceptor {
@Autowired
private RedisTemplate redisTemplate;
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution)
throws IOException {
HttpHeaders headers = request.getHeaders();
TokenResult tokenResult =
(TokenResult) redisTemplate.opsForValue().get("token:admin");
headers.add("Authorization", tokenResult.getAccessToken());
return execution.execute(request, body);
}
}
将拦截器注入 RestTemplate
,代码如下所示:
@Autowired
private HttpTokenInterceptor httpTokenInterceptor;
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
RestTemplate restTemplate = new RestTemplate();
restTemplate.setInterceptors(Collections.singletonList(httpTokenInterceptor));
return restTemplate;
}
Zuul 中传递 Token 到路由的服务中
服务之间接口调用的安全认证是通过 Feign 的请求拦截器统一在请求头中添加 Token 信息,实现认证调用。还有一种调用方式也是需要进行认证,就是我们的 API 网关转发到具体的服务,这时候就不能采用 Feign 拦截器的方式进行 Token 的传递。
在 Zuul 中我们可以用 pre 过滤器来做这件事情,在路由之前将 Token 信息添加到请求头中,然后将请求头转发到具体的服务上。
通过 Zuul 的 pre 过滤器进行 Token 的设置,代码如下所示:
/**
* 调用服务前添加认证请求头过滤器
*/
@Component
public class AuthHeaderFilter extends ZuulFilter {
@Autowired
private RedisTemplate redisTemplate;
@Override
public boolean shouldFilter() {
RequestContext ctx = RequestContext.getCurrentContext();
Object success = ctx.get("isSuccess");
return success == null ? true : Boolean.parseBoolean(success.toString());
}
@Override
public String filterType() {
return "pre";
}
@Override
public int filterOrder() {
return 5;
}
@Override
public Object run() {
RequestContext ctx = RequestContext.getCurrentContext();
TokenResult tokenResult =
(TokenResult) redisTemplate.opsForValue().get("token:admin");
ctx.addZuulRequestHeader("Authorization", tokenResult.getAccessToken());
return null;
}
}
Token 的刷新机制和之前讲的一模一样,还是用那个定时器,直接复制过去即可,但是必须依赖申请 Token 的 Feign 客户端的定义。