權限系統是每一個系統必不可少的一部分,咱們能夠本身實現根據本身的需求採用不一樣的技術方案。最近在咱們的管理後臺尚使用了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());
}
複製代碼
至此,完成了整個權限控制。代碼只是列出了關鍵的部分,沒有達到運行的流程,須要有必定基礎的程序員來根據本身的業務定製。只是提供了一個企業級權限控制的實現方案。