目錄
SpringSecurity權(quán)限管理系統(tǒng)實戰(zhàn)—一、項目簡介和開發(fā)環(huán)境準備
SpringSecurity權(quán)限管理系統(tǒng)實戰(zhàn)—二浩聋、日志、接口文檔等實現(xiàn)
SpringSecurity權(quán)限管理系統(tǒng)實戰(zhàn)—三臊恋、主要頁面及接口實現(xiàn)
SpringSecurity權(quán)限管理系統(tǒng)實戰(zhàn)—四衣洁、整合SpringSecurity(上)
SpringSecurity權(quán)限管理系統(tǒng)實戰(zhàn)—五、整合SpringSecurity(下)
SpringSecurity權(quán)限管理系統(tǒng)實戰(zhàn)—六抖仅、SpringSecurity整合jwt
SpringSecurity權(quán)限管理系統(tǒng)實戰(zhàn)—七坊夫、處理一些問題
SpringSecurity權(quán)限管理系統(tǒng)實戰(zhàn)—八、AOP記錄用戶撤卢、異常日志
SpringSecurity權(quán)限管理系統(tǒng)實戰(zhàn)—九环凿、數(shù)據(jù)權(quán)限的配置
前言
最近是真的懶,感覺我每個月都有那么幾天什么都不想干放吩。智听。
畫風一轉(zhuǎn),前幾天的lpl忍界大戰(zhàn)是真的精彩渡紫,虛假的電競春晚:RNG vs IG 到推。真正的電競春晚 TES vs IG。TES自從阿水和kasra加入之后惕澎,狀態(tài)直接起飛莉测,在我看來TES將是s10奪冠熱門之一。不過這一次木葉村戰(zhàn)勝了曉組織唧喉。
本以為會打滿三局捣卤,沒想到ig直接2:0帶走忍抽。rookie線上壓制了新皇knight,確實永遠可以相信宋義進腌零,或許是因為?小鈺采訪吧梯找。
這兩把我最沒想到的是kasra被寧王壓著打,幾乎沒有節(jié)奏益涧,寶藍在哪都是阿水的噩夢。這波啊驯鳖,這波是盜版打贏了正版闲询,puff小小的證明了自己。
最后還是希望lpl的飯圈粉少一點浅辙,peace
進入正題
一扭弧、無狀態(tài)登錄
-
有狀態(tài)登錄
我們知道在原始的項目中我們是通過session和cookie來實現(xiàn)用戶的識別認證。但是這樣做無疑會增加服務器的壓力记舆,服務的保存了大量的數(shù)據(jù)鸽捻。如果業(yè)務需要擴展,搭建了集群的話泽腮,還需要將session共享御蒲。
-
無狀態(tài)登錄
而什么是無狀態(tài)登錄呢,簡而言之诊赊,就是服務的不需要再保存任何的用戶信息厚满,而是用戶自己攜帶者信息去訪問服務端,服務端通過這些信息來識別客戶端身份碧磅。這樣一來碘箍,有狀態(tài)登錄的缺點都被解決了,但是這同樣也會帶來新問題鲸郊。比如token信息無法在服務端注銷丰榴,必須要等其自己過期,占用更多的空間(意味著需要更多帶寬)秆撮,修改密碼后原本的token在沒過期時仍然可用訪問系統(tǒng)等四濒。
二、JWT介紹
1像吻、什么是jwt
JWT是 Json Web Token 的縮寫峻黍。它是基于 RFC 7519 標準定義的一種可以安全傳輸?shù)?小巧 和 自包含 的JSON對象。由于數(shù)據(jù)是使用數(shù)字簽名的拨匆,所以是可信任的和安全的姆涩。JWT可以使用HMAC算法對secret進行加密或者使用RSA的公鑰私鑰對來進行簽名。
我們來看一下jwt長什么樣
eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJjb2Rlcm15IiwiYXV0aG9yaXRpZXMiOiJhZG1pbiIsImV4cCI6MTU5NjA4MDM5OX0.rfDtzMus50uAFnqMw1tm3c_ZYbmUNkIRqMkeJ0510PAH-RCUWtZkfNPTDYAGVVfDU6jmdEkGyNYvGy3UrNq5pA
JSON Web 令牌以緊湊的形式由三個部分組成惭每,由點分隔骨饿,它們包括:
- 頭部
- 負載
- 簽名
頭部(Header)
jwt的頭部承載兩部分信息:
- 聲明類型亏栈,這里是jwt
- 聲明加密的算法 通常直接使用 HMAC SHA256
像這樣
{
'typ': 'JWT',
'alg': 'HS256'
}
載荷(Payload)
這個部分用來承載要傳遞的數(shù)據(jù),他的默認字段有
- iss:發(fā)行人
- exp:到期時間
- sub:主題
- aud:用戶
- nbf:在此之前不可用
- iat:發(fā)布時間
- jti:JWT ID用于標識該JWT
除以上默認字段外宏赘,我們還可以自定義私有字段绒北,例如
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
簽名(Signature)
Signature 部分是對前兩部分的簽名,防止數(shù)據(jù)篡改察署。
2闷游、JWT工作流程
- 用戶發(fā)起登錄請求
- 服務端驗證身份,將用戶信息贴汪,標識等信息打包成jwt token返回給客戶端
- 用戶拿到token脐往,攜帶token發(fā)送請求給服務端
- 服務的驗證token是否可用,可用便根據(jù)其y業(yè)務邏輯返回相應結(jié)果扳埂。
3业簿、簡單實現(xiàn)
首先我們在maven中引入以下依賴
<!--jjwt-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
新建JwtTest來測試一下
/**
* @author codermy
* @createTime 2020/7/30
*/
public class JwtTest {
public static void main(String[] args) {
String token = Jwts.builder()
//用戶名
.setSubject("codermy")
//自定義屬性 放入用戶擁有請求權(quán)限
.claim("authorities","admin")
// 設置失效時間為1分鐘
.setExpiration(new Date(System.currentTimeMillis()+1000*60))
// 簽名算法和密鑰
.signWith(SignatureAlgorithm.HS512, "java")
.compact();
System.out.println(token);
}
輸出
eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJjb2Rlcm15IiwiYXV0aG9yaXRpZXMiOiJhZG1pbiIsImV4cCI6MTU5NjA4MDM5OX0.rfDtzMus50uAFnqMw1tm3c_ZYbmUNkIRqMkeJ0510PAH-RCUWtZkfNPTDYAGVVfDU6jmdEkGyNYvGy3UrNq5pA
我們再來解析
//解析token
Claims claims = Jwts.parser()
.setSigningKey("java")
.parseClaimsJws(token)
.getBody();
System.out.println(claims);
//獲取用戶名
String username = claims.getSubject();
System.out.println("username:"+username);
//獲取權(quán)限
String authority = claims.get("authorities").toString();
System.out.println("權(quán)限:"+authority);
System.out.println("到期時間:" + claims.getExpiration());
輸出
{sub=codermy, authorities=admin, exp=1596082316}
username:codermy
權(quán)限:admin
到期時間:Thu Jul 30 12:11:56 CST 2020
三、整合JWT
后端實現(xiàn)
其實jwt本身很好理解阳懂,無非就就是一把鑰匙梅尤,可用打開對應的鎖,這不過這把鑰匙稍微特殊點岩调,它還帶了主人的一些信息巷燥。難理解的是要將它符合業(yè)務邏輯的整合進框架中。我自己就被繞了好久才明白誊辉。
我這里寫了一個Jwt的工具類矾湃,用于生成和解析jwt
/**
* @author codermy
* @createTime 2020/7/23
*/
@Component
public class JwtUtils {
private static final String CLAIM_KEY_USERNAME = "sub";
private static final String CLAIM_KEY_CREATED = "created";
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
// 創(chuàng)建token
public String generateToken(String username) {
return Jwts.builder()
.signWith(SignatureAlgorithm.HS512, secret)
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
.compact();
}
// 從token中獲取用戶名
public String getUserNameFromToken(String token){
return getTokenBody(token).getSubject();
}
// 是否已過期
public boolean isExpiration(String token){
return getTokenBody(token).getExpiration().before(new Date());
}
private Claims getTokenBody(String token){
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
}
}
然后我們可以將jwt的一些信息寫在yml中,使得可以靈活的配置堕澄。application.yml中添加如下配置
jwt:
tokenHeader: Authorization #JWT存儲的請求頭
secret: my-springsecurity-plus #JWT加解密使用的密鑰
expiration: 604800 #JWT的超期限時間(60*60*24*7)
tokenHead: 'Bearer ' #JWT負載中拿到開頭邀跃,空格別忘了
我們照著jwt的工作流程來,首先是登錄成功后客戶端會返回一個jwt token
所以我們首先自定義一個MyAuthenticationSuccessHandler繼承AuthenticationSuccessHandler蛙紫,這是登錄成功后的處理器
/**
* @author codermy
* @createTime 2020/8/1
* 登錄成功
*/
@Component
@Slf4j
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Autowired
private JwtUtils jwtUtils;
@Value("${jwt.tokenHeader}")
private String tokenHeader;
@Value("${jwt.tokenHead}")
private String tokenHead;
@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
JwtUserDto userDetails = (JwtUserDto)authentication.getPrincipal();//拿到登錄用戶信息
String jwtToken = jwtUtils.generateToken(userDetails.getUsername());//生成token
Result result = Result.ok().message("登錄成功").jwt(jwtToken);
System.out.println(JSON.toJSONString(result));//用于測試
httpServletResponse.setCharacterEncoding("utf-8");//修改編碼格式
httpServletResponse.setContentType("application/json");
httpServletResponse.getWriter().write(JSON.toJSONString(result));//輸出結(jié)果
httpServletResponse.sendRedirect("/api/admin");//重定向到api/admin頁面拍屑。我這里路由名取的不是很好
}
}
然后我們再寫一個jwt的攔截器,讓每個請求都需要驗證jwt token
/**
* @author codermy
* @createTime 2020/7/30
*/
@Component
@Slf4j
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private UserDetailsServiceImpl userDetailsService;
@Autowired
private JwtUtils jwtUtils;
@Value("${jwt.tokenHeader}")
private String tokenHeader;
@Value("${jwt.tokenHead}")
private String tokenHead;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String authHeader = request.getHeader(this.tokenHeader);//拿到requset中的head
if (authHeader != null && authHeader.startsWith(this.tokenHead)) {
String authToken = authHeader.substring(this.tokenHead.length());// The part after "Bearer "
String username = jwtUtils.getUserNameFromToken(authToken);//解析token獲取用戶名
log.info("checking username:{}", username);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
if (userDetails != null) {//判斷是否存在這個給用戶
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
log.info("authenticated user:{}", username);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
}
chain.doFilter(request, response);
}
}
這里為了之后結(jié)果更直觀坑傅,自定義一個AuthenticationEntryPoint僵驰,用于在未登錄是訪問接口返回json而不是login.html
/**
* @author codermy
* @createTime 2020/8/1
* 當未登錄或者token失效訪問接口時,自定義的返回結(jié)果
*/
@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.setCharacterEncoding("UTF-8");//設置編碼格式
response.setContentType("application/json");
response.getWriter().println(JSON.toJSONString(Result.error().message("尚未登錄唁毒,或者登錄過期 " + authException.getMessage())));
response.getWriter().flush();
}
}
將上述方法加入到SpringSecurityConfig中
/**
* @author codermy
* @createTime 2020/7/15
*/
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsServiceImpl userDetailsService;
@Autowired
private VerifyCodeFilter verifyCodeFilter;
@Autowired
MyAuthenticationSuccessHandler authenticationSuccessHandler;
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Autowired
private RestAuthenticationEntryPoint restAuthenticationEntryPoint;
@Autowired
private RestfulAccessDeniedHandler accessDeniedHandler;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
/**
* 身份認證接口
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring()
.antMatchers(HttpMethod.GET,
"/swagger-resources/**",
"/PearAdmin/**",
"/**/*.html",
"/**/*.css",
"/**/*.js",
"/swagger-ui.html",
"/webjars/**",
"/v2/**");//放行靜態(tài)資源
}
/**
* anyRequest | 匹配所有請求路徑
* access | SpringEl表達式結(jié)果為true時可以訪問
* anonymous | 匿名可以訪問
* denyAll | 用戶不能訪問
* fullyAuthenticated | 用戶完全認證可以訪問(非remember-me下自動登錄)
* hasAnyAuthority | 如果有參數(shù)蒜茴,參數(shù)表示權(quán)限,則其中任何一個權(quán)限可以訪問
* hasAnyRole | 如果有參數(shù)浆西,參數(shù)表示角色粉私,則其中任何一個角色可以訪問
* hasAuthority | 如果有參數(shù),參數(shù)表示權(quán)限近零,則其權(quán)限可以訪問
* hasIpAddress | 如果有參數(shù)诺核,參數(shù)表示IP地址抄肖,如果用戶IP和參數(shù)匹配,則可以訪問
* hasRole | 如果有參數(shù)窖杀,參數(shù)表示角色漓摩,則其角色可以訪問
* permitAll | 用戶可以任意訪問
* rememberMe | 允許通過remember-me登錄的用戶訪問
* authenticated | 用戶登錄后可訪問
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.addFilterBefore(verifyCodeFilter, UsernamePasswordAuthenticationFilter.class);
http.csrf().disable()//關(guān)閉csrf
.sessionManagement()// 基于token,所以不需要session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.httpBasic().authenticationEntryPoint(restAuthenticationEntryPoint)//未登陸時返回 JSON 格式的數(shù)據(jù)給前端入客,否則是html
.and()
.authorizeRequests()
.antMatchers("/captcha").permitAll()//任何人都能訪問這個請求
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login.html")//登錄頁面 不設限訪問
.loginProcessingUrl("/login")//攔截的請求
.successHandler(authenticationSuccessHandler) // 登錄成功處理器
.permitAll()
// 防止iframe 造成跨域
.and()
.headers()
.frameOptions()
.disable()
.and();
// 禁用緩存
http.headers().cacheControl();
// 添加JWT攔截器
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
我這里直接貼了完整的代碼管毙,因為有添加也有刪除,不是很好描述痊项,大家對比著之前的來看锅风,都添加了注釋。
現(xiàn)在我們重啟項目鞍泉,用admin賬號來登錄。登錄成功后發(fā)現(xiàn)頁面并沒有跳轉(zhuǎn)到我們想去的頁面肮帐,但是控制臺打印出了我們想要的jwt信息
{"code":200,"data":[],"jwt":"eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImlhdCI6MTU5NjI1OTgyOCwiZXhwIjoxNTk2ODY0NjI4fQ.Khn5t6WjOsuG6R2if1Q_gAeNq-zTamIAO32b1UVc6L8-6_IAHMaCeWr-v7H2-7Hob0SSmmK23dv71_da-YK8hw","msg":"登錄成功","success":true}
這是為什么呢咖驮?
著很好理解,因為我們的jwt攔截器已經(jīng)起了作用训枢,而我們原本的前端頁面是沒有把jwt token添加在header上的托修,所以認為沒有登錄,重定向到了登錄頁面恒界。
但是我們現(xiàn)在可以借助postman來測試睦刃,postman是一個測試api的工具,大家可以自行百度十酣,這里不做過多介紹涩拙。
在我們未攜帶jwt token信息時,訪問http://localhost:8080/api/menu接口耸采,就會報如下錯誤
我們在header中添加上兴泥,之前登錄成功控制臺打印的token信息(因為我們添加了圖片驗證碼,所以登錄不是很方便用postman虾宇,我們可以在瀏覽器中登錄或者先把驗證碼的攔截器去除)
加上了token信息之后再去訪問http://localhost:8080/api/menu接口搓彻,發(fā)現(xiàn)已經(jīng)可以正常訪問了
我們再嘗試用test用戶登錄后獲取到jwt token訪問該接口,會報如下錯誤
修改Swagger配置
直接貼代碼
/**
* @author codermy
* @createTime 2020/7/10
*/
@Configuration
@EnableSwagger2
public class SwaggerConfig {
@Value("${jwt.tokenHeader}")
private String tokenHeader;
@Value("${jwt.tokenHead}")
private String tokenHead;
@Bean
public Docket createRestApi() {
ParameterBuilder ticketPar = new ParameterBuilder();
List<Parameter> pars = new ArrayList<>();
ticketPar.name(tokenHeader).description("token")
.modelRef(new ModelRef("string"))
.parameterType("header")
.defaultValue(tokenHead + " ")
.required(true)
.build();
pars.add(ticketPar.build());
return new Docket(DocumentationType.SWAGGER_2)
.apiInfo(webApiInfo())
.select()
.apis(RequestHandlerSelectors.basePackage("com.codermy.myspringsecurityplus.controller"))
.paths(PathSelectors.any())
.paths(Predicates.not(PathSelectors.regex("/error.*")))
.build()
.globalOperationParameters(pars);
}
/**
* 該套 API 說明,包含作者嘱朽、簡介旭贬、版本、等信息
* @return
*/
private ApiInfo webApiInfo(){
return new ApiInfoBuilder()
.title("my-springsecurity-plus-API文檔")
.description("本文檔描述了my-springsecurity-plus接口定義")
.version("1.0.5")
.build();
}
}
現(xiàn)在再swagger中就可以添加token測試了
前端適配
那么我們現(xiàn)在已經(jīng)簡單的實現(xiàn)了jwt的無狀態(tài)登錄功能搪泳,需要做的就是讓前端的請求都帶上jwt token稀轨。
。森书。靶端。研究了半天沒弄懂谎势,所以暫時先擱置,下一章解決它杨名。有知道怎么設置請求頭的小伙伴也可以留言告訴我
所以本章結(jié)束的代碼是不能正常在瀏覽器運行的脏榆,但是可以在postman和swagger中測試(如果想運行,在SpringSecurityConfig中添加上.rememberMe()即可)