权限系统是每一个系统必不可少的一部分,咱们能够本身实现根据本身的需求采用不一样的技术方案。最近在咱们的管理后台尚使用了Spring Security + JWT实现了后台的权限系统,包括用户登陆,角色分配,鉴权与受权。css
有哪些技术方案? 业内通用的作法有Shiro,Spring Security,还有不少公司本身实现的基于url拦截的权限框架。从我的使用体验上来讲,有好用的轮子就应该选择用通过不少人验证过的轮子。而不是本身沉迷于简单的增删改,时间应该花在研究security的原理,代码组织架构上,由于我也见过几个项目本身手写的权限框架,并无用的很流畅,反而老是在一些url匹配不够通用上问题频出。 那么权限框架的本质是什么? 对,就是匹配逻辑。举个简单例子,网站用户A拥有权限标识:"user_add","coupon_delete","coupon_all",接收到request请求后,判断此请求须要的权限标识是否匹配。权限标识能够是:menu_url,menu_code,role_code等等,咱们能够选择系统中变更频率小的变量来作角色标识。由于这个权限标识只能硬编码或者ant风格匹配在目标资源上。举个例子:假如你的系统角色固定,那就用角色code做权限标识,如果菜单基本固定,就用菜单url作标识。后面会具体讲到html
用户究竟是怎么登陆的? 这个问题对于初级工程师来讲会很迷惑,曾经也经历过。因此简单说明下。在通常的web软件开发中,开发者不须要关注会话这件事情,由于tomcat容器自动帮咱们管理的会话session,他的流程是这样的,用户访问服务,服务端生成session会话,而且把sessionId回写到浏览期的cookie中,浏览器后面的每次请求就会携带上这个sessionId。服务端就能标识这个用户了,至于登录鉴权的逻辑都是基于你能惟一标识当前的用户来作的。通用的作法是,用户成功登录后,服务端会把用户信息存放在sessionId标识的session中。随着用户体量增多,在分布式的环境下通常的作法是session共享,或者采用redis接替tomcat管理session会话的方案。 为何要用jwt? 全程是json web token,关于jwt是什么,能够参考阮一峰的文章:JSON Web Token 入门教程。使用了jwt后,咱们彻底把登录信息存放在客户端,每次认证都是由客户端带着鉴权参数过来。具体的逻辑是服务端生成token,包含token有效期,存放的鉴权信息等,下发给客户端。客户端自放在本地。服务端就能够提供无状态的服务了,很是方便扩展。前端
导入依赖git
<!-- 基于spring boot -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.2.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
复制代码
配置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<HttpSecurity>.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<String> urls = new ArrayList<>();
private List<String> authenticates = new ArrayList<>();
}
复制代码
application.ymlgithub
ignore:
urls:
- /auth/**
- /act/**
- /druid/*
- /*/user/login
复制代码
anonymous:都支持访问 permitAll():不登录也能访问 authenticated():登录就能访问 access():严格控制权限web
拦截器主要作了这么几件事:redis
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<RequestMatcher> 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的注解也会从这里取出权限标识来比对spring
@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<String> 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<String, Object> 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> T getClaimFromToken(String token, Function<Claims, T> 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<String, Object> 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<String, Object> 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);
}
}
复制代码
咱们回顾下token机制相比传统的session机制带来的好处,服务无状态,服务端不用存储用户的session,用户数过多也不会占用资源,方便服务水平拓展...,token也有一个缺点就是因为token的有效期是保存在客户端的,当用户主动退出,或者服务端要踢出用户的时候很难作到。refresh token能够实现这种场景,而且能实现用户无感知登录。访问资源的称之为access 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());
}
复制代码
至此,完成了整个权限控制。代码只是列出了关键的部分,没有达到运行的流程,须要有必定基础的程序员来根据本身的业务定制。只是提供了一个企业级权限控制的实现方案。