【Spring Security】實現多種認證方式

kamier發表於2023-02-25

一、引言

實際系統通常需要實現多種認證方式,比如使用者名稱密碼、手機驗證碼、郵箱等等。Spring Security可以透過自定義認證器AuthenticationProvider 來實現不同的認證方式。接下來介紹一下SpringSecurity具體如何來實現多種認證方式。

二、具體步驟

這裡我們以使用者名稱密碼、手機驗證碼兩種方式來進行演示,其他一些登入方式類似。

2.1 自定義認證器AuthenticationProvider

首先針對每一種登入方式,我們可以定義其對應的認證器AuthenticationProvider,以及對應的認證資訊Authentication實際場景中這兩個一般是配套使用。認證器AuthenticationProvider有一個認證方法authenticate(),我們需要實現該認證方法,認證成功之後返回認證資訊Authentication。

2.1.1 手機驗證碼

針對手機驗證碼方式,我們可以定義以下兩個類
MobilecodeAuthenticationProvider.class

import com.kamier.security.web.service.MyUser;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

import java.util.HashMap;
import java.util.Map;

public class MobilecodeAuthenticationProvider implements AuthenticationProvider {

    private UserDetailsService userDetailsService;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        MobilecodeAuthenticationToken mobilecodeAuthenticationToken = (MobilecodeAuthenticationToken) authentication;
        String phone = mobilecodeAuthenticationToken.getPhone();
        String mobileCode = mobilecodeAuthenticationToken.getMobileCode();
        System.out.println("登陸手機號:" + phone);
        System.out.println("手機驗證碼:" + mobileCode);

        // 模擬從redis中讀取手機號對應的驗證碼及其使用者名稱
        Map<String, String> dataFromRedis = new HashMap<>();
        dataFromRedis.put("code", "6789");
        dataFromRedis.put("username", "admin");

        // 判斷驗證碼是否一致
        if (!mobileCode.equals(dataFromRedis.get("code"))) {
            throw new BadCredentialsException("驗證碼錯誤");
        }

        // 如果驗證碼一致,從資料庫中讀取該手機號對應的使用者資訊
        MyUser loadedUser = (MyUser) userDetailsService.loadUserByUsername(dataFromRedis.get("username"));
        if (loadedUser == null) {
            throw new UsernameNotFoundException("使用者不存在");
        } else {
            MobilecodeAuthenticationToken result = new MobilecodeAuthenticationToken(loadedUser, null, loadedUser.getAuthorities());
            return result;
        }
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return MobilecodeAuthenticationToken.class.isAssignableFrom(aClass);
    }

    public void setUserDetailsService(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }
}

注意這裡的supports方法,是實現多種認證方式的關鍵,認證管理器AuthenticationManager會透過這個supports方法來判定當前需要使用哪一種認證方式

MobilecodeAuthenticationToken.class

import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import java.util.Collection;

/**
 * 手機驗證碼認證資訊,在UsernamePasswordAuthenticationToken的基礎上新增屬性 手機號、驗證碼
 */
public class MobilecodeAuthenticationToken extends AbstractAuthenticationToken {
    private static final long serialVersionUID = 530L;
    private Object principal;
    private Object credentials;
    private String phone;
    private String mobileCode;


    public MobilecodeAuthenticationToken(String phone, String mobileCode) {
        super(null);
        this.phone = phone;
        this.mobileCode = mobileCode;
        this.setAuthenticated(false);
    }

    public MobilecodeAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        this.credentials = credentials;
        super.setAuthenticated(true);
    }

    public Object getCredentials() {
        return this.credentials;
    }

    public Object getPrincipal() {
        return this.principal;
    }

    public String getPhone() {
        return phone;
    }

    public String getMobileCode() {
        return mobileCode;
    }

    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        if (isAuthenticated) {
            throw new IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
        } else {
            super.setAuthenticated(false);
        }
    }

    public void eraseCredentials() {
        super.eraseCredentials();
        this.credentials = null;
    }
}

2.1.2 使用者名稱密碼

針對使用者名稱密碼方式,我們可以直接使用自帶的DaoAuthenticationProvider以及對應的UsernamePasswordAuthenticationToken。

2.2 實現UserDetailService

UserDetailService服務用以返回當前登入使用者的使用者資訊,可以每一種認證方式實現對應的UserDetailService,也可以使用同一個。這裡我們使用同一個UserDetailService服務,程式碼如下:

MyUserDetailsService.class

import com.google.common.collect.Lists;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

@Service
public class MyUserDetailsService implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String username) throws AuthenticationException {
        MyUser myUser;
        // 這裡模擬從資料庫中獲取使用者資訊
        if (username.equals("admin")) {
            myUser = new MyUser("admin", new BCryptPasswordEncoder().encode("123456"), Lists.newArrayList("p1", "p2"));
            myUser.setAge(25);
            myUser.setSex(1);
            myUser.setAddress("xxxx小區");
            return myUser;
        } else {
            throw new UsernameNotFoundException("使用者不存在");
        }
    }
}

MyUser.class

import com.google.common.collect.Lists;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

public class MyUser extends User {

    private int sex;
    private int age;
    private String address;
    public MyUser(String username, String password, List<String> authorities) {
        super(username, password, Optional.ofNullable(authorities).orElse(Lists.newArrayList()).stream()
                .map(str -> (GrantedAuthority) () -> str)
                .collect(Collectors.toList()));
    }

    public int getSex() {
        return sex;
    }

    public void setSex(int sex) {
        this.sex = sex;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public String getAddress() {
        return address;
    }

    public void setAddress(String address) {
        this.address = address;
    }
}

2.3 統一處理認證異常

定義一個認證異常處理器,統一處理認證異常AuthenticationException,如下

@Component
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        R result = R.error("使用者未登入或已過期");
        response.setContentType("text/json;charset=utf-8");
        response.getWriter().write(new Gson().toJson(result));
    }
}

2.4 配置器WebSecurityConfigurer

在配置器中我們去例項化一個認證管理器AuthenticationManager,這個認證管理器中包含了兩個認證器,分別是MobilecodeAuthenticationProvider(手機驗證碼)、DaoAuthenticationProvider(使用者名稱密碼)。

重寫config方法進行security的配置:

  1. 登入相關介面的放行,其他介面需要認證
  2. 配置認證異常處理器

MySecurityConfigurer.class

@Configuration
public class MySecurityConfigurer extends WebSecurityConfigurerAdapter {

    @Autowired
    private MyAuthenticationEntryPoint myAuthenticationEntryPoint;

    @Autowired
    private UserDetailsService myUserDetailsService;

    @Autowired
    private TokenAuthenticationFilter tokenAuthenticationFilter;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public MobilecodeAuthenticationProvider mobilecodeAuthenticationProvider() {
        MobilecodeAuthenticationProvider mobilecodeAuthenticationProvider = new MobilecodeAuthenticationProvider();
        mobilecodeAuthenticationProvider.setUserDetailsService(myUserDetailsService);
        return mobilecodeAuthenticationProvider;
    }

    @Bean
    public DaoAuthenticationProvider daoAuthenticationProvider() {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        daoAuthenticationProvider.setUserDetailsService(myUserDetailsService);
        return daoAuthenticationProvider;
    }

    /**
     * 定義認證管理器AuthenticationManager
     * @return
     */
    @Bean
    public AuthenticationManager authenticationManager() {
        List<AuthenticationProvider> authenticationProviders = new ArrayList<>();
        authenticationProviders.add(mobilecodeAuthenticationProvider());
        authenticationProviders.add(daoAuthenticationProvider());
        ProviderManager authenticationManager = new ProviderManager(authenticationProviders);
//        authenticationManager.setEraseCredentialsAfterAuthentication(false);
        return authenticationManager;

    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
                // 關閉csrf
                .csrf().disable()
                // 處理認證異常
                .exceptionHandling().authenticationEntryPoint(myAuthenticationEntryPoint)
                .and()
                // 許可權配置,登入相關的請求放行,其餘需要認證
                .authorizeRequests()
                .antMatchers("/login/*").permitAll()
                .anyRequest().authenticated()
                .and()
                // 新增token認證過濾器
                .addFilterAfter(tokenAuthenticationFilter, LogoutFilter.class)
                // 不使用session會話管理
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }
}

到這裡實現多種認證方式基本就結束了。

但在實際專案中,認證成功後通常會返回一個token令牌(如jwt等),後續我們將token放到請求頭中進行請求,後端校驗該token,校驗成功後再訪問相應的介面,所以這裡在上面的配置中加了一個token認證過濾器TokenAuthenticationFilter

TokenAuthenticationFilter的程式碼如下:

@Component
@WebFilter
public class TokenAuthenticationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
        String token = httpServletRequest.getHeader("token");

        // 如果沒有token,跳過該過濾器
        if (!StringUtils.isEmpty(token)) {
            // 模擬redis中的資料
            Map<String, MyUser> map = new HashMap<>();
            map.put("test_token1", new MyUser("admin", new BCryptPasswordEncoder().encode("123456"), Lists.newArrayList("p1", "p2")));
            map.put("test_token2", new MyUser("root", new BCryptPasswordEncoder().encode("123456"), Lists.newArrayList("p1")));

            // 這裡模擬從redis獲取token對應的使用者資訊
            MyUser myUser = map.get(token);
            if (myUser != null) {
                UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(myUser, null, myUser.getAuthorities());
                SecurityContextHolder.getContext().setAuthentication(authRequest);
            } else {
                throw new PreAuthenticatedCredentialsNotFoundException("token不存在");
            }
        }

        filterChain.doFilter(httpServletRequest, httpServletResponse);
    }
}

三、測試驗證

編寫一個簡單的Controller來驗證多種登入方式,程式碼如下:

@RestController
@RequestMapping("/login")
public class LoginController {

    @Autowired
    private AuthenticationManager authenticationManager;

    /**
     * 使用者名稱密碼登入
     * @param username
     * @param password
     * @return
     */
    @GetMapping("/usernamePwd")
    public R usernamePwd(String username, String password) {
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(username, password);
        Authentication authenticate = null;
        try {
            authenticate = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
        } catch (Exception e) {
            e.printStackTrace();
            return R.error("登陸失敗");
        }

        String token = UUID.randomUUID().toString().replace("-", "");
        return R.ok(token, "登陸成功");
    }

    /**
     * 手機驗證碼登入
     * @param phone
     * @param mobileCode
     * @return
     */
    @GetMapping("/mobileCode")
    public R mobileCode(String phone, String mobileCode) {
        MobilecodeAuthenticationToken mobilecodeAuthenticationToken = new MobilecodeAuthenticationToken(phone, mobileCode);
        Authentication authenticate = null;
        try {
            authenticate = authenticationManager.authenticate(mobilecodeAuthenticationToken);
        } catch (Exception e) {
            e.printStackTrace();
            return R.error("驗證碼錯誤");
        }

        String token = UUID.randomUUID().toString().replace("-", "");
        return R.ok(token, "登陸成功");
    }
}
  • 使用者名稱密碼
    訪問/login/usernamePwd介面進行登入,賬號密碼為admin/123456,可以看到訪問成功,如下圖
    image.png
  • 手機驗證碼
    訪問/login/mobileCode介面進行登入,如下圖
    image.png
  • 帶token訪問
    在請求頭帶上token訪問介面,如下圖
    image.png
  • 不帶token訪問
    image.png

到這裡Spring Security實現多種認證方式就結束了,如有錯誤,感謝指正。

相關文章