厲害!我?guī)У膶?shí)習(xí)生僅用四步就整合SpringSecurity+JWT實(shí)現(xiàn)登錄認(rèn)證!
小二是新來的實(shí)習(xí)生,作為技術(shù) leader,我還是很負(fù)責(zé)任的,有什么鍋都想甩給他,啊,不,一不小心怎么把心里話全說出來了呢?重來!
小二是新來的實(shí)習(xí)生,作為技術(shù) leader,我還是很負(fù)責(zé)任的,有什么好事都想著他,這不,我就安排了一個(gè)整合SpringSecurity+JWT實(shí)現(xiàn)登錄認(rèn)證的任務(wù),沒想到,他僅用四步就搞定了,這讓我當(dāng)場就忍不住表揚(yáng)了他:太強(qiáng)了,兄弟!
以下是他在完成任務(wù)時(shí)做的筆記,我讀完后的感覺只有一個(gè):文筆雖然青澀卻娓娓道來,簡直就是公司未來的棟梁之材,各大技術(shù)社區(qū)的博客之星。嗯嗯嗯嗯,分享出來,給大家一個(gè)贊美(吐槽)的機(jī)會:請?jiān)谠u論區(qū)火力全開,別顧及我的面子。
一、關(guān)于 SpringSecurity
在 Spring Boot 出現(xiàn)之前,SpringSecurity 的使用場景是被另外一個(gè)安全管理框架 Shiro 牢牢霸占的,因?yàn)橄鄬τ? SpringSecurity 來說,SSM 中整合 Shiro 更加輕量級。Spring Boot 出現(xiàn)后,使這一情況情況大有改觀。正應(yīng)了那句古話:一人得道雞犬升天,雖然有點(diǎn)不大合適,就將就著用吧。
這是因?yàn)?Spring Boot 為 SpringSecurity 提供了自動(dòng)化配置,大大降低了 SpringSecurity 的學(xué)習(xí)成本。另外,SpringSecurity 的功能也比 Shiro 更加強(qiáng)大。
二、關(guān)于 JWT
JWT,是目前最流行的一個(gè)跨域認(rèn)證解決方案:客戶端發(fā)起用戶登錄請求,服務(wù)器端接收并認(rèn)證成功后,生成一個(gè) JSON 對象(如下所示),然后將其返回給客戶端。
從本質(zhì)上來說,JWT 就像是一種生成加密用戶身份信息的 Token,更安全也更靈活。
三、整合步驟
第一步,給需要登錄認(rèn)證的模塊添加 codingmore-security 依賴:
<dependency>
<groupId>top.codingmore</groupId>
<artifactId>codingmore-security</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
比如說 codingmore-admin 后端管理模塊需要登錄認(rèn)證,就在 codingmore-admin/pom.xml 文件中添加 codingmore-security 依賴。
第二步,在需要登錄認(rèn)證的模塊里添加 CodingmoreSecurityConfig 類,繼承自 codingmore-security 模塊中的 SecurityConfig 類。
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class CodingmoreSecurityConfig extends SecurityConfig {
@Autowired
private IUsersService usersService;
@Bean
public UserDetailsService userDetailsService() {
//獲取登錄用戶信息
return username -> usersService.loadUserByUsername(username);
}
}
UserDetailsService 這個(gè)類主要是用來加載用戶信息的,包括用戶名、密碼、權(quán)限、角色集合....其中有一個(gè)方法如下:
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
認(rèn)證邏輯中,SpringSecurity 會調(diào)用這個(gè)方法根據(jù)客戶端傳入的用戶名加載該用戶的詳細(xì)信息,包括判斷:
- 密碼是否一致
- 通過后獲取權(quán)限和角色
public UserDetails loadUserByUsername(String username) {
// 根據(jù)用戶名查詢用戶
Users admin = getAdminByUsername(username);
if (admin != null) {
List<Resource> resourceList = getResourceList(admin.getId());
return new AdminUserDetails(admin,resourceList);
}
throw new UsernameNotFoundException("用戶名或密碼錯(cuò)誤");
}
getAdminByUsername 負(fù)責(zé)根據(jù)用戶名從數(shù)據(jù)庫中查詢出密碼、角色、權(quán)限等。
public Users getAdminByUsername(String username) {
QueryWrapper<Users> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("user_login", username);
List<Users> usersList = baseMapper.selectList(queryWrapper);
if (usersList != null && usersList.size() > 0) {
return usersList.get(0);
}
// 用戶名錯(cuò)誤,提前拋出異常
throw new UsernameNotFoundException("用戶名錯(cuò)誤");
}
第三步,在 application.yml 中配置下不需要安全保護(hù)的資源路徑:
secure:
ignored:
urls: #安全路徑白名單
- /doc.html
- /swagger-ui/**
- /swagger/**
- /swagger-resources/**
- /**/v3/api-docs
- /**/*.js
- /**/*.css
- /**/*.png
- /**/*.ico
- /webjars/springfox-swagger-ui/**
- /actuator/**
- /druid/**
- /users/login
- /users/register
- /users/info
- /users/logout
第四步,在登錄接口中添加登錄和刷新 token 的方法:
@Controller
@Api(tags = "用戶")
@RequestMapping("/users")
public class UsersController {
@Autowired
private IUsersService usersService;
@Value("${jwt.tokenHeader}")
private String tokenHeader;
@Value("${jwt.tokenHead}")
private String tokenHead;
@ApiOperation(value = "登錄以后返回token")
@RequestMapping(value = "/login", method = RequestMethod.POST)
@ResponseBody
public ResultObject login(@Validated UsersLoginParam users, BindingResult result) {
String token = usersService.login(users.getUserLogin(), users.getUserPass());
if (token == null) {
return ResultObject.validateFailed("用戶名或密碼錯(cuò)誤");
}
// 將 JWT 傳遞回客戶端
Map<String, String> tokenMap = new HashMap<>();
tokenMap.put("token", token);
tokenMap.put("tokenHead", tokenHead);
return ResultObject.success(tokenMap);
}
@ApiOperation(value = "刷新token")
@RequestMapping(value = "/refreshToken", method = RequestMethod.GET)
@ResponseBody
public ResultObject refreshToken(HttpServletRequest request) {
String token = request.getHeader(tokenHeader);
String refreshToken = usersService.refreshToken(token);
if (refreshToken == null) {
return ResultObject.failed("token已經(jīng)過期!");
}
Map<String, String> tokenMap = new HashMap<>();
tokenMap.put("token", refreshToken);
tokenMap.put("tokenHead", tokenHead);
return ResultObject.success(tokenMap);
}
}
使用 Apipost 來測試一下,首先是文章獲取接口,在沒有登錄的情況下會提示暫未登錄或者 token 已過期。
四、實(shí)現(xiàn)原理
小二之所以能僅用四步就實(shí)現(xiàn)了登錄認(rèn)證,主要是因?yàn)樗麑?SpringSecurity+JWT 的代碼封裝成了通用模塊,我們來看看 codingmore-security 的目錄結(jié)構(gòu)。
codingmore-security
├── component
| ├── JwtAuthenticationTokenFilter -- JWT登錄授權(quán)過濾器
| ├── RestAuthenticationEntryPoint
| └── RestfulAccessDeniedHandler
├── config
| ├── IgnoreUrlsConfig
| └── SecurityConfig
└── util
└── JwtTokenUtil -- JWT的token處理工具類
JwtAuthenticationTokenFilter 和 JwtTokenUtil 在講 JWT 的時(shí)候已經(jīng)詳細(xì)地講過了,這里再簡單補(bǔ)充一點(diǎn)。
客戶端的請求頭里攜帶了 token,服務(wù)端肯定是需要針對每次請求解析校驗(yàn) token 的,所以必須得定義一個(gè)過濾器,也就是 JwtAuthenticationTokenFilter:
- 從請求頭中獲取 token
- 對 token 進(jìn)行解析、驗(yàn)簽、校驗(yàn)過期時(shí)間
- 校驗(yàn)成功,將驗(yàn)證結(jié)果放到 ThreadLocal 中,供下次請求使用
重點(diǎn)來看其他四個(gè)類。第一個(gè) RestAuthenticationEntryPoint(自定義返回結(jié)果:未登錄或登錄過期):
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Cache-Control","no-cache");
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().println(JSONUtil.parse(ResultObject.unauthorized(authException.getMessage())));
response.getWriter().flush();
}
}
可以通過 debug 的方式看一下返回的信息正是之前用戶未登錄狀態(tài)下訪問文章頁的錯(cuò)誤信息。
具體的信息是在 ResultCode 類中定義的。
public enum ResultCode implements IErrorCode {
SUCCESS(0, "操作成功"),
FAILED(500, "操作失敗"),
VALIDATE_FAILED(506, "參數(shù)檢驗(yàn)失敗"),
UNAUTHORIZED(401, "暫未登錄或token已經(jīng)過期"),
FORBIDDEN(403, "沒有相關(guān)權(quán)限");
private long code;
private String message;
private ResultCode(long code, String message) {
this.code = code;
this.message = message;
}
}
第二個(gè) RestfulAccessDeniedHandler(自定義返回結(jié)果:沒有權(quán)限訪問時(shí)):
public class RestfulAccessDeniedHandler implements AccessDeniedHandler{
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException e) throws IOException, ServletException {
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Cache-Control","no-cache");
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().println(JSONUtil.parse(ResultObject.forbidden(e.getMessage())));
response.getWriter().flush();
}
}
第三個(gè)IgnoreUrlsConfig(用于配置不需要安全保護(hù)的資源路徑):
@Getter
@Setter
@ConfigurationProperties(prefix = "secure.ignored")
public class IgnoreUrlsConfig {
private List<String> urls = new ArrayList<>();
}
通過 lombok 注解的方式直接將配置文件中不需要權(quán)限校驗(yàn)的路徑放開,比如說 Knife4j 的接口文檔頁面。如果不放開的話,就被 SpringSecurity 攔截了,沒辦法訪問到了。
第四個(gè)SecurityConfig(SpringSecurity通用配置):
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired(required = false)
private DynamicSecurityService dynamicSecurityService;
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = httpSecurity
.authorizeRequests();
//不需要保護(hù)的資源路徑允許訪問
for (String url : ignoreUrlsConfig().getUrls()) {
registry.antMatchers(url).permitAll();
}
// 任何請求需要身份認(rèn)證
registry.and()
.authorizeRequests()
.anyRequest()
.authenticated()
// 關(guān)閉跨站請求防護(hù)及不使用session
.and()
.csrf()
.disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// 自定義權(quán)限拒絕處理類
.and()
.exceptionHandling()
.accessDeniedHandler(restfulAccessDeniedHandler())
.authenticationEntryPoint(restAuthenticationEntryPoint())
// 自定義權(quán)限攔截器JWT過濾器
.and()
.addFilterBefore(jwtAuthenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class);
//有動(dòng)態(tài)權(quán)限配置時(shí)添加動(dòng)態(tài)權(quán)限校驗(yàn)過濾器
if(dynamicSecurityService!=null){
registry.and().addFilterBefore(dynamicSecurityFilter(), FilterSecurityInterceptor.class);
}
}
}
這個(gè)類的主要作用就是告訴 SpringSecurity 那些路徑不需要攔截,除此之外的,都要進(jìn)行 RestfulAccessDeniedHandler(登錄校驗(yàn))、RestAuthenticationEntryPoint(權(quán)限校驗(yàn))和 JwtAuthenticationTokenFilter(JWT 過濾)。
并且將 JwtAuthenticationTokenFilter 過濾器添加到 UsernamePasswordAuthenticationFilter 過濾器之前。
五、測試
第一步,測試登錄接口,Apipost 直接訪問 http://localhost:9002/users/login,可以看到 token 正常返回。
第二步,不帶 token 直接訪問文章接口,可以看到進(jìn)入了 RestAuthenticationEntryPoint 這個(gè)處理器:
第三步,攜帶 token,這次我們改用 Knife4j 來測試,發(fā)現(xiàn)可以正常訪問:
源碼鏈接:
https://github.com/itwanger/coding-more
參考鏈接:
http://www.macrozheng.com/
https://juejin.cn/post/7036556688303849480