web-security 第三期:暢談 Spring Security Authentication (認證)

所有的安全框架都有兩個很重要的組成部分,認證 和  授權 ,簡單的說,認證就是判斷你是誰,授權就是你有權限幹啥,這一期我們先來談一談Spring Security Authentication (認證方式) ,本節源碼地址 (spring-security模塊)

目錄

1.從一個極簡的例子開始(基於內存的認證)

2.基於JDBC的內存認證

2.1.UserDetails

2.2.UserDetailsService

2.3.PasswordEncoder

2.4. DaoAuthenticationProvider

3. 記住我

 


1.從一個極簡的例子開始(基於內存的認證)

首先完成Security的基本配置:

/**
 * @author swing
 */
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
      http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                .authorizeRequests()
                //允許匿名訪問的api,其他的需要驗證
                .antMatchers("/login", "/login/page").permitAll()
                // 除上面外的所有請求全部需要鑑權認證
                .anyRequest().authenticated();
    }

    /**
     * 將認證信息存儲在內存中(這裏相當於初始化了兩個用戶信息)
     *
     * @return 認證信息
     */
    @Bean
    public UserDetailsService users() {
        UserDetails user = User.builder()
                .username("user")
                .password(bCryptPasswordEncoder().encode("112233"))
                .roles("USER")
                .build();
        UserDetails admin = User.builder()
                .username("admin")
                .password(bCryptPasswordEncoder().encode("112233"))
                .roles("USER", "ADMIN")
                .build();
        return new InMemoryUserDetailsManager(user, admin);
    }

    /**
     * 解決 無法直接注入 AuthenticationManager
     *
     * @return AuthenticationManager
     * @throws Exception 異常
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

然後配置登錄接口:

    @Resource
    private AuthenticationManager authenticationManager;
     
    @PostMapping
    @ResponseBody
    Object loginIn(String username, String password) {
        //驗證用戶名和密碼
        Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
        return authentication.getPrincipal();
    }

測試結果:

POST http://localhost:8080/login?username=admin&password=112233

response:
{
  "password": null,
  "username": "admin",
  "authorities": [
    {
      "authority": "ROLE_ADMIN"
    },
    {
      "authority": "ROLE_USER"
    }
  ],
  "accountNonExpired": true,
  "accountNonLocked": true,
  "credentialsNonExpired": true,
  "enabled": true
}

Response code: 200; Time: 455ms; Content length: 198 bytes



POST http://localhost:8080/login?username=admin&password=11223


{
  "timestamp": "2020-06-10T09:50:04.327+00:00",
  "status": 403,
  "error": "Forbidden",
  "trace": "org.springframework.security.authentication.BadCredentialsException: Bad credentials"
  "message": "Access Denied",
  "path": "/login"
}

Response code: 403; Time: 85ms; Content length: 9274 bytes

 

2.基於JDBC的內存認證

前面是認證用戶密碼的最簡單的例子,當然在實際開發中我們並不會這樣做,這是基於內存的認證,即用戶名和密碼等信息是存儲在內存中的,實際開發中當然是將他們放在數據庫中, 也就是基於JDBC的認證,這裏先介紹幾個類

2.1.UserDetails

顧名思義,這個類是存儲的是用戶的詳細信息:(結構大概如下)

{
  "password": null,
  "username": "admin",
//用戶角色(角色是權限的集合)
  "authorities": [
    {
      "authority": "ROLE_ADMIN"
    },
    {
      "authority": "ROLE_USER"
    }
  ],
  "accountNonExpired": true,
  "accountNonLocked": true,
  "credentialsNonExpired": true,
  "enabled": true
}

Spring Security 已經爲我們提供了這個接口的默認實現,即上面的User類,但由於各個項目的用戶所有信息不盡相同,所以可以繼承此接口,自定義UserDetails,如下:

public class UserDetailsImpl implements UserDetails {
    /**
     * 數據庫用戶信息(將其包裝在userDetails中)
     */
    private UserDO userDO;

    /**
     * 返回授予用戶的權限
     *
     * @return 權限集合
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

    @Override
    public String getPassword() {
        return userDO.getPassword();
    }

    @Override
    public String getUsername() {
        return userDO.getUsername();
    }

    /**
     * 賬戶是否未過期,過期無法驗證
     */
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    /**
     * 指定用戶是否解鎖,鎖定的用戶無法進行身份驗證
     */
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    /**
     * 指示是否已過期的用戶的憑據(密碼),過期的憑據防止認證
     */
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    /**
     * 是否可用 ,禁用的用戶不能身份驗證
     */
    @Override
    public boolean isEnabled() {
        return true;
    }
}

2.2.UserDetailsService

DaoAuthenticationProvider使用UserDetailsS​​ervice檢索用戶名,密碼和其他用於使用用戶名和密碼進行身份驗證的屬性。 Spring Security提供UserDetailsS​​ervice的內存中(InMemoryUserDetailsManager)和JDBC(JdbcUserDetailsManager)實現。你也可以自定義UserDetailsService 然後將其註冊爲Bean,如下:

@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    @Resource
    private UserService userService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {        
        UserDO user = userService.getUserByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException(username + ":該用戶不存在!");
        }
        return new UserDetailsImpl(user);
    }
}

2.3.PasswordEncoder

 Spring Security 默認是不允許明文存儲密碼,並且其提供了許多加密方式

{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG 
{noop}password 
{pbkdf2}5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc 
{scrypt}$e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc=  
{sha256}97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0 

如需要指定當前項目的密碼加密模式,如下兩種方式都可:

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

     @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(new BCryptPasswordEncoder());
    }

回顧一下,會發現基於JDBC的認證相當於只是修改了認證信息的獲取路徑,從內存轉變到數據庫(這裏簡化例子,就不使用具體的數據庫操作了)Spring Security其實也爲我們提供了官方參考的表結構,以後會說到。

2.4. DaoAuthenticationProvider

上面配置可以正常的完成用戶驗證,但這是這麼做到的呢? 

DaoAuthenticationProvider is an AuthenticationProvider implementation that leverages a UserDetailsService and PasswordEncoder to authenticate a username and password.

usernamepasswordauthenticationfilter

daoauthenticationprovider

這裏着重說一下重要的兩個環節

  • 當請求到達過濾器 UsernamePasswordAuthenticationFilter 時,開始進行用戶信息的驗證
  • 認證成功的結果是什麼呢?即圖二的第五步,身份驗證成功後,返回的身份驗證的類型爲UsernamePasswordAuthenticationToken,其主體爲已配置的UserDetailsS​​ervice返回的UserDetails。最後,將通過authentication Filter 在SecurityContextHolder上設置返回的UsernamePasswordAuthenticationToken。

3. 記住我

我們先來討論幾個類:

securitycontextholder

 SecurityContextHolder 是 Spring Security身份驗證模型的核心,用於存儲通過身份驗證的人員的詳細信息。 Spring Security並不關心如何填充SecurityContextHolder。如果它包含一個值,那麼它將用作當前經過身份驗證的用戶,而觀察前面的認證過程,也就是向SecurityContextHolder中填充值的過程。

那麼有一個很奇怪的問題,如果我第一個請求(/login)正確認證了信息,那麼爲什麼我第二個請求就不能被服務器認證了?

應爲Http是無狀態的,所以你的每個請求對於服務器來說都是陌生的請求,服務器必須認證後才讓你訪問資源。因此SecurityContextHolder中的值在每個請求結束後都需要被清理,然後再認證,再填充,由於每個請求是一個跨越多個方法的線程,因此SecurityContextHolder使用ThreadLocal存儲這些詳細信息。官方文章這樣描述:

By default the SecurityContextHolder uses a ThreadLocal to store these details, which means that the SecurityContext is always available to methods in the same thread of execution, even if the SecurityContext is not explicitly passed around as an argument to those methods. Using a ThreadLocal in this way is quite safe if care is taken to clear the thread after the present principal’s request is processed. Spring Security’s FilterChainProxy ensures that the SecurityContext is always cleared.

而在此請求的線程作用域內,可以隨時獲取Authentication,如下:

SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
String username = authentication.getName();
Object principal = authentication.getPrincipal();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();

但如果每個請求都需要攜帶密碼,那可又麻煩又不安全,如何才能讓程序在一段時間內記住我呢?

很簡單:認證成功後,讓後臺給我分配一個令牌(token),這個令牌存有我的密碼和其他信息(加密過)接下來的每次請求,我都帶上這個令牌(有效期內),在servlet容器中建立一個過濾器,在請求到達UserpasswrodAuthenticationFilter之前,完成我的驗證,然後將認證結果填寫在SecurityContextHolder中,那麼此請求便可以訪問資源了,如下實例片段(我這裏使用的還是密碼驗證,實際開發使用令牌)下一期,我將使用JWT來實現上面闡述的令牌功能

@Component
public class AuthenticationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken("swing", "123456");
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        filterChain.doFilter(request, response);
    }
}

        //將認證設置在UsernamePasswordAuthenticationFilter之前
        http.addFilterBefore(authenticationFilter, UsernamePasswordAuthenticationFilter.class);

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章