一、引言
實際系統通常需要實現多種認證方式,比如使用者名稱密碼、手機驗證碼、郵箱等等。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的配置:
- 登入相關介面的放行,其他介面需要認證
- 配置認證異常處理器
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,可以看到訪問成功,如下圖 - 手機驗證碼
訪問/login/mobileCode介面進行登入,如下圖 - 帶token訪問
在請求頭帶上token訪問介面,如下圖 - 不帶token訪問
到這裡Spring Security實現多種認證方式就結束了,如有錯誤,感謝指正。