一、有状态 vs 无状态
二、微服务认证方案
1. “处处安全”方案
代表实现:
示例代码:
2. 外部无状态、内部有状态方案
3. “网关认证授权、内部裸奔”方案
4. “内部裸奔”改进方案
5. 方案对比与选择
三、访问控制模型
RABC访问控制模型
1. RABC概述
RBAC(Role-Based Access Control,基于角色的访问控制),就是用户通过角色与权限进行关联。简单地说,一个用户拥有若干角色,每一个角色拥有若干权限。这样,就构造成“用户-角色-权限”的授权模型。在这种模型中,用户与角色之间,角色与权限之间,一般是多对多的关系。(如下图)
2. RABC对象关系
-
权限
系统的所有权限信息。权限具有上下级关系,是一个树状的结构。如:
- 系统管理
- 用户管理
- 查看用户
- 新增用户
- 修改用户
- 删除用户
- 用户管理
- 系统管理
-
用户
系统的具体操作者,可以归属于一个或多个角色,它与角色的关系是多对多的关系
-
角色
为了对许多拥有相似权限的用户进行分类管理,定义了角色的概念,例如系统管理员、管理员、用户、访客等角色。角色具有上下级关系,可以形成树状视图,父级角色的权限是自身及它的所有子角色的权限的综合。父级角色的用户、父级角色的组同理可推。
3. RABC关系图
4. RABC模块图
5. RABC结构表
CREATE TABLE `tb_permission` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`parent_id` bigint(20) DEFAULT NULL COMMENT '父权限',
`name` varchar(64) NOT NULL COMMENT '权限名称',
`enname` varchar(64) NOT NULL COMMENT '权限英文名称',
`url` varchar(255) NOT NULL COMMENT '授权路径',
`description` varchar(200) DEFAULT NULL COMMENT '备注',
`created` datetime NOT NULL,
`updated` datetime NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=37 DEFAULT CHARSET=utf8 COMMENT='权限表';
CREATE TABLE `tb_role` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`parent_id` bigint(20) DEFAULT NULL COMMENT '父角色',
`name` varchar(64) NOT NULL COMMENT '角色名称',
`enname` varchar(64) NOT NULL COMMENT '角色英文名称',
`description` varchar(200) DEFAULT NULL COMMENT '备注',
`created` datetime NOT NULL,
`updated` datetime NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=37 DEFAULT CHARSET=utf8 COMMENT='角色表';
CREATE TABLE `tb_role_permission` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`role_id` bigint(20) NOT NULL COMMENT '角色 ID',
`permission_id` bigint(20) NOT NULL COMMENT '权限 ID',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=37 DEFAULT CHARSET=utf8 COMMENT='角色权限表';
CREATE TABLE `tb_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL COMMENT '用户名',
`password` varchar(64) NOT NULL COMMENT '密码,加密存储',
`phone` varchar(20) DEFAULT NULL COMMENT '注册手机号',
`email` varchar(50) DEFAULT NULL COMMENT '注册邮箱',
`created` datetime NOT NULL,
`updated` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`) USING BTREE,
UNIQUE KEY `phone` (`phone`) USING BTREE,
UNIQUE KEY `email` (`email`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=37 DEFAULT CHARSET=utf8 COMMENT='用户表';
CREATE TABLE `tb_user_role` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) NOT NULL COMMENT '用户 ID',
`role_id` bigint(20) NOT NULL COMMENT '角色 ID',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=37 DEFAULT CHARSET=utf8 COMMENT='用户角色表';
四、JWT
1. JWT简介
JWT全称Json web token
,是一个开放标准(RFC 7519),用来在各方之间安全地传输信息。JWT可被验证和信任,因为它是数字签名的。
2. 组成
3. 公式
4. 定义JWT操作工具类
自定义JWT操作工具类
-
pom.xml
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.10.7</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.10.7</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.10.7</version> <scope>runtime</scope> </dependency>
-
工具类
@Slf4j @RequiredArgsConstructor @SuppressWarnings("WeakerAccess") @Component public class JwtOperator { /** * 秘钥 * - 默认aaabbbcccdddeeefffggghhhiiijjjkkklllmmmnnnooopppqqqrrrsssttt */ @Value("${secret:aaabbbcccdddeeefffggghhhiiijjjkkklllmmmnnnooopppqqqrrrsssttt}") private String secret; /** * 有效期,单位秒 * - 默认2周 */ @Value("${expire-time-in-second:1209600}") private Long expirationTimeInSecond; /** * 从token中获取claim * * @param token token * @return claim */ public Claims getClaimsFromToken(String token) { try { return Jwts.parser() .setSigningKey(this.secret.getBytes()) .parseClaimsJws(token) .getBody(); } catch (ExpiredJwtException | UnsupportedJwtException | MalformedJwtException | IllegalArgumentException e) { log.error("token解析错误", e); throw new IllegalArgumentException("Token invalided."); } } /** * 获取token的过期时间 * * @param token token * @return 过期时间 */ public Date getExpirationDateFromToken(String token) { return getClaimsFromToken(token) .getExpiration(); } /** * 判断token是否过期 * * @param token token * @return 已过期返回true,未过期返回false */ private Boolean isTokenExpired(String token) { Date expiration = getExpirationDateFromToken(token); return expiration.before(new Date()); } /** * 计算token的过期时间 * * @return 过期时间 */ private Date getExpirationTime() { return new Date(System.currentTimeMillis() + this.expirationTimeInSecond * 1000); } /** * 为指定用户生成token * * @param claims 用户信息 * @return token */ public String generateToken(Map<String, Object> claims) { Date createdTime = new Date(); Date expirationTime = this.getExpirationTime(); byte[] keyBytes = secret.getBytes(); SecretKey key = Keys.hmacShaKeyFor(keyBytes); return Jwts.builder() .setClaims(claims) .setIssuedAt(createdTime) .setExpiration(expirationTime) // 你也可以改用你喜欢的算法 // 支持的算法详见:https://github.com/jwtk/jjwt#features .signWith(key, SignatureAlgorithm.HS256) .compact(); } /** * 判断token是否非法 * * @param token token * @return 未过期返回true,否则返回false */ public Boolean validateToken(String token) { return !isTokenExpired(token); } }
-
配置
jwt: # 秘钥(各个微服务的秘钥一定要一致) secret: aaaaaaaaaaassssssssscxzdfacdg # 有效期,单位秒,默认2周 expire-time-in-second: 1209600
-
测试使用
@Autowired private JwtOperator jwtOperator; public static void main(String[] args) { // 1. 初始化 JwtOperator jwtOperator = new JwtOperator(); jwtOperator.expirationTimeInSecond = 1209600L; jwtOperator.secret = "aaabbbcccdddeeefffggghhhiiijjjkkklllmmmnnnooopppqqqrrrsssttt"; // 2.设置用户信息 HashMap<String, Object> objectObjectHashMap = Maps.newHashMap(); objectObjectHashMap.put("id", "1"); // 测试1: 生成token String token = jwtOperator.generateToken(objectObjectHashMap); // 会生成类似该字符串的内容: eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjEiLCJpYXQiOjE1NjU1ODk4MTcsImV4cCI6MTU2Njc5OTQxN30.27_QgdtTg4SUgxidW6ALHFsZPgMtjCQ4ZYTRmZroKCQ System.out.println(token); // 将我改成上面生成的token!!! String someToken = "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjEiLCJpYXQiOjE1NjU1ODk4MTcsImV4cCI6MTU2Njc5OTQxN30.27_QgdtTg4SUgxidW6ALHFsZPgMtjCQ4ZYTRmZroKCQ"; // 测试2: 如果能token合法且未过期,返回true Boolean validateToken = jwtOperator.validateToken(someToken); System.out.println(validateToken); // 测试3: 获取用户信息 Claims claims = jwtOperator.getClaimsFromToken(someToken); System.out.println(claims); // 将我改成你生成的token的第一段(以.为边界) String encodedHeader = "eyJhbGciOiJIUzI1NiJ9"; // 测试4: 解密Header byte[] header = Base64.decodeBase64(encodedHeader.getBytes()); System.out.println(new String(header)); // 将我改成你生成的token的第二段(以.为边界) String encodedPayload = "eyJpZCI6IjEiLCJpYXQiOjE1NjU1ODk1NDEsImV4cCI6MTU2Njc5OTE0MX0"; // 测试5: 解密Payload byte[] payload = Base64.decodeBase64(encodedPayload.getBytes()); System.out.println(new String(payload)); // 测试6: 这是一个被篡改的token,因此会报异常,说明JWT是安全的 jwtOperator.validateToken("eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjEiLCJpYXQiOjE1NjU1ODk3MzIsImV4cCI6MTU2Njc5OTMzMn0.nDv25ex7XuTlmXgNzGX46LqMZItVFyNHQpmL9UQf-aUx"); }
五、实现认证授权
1. 实现小程序登录
-
前端部分代码
login(e) { const self = this; let userInfo = e.mp.detail.userInfo; // 登录 wx.login({ success: (res) => { request( LOGIN_URL, 'POST', { code: res.code, wxNickname: userInfo.nickName, avatarUrl: userInfo.avatarUrl } ).then(res => { console.log('登录成功...', res); wx.setStorageSync('token', res.token); wx.setStorageSync('user', res.user); console.log('user...', res.user); wx.showToast({ title: '登录成功!' }); console.log('user...', res.user); self.user = res.user; }).catch(error => { console.log('error', error); reject(error) }); } }); },
-
后端
-
pom.xml
<!-- weixin --> <dependency> <groupId>com.github.binarywang</groupId> <artifactId>weixin-java-miniapp</artifactId> <version>3.5.0</version> </dependency>
-
configuration
@Configuration public class WxConfig { @Bean public WxMaConfig wxMaConfig() { WxMaDefaultConfigImpl config = new WxMaDefaultConfigImpl(); //appid和secret可在微信公众平台查看 config.setAppid("xxxxxxxxxxxx"); config.setSecret("xxxxxxxxxxxxxxxxxxxxxxxxxx"); return config; } @Bean public WxMaService wxMaService(WxMaConfig wxMaConfig) { WxMaServiceImpl wxMaService = new WxMaServiceImpl(); wxMaService.setWxMaConfig(wxMaConfig); return wxMaService; } }
-
Controller代码
@PostMapping("/login") public LoginRespDTO login(@RequestBody UserLoginDTO loginDTO) throws WxErrorException { //微信小程序服务端校验是否已经登录的结果 WxMaJscode2SessionResult result = wxMaService.getUserService().getSessionInfo(loginDTO.getCode()); //微信的openId,用户在微信的唯一标识 String openid = result.getOpenid(); /** *看用户是否已经注册到数据库 *如果未注册,插入返回新user *如果已经注册,返回user */ User user = this.userService.login(loginDTO, openid); //颁发token Map<String,Object> userInfo = new HashMap<>(3); 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() .token(token) .expirationTime(jwtOperator.getExpirationTime().getTime()) .build() ).build(); }
-
Service代码
public User login(UserLoginDTO loginDTO,String openId) { User user = this.userMapper.selectOne( User.builder().wxId(openId).build() ); if (user == null) { User userToSave = User.builder() .wxId(openId) .bonus(300) .wxNickname(loginDTO.getWxNickname()) .avatarUrl(loginDTO.getAvatarUrl()) .roles("user") .createTime(new Date()) .updateTime(new Date()) .build(); this.userMapper.insertSelective( userToSave ); return userToSave; } return user; }
-
2. AOP实现登录检查状态
-
实现登录检查的方式
-
使用Spring AOP实现登录检查状态
-
pom.xml
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>
-
新建注解
public @interface CheckLogin { }
-
新建AOP切面,实现加有注解的地方需要进行token验证
@Aspect @Component @RequiredArgsConstructor(onConstructor = @__(@Autowired)) public class AuthAspect { private final JwtOperator jwtOperator; /** * 检查登录状态 * @param point * @return */ @Around("@annotation(com.banmingi.nodeapp.contentcenter.auth.CheckLogin)") public Object checkLogin(ProceedingJoinPoint point) throws Throwable { this.checkToken(); return point.proceed(); } private void checkToken() { try { //1. 从header里面获取token HttpServletRequest request = getHttpServletRequest(); String token = request.getHeader("X-Token"); //2. 校验token是否合法或在有效期内,如果不合法或已过期,直接抛异常;如果合法或未过期,放行 Boolean isValid = jwtOperator.validateToken(token); if (!isValid) { throw new SecurityException("token 不合法!"); } //3. 如果校验成功,就将用户的信息设置到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")); } catch (Throwable throwable) { throw new SecurityException("token 不合法!"); } } /** * 获取request * @return */ private HttpServletRequest getHttpServletRequest() { RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); ServletRequestAttributes attributes = (ServletRequestAttributes)requestAttributes; return attributes.getRequest(); } }
-
异常处理
public class SecurityException extends RuntimeException { public SecurityException(String message) { super(message); } public SecurityException(String message, Throwable cause) { super(message, cause); } }
@RestControllerAdvice @Slf4j public class GlobalExceptionErrorHander { @ExceptionHandler(SecurityException.class) public ResponseEntity<ErrorBody> error(SecurityException e) { log.warn("发生Security异常",e); return new ResponseEntity<ErrorBody>( ErrorBody.builder() .body(e.getMessage()) .status(HttpStatus.UNAUTHORIZED.value()) .build(), HttpStatus.UNAUTHORIZED ); } } @Data @Builder @NoArgsConstructor @AllArgsConstructor class ErrorBody { private String body; private int status; }
-
3. 微服务之间token的传递
1. Feign传递token
-
参数传递时使用
@RequestHeader
注解Controller代码:
@GetMapping("/{id}") @CheckLogin public ShareDTO findById(@PathVariable Integer id, @RequestHeader("X-Token") String token) { return this.shareService.findById(id,token); }
Feign Client接口代码:
@GetMapping("/users/{id}") UserDTO findById(@PathVariable Integer id, @RequestHeader("X-Token") String token);
-
使用Feign的
RequestInterceptor
拦截器定义一个类实现RequestInterceptor接口
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.isNoneBlank(token)) requestTemplate.header("X-Token",token); } }
配置(这里使用全局配置方式,所有Feign接口都会带上token):
feign: client: config: #全局配置 default: loggerLevel: BASIC requestInterceptors: - com.banmingi.nodeapp.contentcenter.interceptor.TokenRelayRequestInterceptor
2. RestTemplate传递token
-
调用
exchange()
方法@GetMapping("/tokenRelay/{userId}") public ResponseEntity<UserDTO> tokenRelay(@PathVariable Integer userId, HttpServletRequest request) { String token = request.getHeader("X-Token"); HttpHeaders httpHeaders = new HttpHeaders(); httpHeaders.add("X-Token",token); ResponseEntity<UserDTO> exchange = this.restTemplate .exchange("http://user-center/users/{userId}", HttpMethod.GET, new HttpEntity<>(httpHeaders), UserDTO.class, userId); return exchange; }
-
使用RestTemplate的
ClientHttpRequestIntercept
拦截器定义一个类实现ClientHttpRequestIntercept接口
public class TestRestTemplateTokenRelayInterceptor implements ClientHttpRequestInterceptor { @Override public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { //1. 从header里面获取token RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); ServletRequestAttributes attributes = (ServletRequestAttributes)requestAttributes; HttpServletRequest httpRequest = attributes.getRequest(); String token = httpRequest.getHeader("X-Token"); HttpHeaders headers = request.getHeaders(); headers.add("X-Token",token); //保证请求继续执行 return execution.execute(request,body); } }
配置:
@Bean @LoadBalanced //@SentinelRestTemplate public RestTemplate restTemplate(){ RestTemplate restTemplate = new RestTemplate(); restTemplate.setInterceptors( Collections.singletonList( new TestRestTemplateTokenRelayInterceptor() )); return restTemplate; }
4. AOP实现用户权限验证
使用Spring AOP实现用户权限验证
-
pom.xml
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>
-
新建注解
@Retention(RetentionPolicy.RUNTIME) public @interface CheckAuthorization { String value(); }
-
新建AOP切面,实现加有注解的地方需要进行权限验证
@Aspect @Component @RequiredArgsConstructor(onConstructor = @__(@Autowired)) public class AuthAspect { private final JwtOperator jwtOperator; /** * 检查登录状态 * @param point * @return */ @Around("@annotation(com.banmingi.nodeapp.contentcenter.auth.CheckLogin)") public Object checkLogin(ProceedingJoinPoint point) throws Throwable { this.checkToken(); return point.proceed(); } private void checkToken() { try { //1. 从header里面获取token HttpServletRequest request = getHttpServletRequest(); String token = request.getHeader("X-Token"); //2. 校验token是否合法或在有效期内,如果不合法或已过期,直接抛异常;如果合法或未过期,放行 Boolean isValid = jwtOperator.validateToken(token); if (!isValid) { throw new SecurityException("token 不合法!"); } //3. 如果校验成功,就将用户的信息设置到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")); } catch (Throwable throwable) { throw new SecurityException("token 不合法!"); } } /** * 获取request * @return */ private HttpServletRequest getHttpServletRequest() { RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); ServletRequestAttributes attributes = (ServletRequestAttributes)requestAttributes; return attributes.getRequest(); } /** * 权限验证 * @param point * @return * @throws Throwable */ @Around("@annotation(com.banmingi.nodeapp.contentcenter.auth.CheckAuthorization)") public Object checkAuthorization(ProceedingJoinPoint point) throws Throwable { try { //1. 验证token是否合法 this.checkToken(); //2. 验证用户角色是否匹配 HttpServletRequest request = getHttpServletRequest(); String role = (String) request.getAttribute("role"); MethodSignature signature = (MethodSignature) point.getSignature(); //拿到添加@CheckAuthorization注解的方法 Method method = signature.getMethod(); //拿到@CheckAuthorization注解 CheckAuthorization annotation = method.getAnnotation(CheckAuthorization.class); String value = annotation.value(); if (!Objects.equals(role,value)) { throw new SecurityException("用户无权访问!"); } } catch (Throwable throwable) { throwable.printStackTrace(); throw new SecurityException("用户无权访问!"); } return point.proceed(); } }