Spring Security 玩出花!兩種方式 DIY 登入

語言: CN / TW / HK

@[toc]

一般情況下,我們在使用 Spring Security 的時候,用的是 Spring Security 自帶的登入方案,配置一下登入介面,配置一下登入引數,再配置一下登入回撥就能用了,這種用法可以算是最佳實踐了!

但是!

總會有一些奇奇怪怪得需求,例如想自定義登入,像 Shiro 那樣自己寫登入邏輯,如果要實現這一點,該怎麼做?今天松哥就來和大家分享一下。

松哥琢磨了一下,想在 Spring Security 中自定義登入邏輯,我們有兩種思路,不過這兩種思路底層實現其實異曲同工,我們一起來看下。

1. 化腐朽為神奇

前面松哥和大家分享了一個 Spring Security 影片:

這個影片裡主要是和大家分享了我們其實可以使用 HttpServletRequest 來完成系統的登入,這其實是 JavaEE 的規範,這種登入方式雖然冷門,但是卻很好玩!

然後松哥還和大家分享了一個影片:

這個影片其實是在講 Spring Security 對 HttpServletRequest 登入邏輯的實現,或句話說,HttpServletRequest 中提供的那幾個和登入相關的 API,Spring Security 都按照自己的實現方式對其進行了重寫。

有了這兩個儲備知識後,第一個 DIY Spring Security 登入的方案呼之欲出。

1.1 實踐

我們來看看具體操作。

首先我們來建立一個 Spring Boot 工程,引入 Web 和 Security 兩個依賴,如下:

方便起見,我們在 application.properties 中配置一下預設的使用者名稱密碼:

spring.security.user.name=javaboy
spring.security.user.password=123

接下來我們提供一個 SecurityConfig,為登入介面放行:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/login")
                .permitAll()
                .anyRequest().authenticated()
                .and()
                .csrf().disable();
    }
}

登入介面就是 /login ,一會我們自定義的登入邏輯就寫在這個裡邊,我們來看下:

@RestController
public class LoginController {
    @PostMapping("/login")
    public String login(String username, String password, HttpServletRequest req) {
        try {
            req.login(username, password);
            return "success";
        } catch (ServletException e) {
            e.printStackTrace();
        }
        return "failed";
    }
}

直接呼叫 HttpServletRequest#login 方法,傳入使用者名稱和密碼完成登入操作。

最後我們再提供一個測試介面,如下:

@RestController
public class HelloController {
    @GetMapping("/hello")
    public String hello() {
        return "hello security!";
    }
}

just this!

啟動專案,我們首先訪問 /hello 介面,會訪問失敗,接下來我們訪問 /login 介面執行登入操作,如下:

登入成功之後,再去訪問 /hello 介面,此時就可以訪問成功了。

是不是很 Easy?登入成功後,以後的授權等操作都還是原來的寫法不變。

1.2 原理分析

上面這種登入方式的原理其實松哥一開始就介紹過了,如果大家還不熟悉,可以看看這兩個影片就懂了:

這裡我也是稍微說兩句。

我們在 LoginController#login 方法中所獲取到的 HttpServletRequest 例項其實是 HttpServlet3RequestFactory 中的一個內部類 Servlet3SecurityContextHolderAwareRequestWrapper 的物件,在這個類中,重寫了 HttpServletRequest 的 login 以及 authenticate 等方法,我們先來看看 login 方法,如下:

@Override
public void login(String username, String password) throws ServletException {
    if (isAuthenticated()) {
        throw new ServletException("Cannot perform login for '" + username + "' already authenticated as '"
                + getRemoteUser() + "'");
    }
    AuthenticationManager authManager = HttpServlet3RequestFactory.this.authenticationManager;
    if (authManager == null) {
        HttpServlet3RequestFactory.this.logger.debug(
                "authenticationManager is null, so allowing original HttpServletRequest to handle login");
        super.login(username, password);
        return;
    }
    Authentication authentication = getAuthentication(authManager, username, password);
    SecurityContextHolder.getContext().setAuthentication(authentication);
}

可以看到:

  1. 如果使用者已經認證了,就丟擲異常。
  2. 獲取到一個 AuthenticationManager 物件。
  3. 呼叫 getAuthentication 方法完成登入,在該方法中,會根據使用者名稱密碼構建 UsernamePasswordAuthenticationToken 物件,然後呼叫 Authentication#authenticate 方法完成登入,具體程式碼如下:
private Authentication getAuthentication(AuthenticationManager authManager, String username, String password)
        throws ServletException {
    try {
        return authManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
    }
    catch (AuthenticationException ex) {
        SecurityContextHolder.clearContext();
        throw new ServletException(ex.getMessage(), ex);
    }
}

該方法返回的是一個認證後的 Authentication 物件。

  1. 最後,將認證後的 Authentication 物件存入 SecurityContextHolder 中,這裡的具體邏輯我就不囉嗦了,我在公眾號【江南一點雨】之前的影片中已經講過多次了。

這就是 login 方法的執行邏輯。

Servlet3SecurityContextHolderAwareRequestWrapper 類也重寫了 HttpServletRequest#authenticate 方法,這個也是做認證的方法:

@Override
public boolean authenticate(HttpServletResponse response) throws IOException, ServletException {
    AuthenticationEntryPoint entryPoint = HttpServlet3RequestFactory.this.authenticationEntryPoint;
    if (entryPoint == null) {
        HttpServlet3RequestFactory.this.logger.debug(
                "authenticationEntryPoint is null, so allowing original HttpServletRequest to handle authenticate");
        return super.authenticate(response);
    }
    if (isAuthenticated()) {
        return true;
    }
    entryPoint.commence(this, response,
            new AuthenticationCredentialsNotFoundException("User is not Authenticated"));
    return false;
}

可以看到,這個方法用來判斷使用者是否已經完成認證操作,返回 true 表示使用者已經完成認證,返回 false 表示使用者尚未完成認證工作。

2. 原始碼的力量

看了上面的原理分析,大家應該也明白了第二種方案了,就是不使用 HttpServletRequest#login 方法,我們直接呼叫 AuthenticationManager 進行登入驗證。

一起來看下。

首先我們修改配置類如下:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/login","/login2")
                .permitAll()
                .anyRequest().authenticated()
                .and()
                .csrf().disable();
    }

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(User.withUsername("javaboy").password("{noop}123").roles("admin").build());
        provider.setUserDetailsService(manager);
        return new ProviderManager(provider);
    }
}
/login2
ss

接下來提供一個登入介面:

@RestController
public class LoginController {
    @Autowired
    AuthenticationManager authenticationManager;
    @PostMapping("/login2")
    public String login2(String username, String password, HttpServletRequest req) {
        try {
            Authentication token = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
            SecurityContextHolder.getContext().setAuthentication(token);
            return "success";
        } catch (Exception e) {
            e.printStackTrace();
        }
        return "failed";
    }
}

在登入介面中,傳入使用者名稱密碼等引數,然後將使用者名稱密碼等引數封裝成一個 UsernamePasswordAuthenticationToken 物件,最後呼叫 AuthenticationManager#authenticate 方法進行驗證,驗證成功後會返回一個認證後的 Authentication 物件,再手動把該 Authentication 物件存入 SecurityContextHolder 中。

配置完成後,重啟專案,進行登入測試即可。

第二種方案和第一種方案異曲同工,第二種實際上就是把第一種的底層拉出來自己重新實現, 僅此而已

3. 小結

好啦,今天就和大家介紹了兩種 Spring Security DIY 登入的方案,這些方案可能工作中並不常用,但是對於大家理解 Spring Security 原理還是大有裨益的,感興趣的小夥伴可以敲一下試試哦~

另外,如果你感覺閱讀本文吃力,不妨在公眾號後臺回覆 ss ,看看 Spring Security 系列的其他文章,這有助於理解本文,當然也可以看看松哥的新書:

《深入淺出Spring Security》一書已由清華大學出版社正式出版發行,感興趣的小夥伴戳這裡 ->->>深入淺出Spring Security ,一本書學會 Spring Security。