权限系统是每个系统必不可少的一部分,我们可以自己实现根据自己的需求采用不同的技术方案。最近在我们的管理后台尚使用了Spring Security + JWT实现了后台的权限系统,包括用户登录,角色分配,鉴权与授权。
理解权限框架本质
有哪些技术方案?
业内通用的做法有Shiro,Spring Security,还有很多公司自己实现的基于url拦截的权限框架。从个人使用体验上来说,有好用的轮子就应该选择用经过很多人验证过的轮子。而不是自己沉迷于简单的增删改,时间应该花在研究security的原理,代码组织架构上,因为我也见过几个项目自己手写的权限框架,并没有用的很流畅,反而总是在一些url匹配不够通用上问题频出。
那么权限框架的本质是什么?
对,就是匹配逻辑。举个简单例子,网站用户A拥有权限标识:"user_add","coupon_delete","coupon_all",接收到request请求后,判断此请求需要的权限标识是否匹配。权限标识可以是:menu_url,menu_code,role_code等等,我们可以选择系统中变动频率小的变量来做角色标识。因为这个权限标识只能硬编码或者ant风格匹配在目标资源上。举个例子:假如你的系统角色固定,那就用角色code作权限标识,若是菜单基本固定,就用菜单url做标识。后面会具体讲到
用户登录的逻辑和jwt
用户到底是怎么登录的?
这个问题对于初级工程师来说会很迷惑,曾经也经历过。所以简单说明下。在一般的web软件开发中,开发者不需要关注会话这件事情,因为tomcat容器自动帮我们管理的会话session,他的流程是这样的,用户访问服务,服务端生成session会话,并且把sessionId回写到浏览期的cookie中,浏览器后面的每次请求就会携带上这个sessionId。服务端就能标识这个用户了,至于登陆鉴权的逻辑都是基于你能唯一标识当前的用户来做的。通用的做法是,用户成功登陆后,服务端会把用户信息存放在sessionId标识的session中。随着用户体量增多,在分布式的环境下一般的做法是session共享,或者采用redis接替tomcat管理session会话的方案。
为什么要用jwt?
全程是json web token,关于jwt是什么,可以参考阮一峰的文章:JSON Web Token 入门教程。使用了jwt后,我们完全把登陆信息存放在客户端,每次认证都是由客户端带着鉴权参数过来。具体的逻辑是服务端生成token,包含token有效期,存放的鉴权信息等,下发给客户端。客户端自放在本地。服务端就可以提供无状态的服务了,非常方便扩展。
实际案例
导入依赖
org.springframework.boot
spring-boot-starter-parent
2.1.2.RELEASE
org.springframework.boot
spring-boot-starter-security
配置security
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 读取忽略的配置文件
*/
@Autowired
private FilterIgnorePropertiesConfig filterIgnorePropertiesConfig;
/**
* 未携带token的异常处理
*/
@Autowired
private JwtAuthenticationEntryPoint unauthorizedHandler;
/**
* 业务的用户密码验证
*/
@Autowired
private JwtUserDetailsService jwtUserDetailsService;
/**
* 自定义基于JWT的安全过滤器
*/
@Autowired
private JwtAuthorizationTokenFilter authenticationTokenFilter;
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
// 配置基于数据库的用户密码查询 密码使用security自带的BCryptEncoder(结合了随机盐和加密算法)
auth.userDetailsService(jwtUserDetailsService)
.passwordEncoder(passwordEncoder());
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
http.headers().frameOptions().disable();
// 【1】授权异常及不创建会话(不使用session)
http.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
//允许不登录访问的接口
ExpressionUrlAuthorizationConfigurer.ExpressionInterceptUrlRegistry registry = http.authorizeRequests();
// 【2】 从配置文件读取url
registry.antMatchers(HttpMethod.OPTIONS, "/**").anonymous();
filterIgnorePropertiesConfig.getUrls().forEach(url -> registry.antMatchers(url).permitAll());
//需要登录才允许访问
filterIgnorePropertiesConfig.getAuthenticates().forEach(url -> registry.antMatchers(url).authenticated());
//其它的严格控制权限,必须权限拥有的菜单中对应的api_url才允许访问 【3】 权限控制
//registry.anyRequest().access("@permissionService.hasPermission(request,authentication)");
registry.anyRequest().authenticated();
// 把token拦截器配置在security 用户名和密码拦截器之前 【4】 从token解析的逻辑
http.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
@Override
public void configure(WebSecurity web) throws Exception {
// AuthenticationTokenFilter will ignore the below paths
web.ignoring()
.antMatchers(
HttpMethod.GET,
"/*.html",
"/**/*.html",
"/**/*.css",
"/**/*.js"
);
}
}
处理配置文件
@Data
@Configuration
@RefreshScope
@ConditionalOnExpression("!'${ignore}'.isEmpty()")
@ConfigurationProperties(prefix = "ignore")
public class FilterIgnorePropertiesConfig {
private List urls = new ArrayList<>();
private List authenticates = new ArrayList<>();
}
application.yml
ignore:
urls:
- /auth/**
- /act/**
- /druid/*
- /*/user/login
anonymous:都支持访问
permitAll():不登陆也能访问
authenticated():登陆就能访问
access():严格控制权限
token拦截器
拦截器主要做了这么几件事:
1.从请求头里面获取token
2.解析token里面存放的用户信息
3.用户信息不为空,且当前请求SecurityContextHolder(默认的实现是ThreadLocal)中的用户信息为空,就设置进去。
3.1用redis标记了token是否是用户手动过期掉的,因为token本身存放了过期时间 无法修改。
3.2根据3中简要的用户信息查询全部用户信息,包括角色,菜单等。如果你足够信任token,也可以省略这里查询数据库。
@Slf4j
@Component
public class JwtAuthorizationTokenFilter extends OncePerRequestFilter {
@Autowired
private FilterIgnorePropertiesConfig filterIgnorePropertiesConfig;
private OrRequestMatcher orRequestMatcher;
@Autowired
private UserDetailsService userDetailsService;
private final JwtTokenUtil jwtTokenUtil;
private final String tokenHeader;
private int expiration;
@Autowired
private RedisManager redisManager;
@PostConstruct
public void init() {
// 初始化忽略的url不走过此滤器
List matchers = filterIgnorePropertiesConfig.getUrls().stream()
.map(url -> new AntPathRequestMatcher(url))
.collect(Collectors.toList());
orRequestMatcher = new OrRequestMatcher(matchers);
}
public JwtAuthorizationTokenFilter(JwtTokenUtil jwtTokenUtil, @Value("${jwt.header}") String tokenHeader, @Value("${jwt.expiration}") Long expire) {
this.jwtTokenUtil = jwtTokenUtil;
this.tokenHeader = tokenHeader;
this.expiration = (int) (expire / 1000);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
String requestURI = request.getRequestURI();
log.debug("processing authentication for '{}'", requestURI);
final String requestHeader = request.getHeader(this.tokenHeader);
JwtUser jwtUser = null;
String authToken = null;
if (requestHeader != null && requestHeader.startsWith("Bearer ")) {
authToken = requestHeader.substring(7);
try {
jwtUser = jwtTokenUtil.getJwtUserFromToken(authToken);
} catch (ExpiredJwtException e) {
// token 过期
throw new AccountExpiredException("登陆状态已过期");
} catch (MalformedJwtException e) {
log.info("解析前端传过来的Authentication错误,但不影响业务逻辑!token:{}", requestHeader);
} catch (Exception e) {
log.info("JwtAuthorizationTokenFilter处理异常!{}", e.getMessage());
}
}
log.debug("checking authentication for user '{}'", jwtUser);
//生成jwt的token的过期时间是一天,而这里控制实际过期时间是两个小时(application.yml配置的过期时间)
if (jwtUser != null && jwtUser.getUsername() != null && SecurityContextHolder.getContext().getAuthentication() == null) {
if (redisManager.exists(CacheAdminConstant.USER_AUTHORITY_NOT_EXPIRED + authToken)) {
redisManager.expire(CacheAdminConstant.USER_AUTHORITY_NOT_EXPIRED + authToken, expiration);
} else {
throw new AccountExpiredException("登录信息已经过期或已经退出登录,请重新登录!");
}
UserDetails user = userDetailsService.loadUserByUsername(jwtUser.getUsername());
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
log.debug("authorizated user '{}', setting security context", user.getUsername());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
/**
* 可以重写
* @param request
* @return 返回为true时,则不过滤即不会执行doFilterInternal
* @throws ServletException
*/
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
return orRequestMatcher.matches(request);
}
}
从持久层查询用户
1.把用户的权限标识封装到GrantedAuthority对象,这是security封装的权限顶级接口。
2.检验菜单权限的时候就会通过这里封装的权限标识来比对。
3.关于权限标识的选取上文有提到,尽量选择不容易变动的变量(角色Code|菜单Code|菜单path)。
4.这个对象就是放在线程变量的用户对象,serurity的注解也会从这里取出权限标识来比对
@Primary
@Service
@Transactional(propagation = Propagation.SUPPORTS, readOnly = true, rollbackFor = Exception.class)
public class JwtUserDetailsService implements UserDetailsService {
@Autowired
private SysUserService sysUserService;
@Override
public UserDetails loadUserByUsername(String username){
// 根据登陆的用户名查询用户相关的信息
UserEntity user = sysUserService.loadUserByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("该账户不存在,请联系管理员添加");
} else {
return create(user);
}
}
public UserDetails create(UserEntity user) {
JwtUser jwtUser = new JwtUser();
BeanUtils.copyProperties(user, jwtUser);
Set roleCodeList = new HashSet<>();
// roleCodeList.addAll(user.getRoleIdList().stream().map(String::valueOf).collect(Collectors.toList()));
// 选取菜单permission作为权限标识
roleCodeList.addAll(user.getPermissionList().stream().filter(StringUtils::isNotEmpty).collect(Collectors.toSet()));
Collection extends GrantedAuthority> authorities = AuthorityUtils.createAuthorityList(roleCodeList.toArray(new String[0]));
jwtUser.setAuthorities(authorities);
return jwtUser;
}
}
用户登陆的流程
上面的部分是用户带着token来访问授权接口,或者不带token访问公用接口。那么token是怎么生成的呢?我们需要暴露公开的登陆接口,校验用户信息状态等。成功通过校验后,把部分用户信息封装在token里面下发给客户端。
这是一个基于的jjwt的jwtToken工具类:
@Component
@Slf4j
public class JwtTokenUtil {
private transient Clock clock = DefaultClock.INSTANCE;
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
@Value("${jwt.header}")
private String tokenHeader;
@Autowired
private RedisManager redisManager;
private ObjectMapper mapper = new ObjectMapper();
public JwtUser getJwtUserFromToken(String token) throws Exception {
String subject = getClaimFromToken(token, Claims::getSubject);
Map subjectMap = mapper.readValue(subject, Map.class);
// 在token中存储了用户ID 用户名 用户状态
JwtUser jwtUser = new JwtUser();
jwtUser.setUserId(Long.valueOf(subjectMap.get("userId").toString()));
jwtUser.setUsername((String) subjectMap.get("username"));
jwtUser.setState((Integer) subjectMap.get("state"));
return jwtUser;
}
public Date getIssuedAtDateFromToken(String token) {
return getClaimFromToken(token, Claims::getIssuedAt);
}
public Date getExpirationDateFromToken(String token) {
return getClaimFromToken(token, Claims::getExpiration);
}
public T getClaimFromToken(String token, Function claimsResolver) {
final Claims claims = getAllClaimsFromToken(token);
return claimsResolver.apply(claims);
}
private Claims getAllClaimsFromToken(String token) {
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
}
private Boolean isTokenExpired(String token) {
final Date expirationDate = getExpirationDateFromToken(token);
return expirationDate.before(clock.now());
}
private Boolean isCreatedBeforeLastPasswordReset(Date created, Date lastPasswordReset) {
return (lastPasswordReset != null && created.before(lastPasswordReset));
}
private Boolean ignoreTokenExpiration(String token) {
// here you specify tokens, for that the expiration is ignored
return false;
}
// 登陆校验成功后调用这个接口生成token下发
public String generateToken(UserDetails userDetails) {
Map claims = new HashMap<>();
try {
String subject = mapper.writeValueAsString(userDetails);
log.info("generateToken subject:{}", subject);
String token = doGenerateToken(claims, subject);
redisManager.set(CacheAdminConstant.USER_AUTHORITY_NOT_EXPIRED + token, "1", (int) (expiration / 1000));
return token;
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Cannot format json", e);
}
}
private String doGenerateToken(Map claims, String subject) {
final Date createdDate = clock.now();
final Date expirationDate = calculateExpirationDate(createdDate);
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(createdDate)
.setExpiration(expirationDate)
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
public Boolean canTokenBeRefreshed(String token, Date lastPasswordReset) {
final Date created = getIssuedAtDateFromToken(token);
return !isCreatedBeforeLastPasswordReset(created, lastPasswordReset)
&& (!isTokenExpired(token) || ignoreTokenExpiration(token));
}
public String refreshToken(String token) {
final Date createdDate = clock.now();
final Date expirationDate = calculateExpirationDate(createdDate);
final Claims claims = getAllClaimsFromToken(token);
claims.setIssuedAt(createdDate);
claims.setExpiration(expirationDate);
return Jwts.builder()
.setClaims(claims)
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
public Boolean validateToken(String token, UserDetails userDetails) throws Exception {
JwtUser user = (JwtUser) userDetails;
final JwtUser jwtUser = getJwtUserFromToken(token);
return (
jwtUser.getUsername().equals(user.getUsername())
&& !isTokenExpired(token));
}
private Date calculateExpirationDate(Date createdDate) {
//过期时间1天
return new Date(createdDate.getTime() + 1000 * 60 * 60 * 24);
}
}
jwt token刷新机制
我们回顾下token机制相比传统的session机制带来的好处,服务无状态,服务端不用存储用户的session,用户数过多也不会占用资源,方便服务水平拓展...,token也有一个缺点就是由于token的有效期是保存在客户端的,当用户主动退出,或者服务端要踢出用户的时候很难做到。refresh token可以实现这种场景,并且能实现用户无感知登陆。访问资源的称之为access token,客户端访问所有的资源都需要带上,它的有效期比较短。refresh token是用来刷新access token,它的有效期是比较长的。接下来回顾一下整个会话管理流程:
- 客户端使用用户名和密码认证
- 服务端校验用户名和密码,下发access_token(2小时有效)和refresh_token(7天有效)
- 客户端带着access_token访问需要认证的资源,access_token有效,返回资源。
- access_token过期,返回和客户端约定的响应码,客户端带着refresh_token刷新access_token.
- refresh_token 有效,正常返回,refresh_token过期走重新登陆流程。
-
客户端使用新的 access_token 访问需要认证的接口
将生成的refresh_token以及过期时间存储在服务端的数据库中,只有在申请新的access_token时才会验证。同时我们也能实现在服务端踢出用户,只需要禁用|删除refresh_token,用户在刷新access_token时就会重新去登陆。(时间精度的控制取决于access_token的有效期)
接口权限控制
当我们完成了用户登陆-token下发-请求拦截认证的流程后,当request到达Controller层,SecurityContextHolder已经存储了用户的常用信息(用户名,权限标识等等),所以在Controller层可以直接使用注解来鉴权。
@PreAuthorize("hasAuthority('test_menu_code')")
@PostMapping("/getUserInfo")
public ResponseResult getUserInfo() {
return new ResponseResult(getUser());
}
至此,完成了整个权限控制。代码只是列出了关键的部分,没有达到运行的流程,需要有一定基础的程序员来根据自己的业务定制。只是提供了一个企业级权限控制的实现方案。