第08章-认证授权
# 8.1 有状态VS无状态
如图所示,当应用有多个实例的时候,将session存储到一个中央存储中去(Session Store),常常使用Redis或MemCache。
Tips
也可使用粘性会话,即:对相同IP的请求,NGINX总会转发到相同的Tomcat实例,这样就就无需图中的Session Store了。不过这种方式有很多缺点:比如用户断网重连,刷新页面,由于IP变了,NGINX会转发到其他Tomcat实例,而其他实例没有Session,于是就认为用户未登录。这让用户莫名其妙。 粘性会话不是本章重点,如果感兴趣可以百度一下(用得越来越少了)。
这种统一session管理的方式有些显著缺点,如果Session Store挂了,所有系统全部完蛋;如果Session Store需要迁移,则别的实例都需要改连接地址;如果Session Store达到了容量或性能瓶颈,就需要为其提供解决方案。
后来的发展趋势,特别是微服务流行起来之后,越来越多的提到无状态,所谓无状态是只服务器端不记录用户的登录状态,更直观的讲,服务器不再维护session了。
无状态的玩法是这样的,服务端不存储用户的登录状态,而是在用户登录的时候颁发一个Token给用户,之后用户的每个请求都会带上这个Token,可以放在Header里传递,也可以放在URL参数里传递,服务端拿到Token校验Token的合法性时效性等。也可以在Token中传递一些不太敏感的用户信息,这样Token解析完服务器就能直接使用了。
这里讲的是解密Token直接拿到用户信息,事实上要看你项目的具体实现,有时候Token里不一定带有用户信息,而是利用Token到某个地方查询,才能获得用户信息。
有状态无状态优缺点对比:
优点 | 缺点 | |
---|---|---|
有状态 | 服务器端控制力强 | 存在中心点,鸡蛋在一个篮子里 迁移麻烦服务器端存储数据,加大了服务器端压力 |
无状态 | 去中心化 无存储,简单 任意扩容、缩容 | 服务器端控制力相对弱 |
# 8.2 微服务认证方案
# 8.2.1 “处处安全”方案
处处安全方案常用的协议是OAuth 2.0,OAuth2.0系列文章 (opens new window)
这种方案些的代表实现主要有:
Spring Cloud Security
Jboss Keycloak
Keycloak功能强大,上手简单,维护方便,有图形化界面。但是是基于servlet模型的,所以无法和Spring Cloud Gateway配合使用。
处处安全优缺点:
- 优点:安全性好
- 缺点:实现成本比较高,由于存在多次Token交换和多次认证,带来了性能开销
参考:
# 8.2.2 “外部无状态,内部有状态”方案
这种方案中,网关不会存储Session,而是使用Token,而网关代理的微服务则使用了Session Store共享Session。
这种方案看起来非常怪异,其应用场景如下,在一个新老服务共存的庞大系统中,老服务使用网关传过来的JSESSIONID去Session Store中获取用户登录状态,新的微服务系统使用Token解密,这样可以一步步将传统架构重构成新的微服务架构。
# 8.2.3 “网关认证授权,内部裸奔”方案
请求在网关处做登录认证,登录成功颁发Token,之后的请求都携带Token,网关解密Token 网关可以将解密出user_id、username附加到请求header中,去请求后端微服务。这种方案下,网关说用户是谁,后端微服务就相信这是谁。
- 优点:实现简单,性能好
- 缺点:一旦网关被攻破就完了
# 8.2.4 “内部裸奔”改进方案
请求经过网关去认证授权中心去登录,登录成功颁发Token,之后所有请求都携带该Token,网关并不操作Token,只是将Token传递给后端微服务,后端微服务解析Token来确定用户身份,微服务之间的调用也是同理。
优点:降低了网关的复杂度,网关不需要关心用户是谁了,不再揭秘解析Token,只做转发,并提高了一定的安全性 缺点:每个微服务都参与解密Token,这样知道密钥的人很多,泄密风险也相应变大
# 8.2.5 认证方案对比
方案 | 复杂度 | 安全性 | 性能 | 测试难度 |
---|---|---|---|---|
处处安全 | 高 | 高 | 中等 | 难(一般做继承测试) |
外部无状态,内部有状态 | 低 | 中 | 高 | 难(一般做继承测试) |
内部裸奔 | 低 | 一般 | 高 | 简单(造Header即可实现接口测试) |
内部裸奔改进版 | 低 | 高 | 高 | 中(造Token即可实现接口测试) |
# 8.3 访问控制模型
- Access Control List(ACL)
- Role-based access control(RBAC)
- Attribute-based access control(ABAC)
- Rule-based access control
- Time-based access control
RBAC访问控制模型:
# 8.4 JWT
JWT全称Json web token,是一个开放标准(RFC 7519),用来在各方之间安全地传输信息。JWT可被验证和信任,因为它是数字签名的。
JWT组成:
组成 | 作用 | 内容实例 |
---|---|---|
Header(头) | 记录令牌类型、签名的算法等 | {"alg":"HS256", "typ":"JWT"} |
Payload(有效负载) | 携带一些用户信息 | {"userld":"1", "username":"damu"} |
Signature(签名) | 防止Token被篡改、确保安全性 | 计算出来的签名,一个字符串 |
Token算法:
Token = Base64(Header).Base64(Payload).Base64(Signature)
示例:aaaa.bbbbb.ccccc
签名算法:
Signature = Header指定的签名算法(Base64(header).Base64(payload),秘钥)
秘钥:HS256("aaaa.bbbbb",秘钥)
参考:JWT工具类手记 (opens new window)
# 8.5 微信登录认证
在user-center中微信小程序登录认证代码,用到了微信开发的Java SDK(WxJava (opens new window)),使用步骤如下:
加依赖
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>weixin-java-miniapp</artifactId>
<version>4.0.0</version>
</dependency>
2
3
4
5
编写配置类
/**
* 微信小程序相关Bean配置
*/
@Configuration
public class WxConfig {
/**
* 小程序配置
*/
@Bean
public WxMaConfig wxMaConfig() {
WxMaDefaultConfigImpl config = new WxMaDefaultConfigImpl();
config.setAppid("wx97xxxxxxxxxf181");
config.setSecret("6dabxxxxxxxxxxxxxxxxxxxxxxxxd00c");
return config;
}
/**
* 小程序服务接口,提供各种api
*/
@Bean
public WxMaService wxMaService() {
WxMaServiceImpl wxMaService = new WxMaServiceImpl();
wxMaService.setWxMaConfig(wxMaConfig());
return wxMaService;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
业务代码:
UserController
@Resource
private WxMaService wxMaService;
@Resource
private JwtOperator jwtOperator;
@PostMapping("/login")
public LoginRespDTO login(@RequestBody UserLoginDTO loginDTO) throws WxErrorException {
// 微信小程序服务端校验是否已登录
WxMaJscode2SessionResult result = wxMaService.getUserService().getSessionInfo(loginDTO.getCode());
// 微信的OpenId,微信端唯一标识
String openid = result.getOpenid();
// String openid = "xxx"; // 注释上述代码,放开本行,接口调试用
// 看用户是否注册,未注册就插入用户信息,注册了就颁发Token
User user = userService.login(loginDTO, openid);
// 颁发Token
Map<String, Object> userInfo = new HashMap<>();
userInfo.put("id", user.getId());
userInfo.put("wxNickname", user.getWxNickname());
userInfo.put("role", user.getRoles());
String token = jwtOperator.generateToken(userInfo);
log.info("用户{}登录成功,生成token={},有效期至{}", user.getWxNickname(), token, jwtOperator.getExpirationTime());
return LoginRespDTO.builder()
.user(UserRespDTO.builder()
.id(user.getId())
.avatarUrl(user.getAvatarUrl())
.bonus(user.getBonus())
.wxNickname(user.getWxNickname())
.build())
.token(JwtTokenRespDTO.builder()
.expirationTime(jwtOperator.getExpirationTime().getTime())
.token(token)
.build())
.build();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
UserService
public User login(UserLoginDTO loginDTO, String openId) {
User user = userMapper.selectOne(
User.builder().wxId(openId).build());
if (user == null) {
User userToSave = User.builder()
.wxId(openId).wxNickname(loginDTO.getWxNickname()).avatarUrl(loginDTO.getAvatarUrl())
.bonus(300).roles("user").createTime(new Date()).updateTime(new Date())
.build();
userMapper.insertSelective(userToSave);
return userToSave;
}
return user;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
请求登录接口
# 8.6 登录状态校验
当请求进到服务端的时候,有以下几种方式时间实现登录状态的检查
- Servlet过滤器
- Spring MVC拦截器:可以方便的调用Spring MVC的api
- Spring AOP:代码简洁,可插拔,注解切面,不需要校验的地方不写注解即可
加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
2
3
4
写注解切面
@Slf4j
@Aspect
@Component
public class CheckLoginAspect {
@Resource
private JwtOperator jwtOperator;
@Around("@annotation(com.lucifer.usercenter.auth.CheckLogin)")
public Object checkLogin(ProceedingJoinPoint point) throws Throwable {
// 1.从header中获取token
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
ServletRequestAttributes attributes = (ServletRequestAttributes) requestAttributes;
HttpServletRequest request = attributes.getRequest();
String token = request.getHeader("X-Token");
// 2.校验token有效性和时效性
if (StringUtils.isBlank(token) || !jwtOperator.validateToken(token)) {
log.warn("token check failed, token={}", token);
throw new RuntimeException("无效Token");
}
// 3.如果校验成功,就将解析token获取用户信息,存到request的attribute中
Claims claims = jwtOperator.getClaimsFromToken(token);
request.setAttribute("id", claims.get("id"));
request.setAttribute("wxNickname", claims.get("wxNickname"));
request.setAttribute("role", claims.get("role"));
log.warn("token check passed, token={}", token);
return point.proceed();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# 8.7 Feign传递token
每个微服务请求进来的时候都会校验请求头里的token,但是微服务之间使用Feign互相调用的时候不会传递token,就导致下游微服务校验token的时候不通过。为了解决这个问题有以下两种方式
- @RequestHeader注解
- RequestInterceptor拦截器
使用@RequestHeader注解对代码侵入性比较大,而且每个接口都要加,这里采用为Feign配置RequestInterceptor拦截器方式
编写拦截器
/**
* Feign拦截器,用于传递token
*/
public class TokenRelayRequestInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate requestTemplate) {
// 1.从header中获取token
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
ServletRequestAttributes attributes = (ServletRequestAttributes) requestAttributes;
HttpServletRequest request = attributes.getRequest();
String token = request.getHeader("X-Token");
// 2. 传递token
if (!StringUtils.isBlank(token)) {
requestTemplate.header("X-Token", token);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
配置拦截器
feign:
client:
config:
# 想要调用的微服务名称,改成default即对所有服务生效
default:
# 配置全面默认拦截器
requestInterceptors:
- com.lucifer.contentcenter.feignclient.interceptor.TokenRelayRequestInterceptor
2
3
4
5
6
7
8
参考:
- [4.3 Feign配置](#4.3 Feign配置)
- 官方文档 (opens new window)
# 8.9 RestTemplate传递token
RestTemplate传递token有以下几种方式
- HttpEntity中封装header
- Spring的ClientHttpRequestInterceptor
方式一:
public class TestRestTemplate {
@Autowired
private RestTemplate restTemplate;
public void postObject(String token){
String url="http://www.baidu.com";
User user = new User();
// 构造请求头
HttpHeaders header = new HttpHeaders();
header.add("X-token", token);
// 创建httpEntity
HttpEntity<User> httpEntity = new HttpEntity<>(user, header);
// 发送请求
JSONObject response = restTemplate.postForObject(url, httpEntity, JSONObject.class);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
请求方式很多,除了xxxForObject,还有exchange,都是利用将header封装在HttpEntity中的方式实现携带请求头
方式二:
编写拦截器:
/**
* 拦截器,可向RestTemplate中注册拦截器
*/
public class RelayTokenClientHttpRequestInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
// 1.取出原始请求头中的token
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
ServletRequestAttributes attributes = (ServletRequestAttributes) requestAttributes;
HttpServletRequest httpServletRequest = attributes.getRequest();
String token = httpServletRequest.getHeader("X-Token");
// 2.设置header
HttpHeaders headers = request.getHeaders();
headers.add("X-Token", token);
// 继续执行(后面还有拦截器的话继续,责任链模式)
return execution.execute(request, body);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
RestTemplate上配置拦截器
@Bean
@LoadBalanced //负载均衡注解
@SentinelRestTemplate // Sentinel整合RestTemplate
public RestTemplate restTemplate() {
RestTemplate template = new RestTemplate();
// 配置拦截器链
template.setInterceptors(Collections.singletonList(
new RelayTokenClientHttpRequestInterceptor()
));
return template;
}
2
3
4
5
6
7
8
9
10
11