權(quán)限系統(tǒng)是每個系統(tǒng)必不可少的一部分撑蚌,我們可以自己實現(xiàn)根據(jù)自己的需求采用不同的技術(shù)方案雕拼。最近在我們的管理后臺尚使用了Spring Security + JWT實現(xiàn)了后臺的權(quán)限系統(tǒng),包括用戶登錄,角色分配腕扶,鑒權(quán)與授權(quán)。
理解權(quán)限框架本質(zhì)
有哪些技術(shù)方案惩淳?
業(yè)內(nèi)通用的做法有Shiro蕉毯,Spring Security乓搬,還有很多公司自己實現(xiàn)的基于url攔截的權(quán)限框架。從個人使用體驗上來說代虾,有好用的輪子就應(yīng)該選擇用經(jīng)過很多人驗證過的輪子进肯。而不是自己沉迷于簡單的增刪改,時間應(yīng)該花在研究security的原理棉磨,代碼組織架構(gòu)上江掩,因為我也見過幾個項目自己手寫的權(quán)限框架,并沒有用的很流暢乘瓤,反而總是在一些url匹配不夠通用上問題頻出环形。
那么權(quán)限框架的本質(zhì)是什么?
對衙傀,就是匹配邏輯抬吟。舉個簡單例子,網(wǎng)站用戶A擁有權(quán)限標識:"user_add","coupon_delete","coupon_all",接收到request請求后统抬,判斷此請求需要的權(quán)限標識是否匹配火本。權(quán)限標識可以是:menu_url,menu_code,role_code等等,我們可以選擇系統(tǒng)中變動頻率小的變量來做角色標識聪建。因為這個權(quán)限標識只能硬編碼或者ant風(fēng)格匹配在目標資源上钙畔。舉個例子:假如你的系統(tǒng)角色固定,那就用角色code作權(quán)限標識金麸,若是菜單基本固定擎析,就用菜單url做標識。后面會具體講到
用戶登錄的邏輯和jwt
用戶到底是怎么登錄的挥下?
這個問題對于初級工程師來說會很迷惑揍魂,曾經(jīng)也經(jīng)歷過。所以簡單說明下见秽。在一般的web軟件開發(fā)中愉烙,開發(fā)者不需要關(guān)注會話這件事情,因為tomcat容器自動幫我們管理的會話session解取,他的流程是這樣的步责,用戶訪問服務(wù),服務(wù)端生成session會話禀苦,并且把sessionId回寫到瀏覽期的cookie中蔓肯,瀏覽器后面的每次請求就會攜帶上這個sessionId。服務(wù)端就能標識這個用戶了振乏,至于登陸鑒權(quán)的邏輯都是基于你能唯一標識當前的用戶來做的蔗包。通用的做法是,用戶成功登陸后慧邮,服務(wù)端會把用戶信息存放在sessionId標識的session中调限。隨著用戶體量增多舟陆,在分布式的環(huán)境下一般的做法是session共享,或者采用redis接替tomcat管理session會話的方案耻矮。
為什么要用jwt秦躯?
全程是json web token,關(guān)于jwt是什么裆装,可以參考阮一峰的文章:JSON Web Token 入門教程踱承。使用了jwt后,我們完全把登陸信息存放在客戶端哨免,每次認證都是由客戶端帶著鑒權(quán)參數(shù)過來茎活。具體的邏輯是服務(wù)端生成token,包含token有效期琢唾,存放的鑒權(quán)信息等载荔,下發(fā)給客戶端〔商遥客戶端自放在本地身辨。服務(wù)端就可以提供無狀態(tài)的服務(wù)了,非常方便擴展芍碧。
實際案例
導(dǎo)入依賴
<!-- 基于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;
/**
* 業(yè)務(wù)的用戶密碼驗證
*/
@Autowired
private JwtUserDetailsService jwtUserDetailsService;
/**
* 自定義基于JWT的安全過濾器
*/
@Autowired
private JwtAuthorizationTokenFilter authenticationTokenFilter;
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
// 配置基于數(shù)據(jù)庫的用戶密碼查詢 密碼使用security自帶的BCryptEncoder(結(jié)合了隨機鹽和加密算法)
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】授權(quán)異常及不創(chuàng)建會話(不使用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());
//其它的嚴格控制權(quán)限,必須權(quán)限擁有的菜單中對應(yīng)的api_url才允許訪問 【3】 權(quán)限控制
//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.yml
ignore:
urls:
- /auth/**
- /act/**
- /druid/*
- /*/user/login
anonymous:都支持訪問
permitAll():不登陸也能訪問
authenticated():登陸就能訪問
access():嚴格控制權(quán)限
token攔截器
攔截器主要做了這么幾件事:
1.從請求頭里面獲取token
2.解析token里面存放的用戶信息
3.用戶信息不為空号俐,且當前請求SecurityContextHolder(默認的實現(xiàn)是ThreadLocal)中的用戶信息為空泌豆,就設(shè)置進去。
3.1用redis標記了token是否是用戶手動過期掉的吏饿,因為token本身存放了過期時間 無法修改踪危。
3.2根據(jù)3中簡要的用戶信息查詢?nèi)坑脩粜畔ⅲń巧砺洌藛蔚日暝丁H绻阕銐蛐湃蝨oken,也可以省略這里查詢數(shù)據(jù)庫笨忌。
@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("登陸狀態(tài)已過期");
} catch (MalformedJwtException e) {
log.info("解析前端傳過來的Authentication錯誤蓝仲,但不影響業(yè)務(wù)邏輯!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("登錄信息已經(jīng)過期或已經(jīng)退出登錄,請重新登錄途凫!");
}
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時垢夹,則不過濾即不會執(zhí)行doFilterInternal
* @throws ServletException
*/
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
return orRequestMatcher.matches(request);
}
}
從持久層查詢用戶
1.把用戶的權(quán)限標識封裝到GrantedAuthority對象,這是security封裝的權(quán)限頂級接口维费。
2.檢驗菜單權(quán)限的時候就會通過這里封裝的權(quán)限標識來比對果元。
3.關(guān)于權(quán)限標識的選取上文有提到促王,盡量選擇不容易變動的變量(角色Code|菜單Code|菜單path)。
4.這個對象就是放在線程變量的用戶對象,serurity的注解也會從這里取出權(quán)限標識來比對
@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){
// 根據(jù)登陸的用戶名查詢用戶相關(guān)的信息
UserEntity user = sysUserService.loadUserByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("該賬戶不存在而晒,請聯(lián)系管理員添加");
} 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作為權(quán)限標識
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來訪問授權(quán)接口蝇狼,或者不帶token訪問公用接口。那么token是怎么生成的呢欣硼?我們需要暴露公開的登陸接口题翰,校驗用戶信息狀態(tài)等。成功通過校驗后诈胜,把部分用戶信息封裝在token里面下發(fā)給客戶端豹障。
這是一個基于的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 用戶名 用戶狀態(tài)
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;
}
// 登陸校驗成功后調(diào)用這個接口生成token下發(fā)
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);
}
}
jwt token刷新機制
我們回顧下token機制相比傳統(tǒng)的session機制帶來的好處,服務(wù)無狀態(tài)焦匈,服務(wù)端不用存儲用戶的session血公,用戶數(shù)過多也不會占用資源,方便服務(wù)水平拓展...缓熟,token也有一個缺點就是由于token的有效期是保存在客戶端的累魔,當用戶主動退出,或者服務(wù)端要踢出用戶的時候很難做到够滑。refresh token可以實現(xiàn)這種場景垦写,并且能實現(xiàn)用戶無感知登陸。訪問資源的稱之為access token彰触,客戶端訪問所有的資源都需要帶上梯投,它的有效期比較短。refresh token是用來刷新access token况毅,它的有效期是比較長的分蓖。接下來回顧一下整個會話管理流程:
- 客戶端使用用戶名和密碼認證
- 服務(wù)端校驗用戶名和密碼,下發(fā)access_token(2小時有效)和refresh_token(7天有效)
- 客戶端帶著access_token訪問需要認證的資源尔许,access_token有效么鹤,返回資源。
- access_token過期味廊,返回和客戶端約定的響應(yīng)碼蒸甜,客戶端帶著refresh_token刷新access_token.
- refresh_token 有效,正常返回余佛,refresh_token過期走重新登陸流程迅皇。
-
客戶端使用新的 access_token 訪問需要認證的接口
將生成的refresh_token以及過期時間存儲在服務(wù)端的數(shù)據(jù)庫中,只有在申請新的access_token時才會驗證衙熔。同時我們也能實現(xiàn)在服務(wù)端踢出用戶登颓,只需要禁用|刪除refresh_token,用戶在刷新access_token時就會重新去登陸红氯。(時間精度的控制取決于access_token的有效期)
接口權(quán)限控制
當我們完成了用戶登陸-token下發(fā)-請求攔截認證的流程后框咙,當request到達Controller層咕痛,SecurityContextHolder已經(jīng)存儲了用戶的常用信息(用戶名,權(quán)限標識等等)喇嘱,所以在Controller層可以直接使用注解來鑒權(quán)坑资。
@PreAuthorize("hasAuthority('test_menu_code')")
@PostMapping("/getUserInfo")
public ResponseResult getUserInfo() {
return new ResponseResult(getUser());
}
至此眯牧,完成了整個權(quán)限控制。代碼只是列出了關(guān)鍵的部分,沒有達到運行的流程闻坚,需要有一定基礎(chǔ)的程序員來根據(jù)自己的業(yè)務(wù)定制挎狸。只是提供了一個企業(yè)級權(quán)限控制的實現(xiàn)方案振湾。