厲害!我?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ù)交,沒想到,他僅用四步就搞定了,這讓我感覺倍有面。

一、關(guān)于 SpringSecurity

在 Spring Boot 出現(xiàn)之前,SpringSecurity 的使用場(chǎng)景是被另外一個(gè)安全管理框架 Shiro 牢牢霸占的,因?yàn)橄鄬?duì)于 SpringSecurity 來說,SSM 中整合 Shiro 更加輕量級(jí)。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ā)起用戶登錄請(qǐng)求,服務(wù)器端接收并認(rèn)證成功后,生成一個(gè) JSON 對(duì)象(如下所示),然后將其返回給客戶端。

從本質(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 會(huì)調(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 來測(cè)試一下,首先是文章獲取接口,在沒有登錄的情況下會(huì)提示暫未登錄或者 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ì)地講過了,這里再簡(jiǎn)單補(bǔ)充一點(diǎn)。

客戶端的請(qǐng)求頭里攜帶了 token,服務(wù)端肯定是需要針對(duì)每次請(qǐng)求解析校驗(yàn) token 的,所以必須得定義一個(gè)過濾器,也就是 JwtAuthenticationTokenFilter:

  • 從請(qǐng)求頭中獲取 token
  • 對(duì) token 進(jìn)行解析、驗(yàn)簽、校驗(yàn)過期時(shí)間
  • 校驗(yàn)成功,將驗(yàn)證結(jié)果放到 ThreadLocal 中,供下次請(qǐng)求使用

重點(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();
        }

        // 任何請(qǐng)求需要身份認(rèn)證
        registry.and()
                .authorizeRequests()
                .anyRequest()
                .authenticated()
                // 關(guān)閉跨站請(qǐng)求防護(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 過濾器之前。

五、測(cè)試

第一步,測(cè)試登錄接口,Apipost 直接訪問 http://localhost:9002/users/login,可以看到 token 正常返回。

第二步,不帶 token 直接訪問文章接口,可以看到進(jìn)入了 RestAuthenticationEntryPoint 這個(gè)處理器:

第三步,攜帶 token,這次我們改用 Knife4j 來測(cè)試,發(fā)現(xiàn)可以正常訪問:

源碼鏈接:

https://github.com/itwanger/coding-more

本篇已收錄至 GitHub 上星標(biāo) 1.9k+ star 的開源專欄《Java 程序員進(jìn)階之路》,據(jù)說每一個(gè)優(yōu)秀的 Java 程序員都喜歡她,風(fēng)趣幽默、通俗易懂。內(nèi)容包括 Java 基礎(chǔ)、Java 并發(fā)編程、Java 虛擬機(jī)、Java 企業(yè)級(jí)開發(fā)、Java 面試等核心知識(shí)點(diǎn)。學(xué) Java,就認(rèn)準(zhǔn) Java 程序員進(jìn)階之路??。

https://github.com/itwanger/toBeBetterJavaer

star 了這個(gè)倉庫就等于你擁有了成為了一名優(yōu)秀 Java 工程師的潛力。也可以戳下面的鏈接跳轉(zhuǎn)到《Java 程序員進(jìn)階之路》的官網(wǎng)網(wǎng)址,開始愉快的學(xué)習(xí)之旅吧。

https://tobebetterjavaer.com/

沒有什么使我停留——除了目的,縱然岸旁有玫瑰、有綠蔭、有寧靜的港灣,我是不系之舟。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容