概要
基於上文講解的spring cloud 授權服務的搭建,本文擴展了spring security 的登陸方式,增加手機驗證碼登陸、二維碼登陸。 主要實現方式為使用自定義filter、 AuthenticationProvider、 AbstractAuthenticationToken 根據不同登陸方式分別處理。 本文相應代碼在Github上已更新。
GitHub 地址:https://github.com/fp2952/spring-cloud-base/tree/master/auth-center/auth-center-provider
srping security 登陸流程
關於二維碼登陸
二維碼掃碼登陸前提是已在微信端登陸,流程如下:
- 用戶點擊二維碼登陸,調用后台接口生成二維碼(帶參數key), 返回二維碼鏈接、key到頁面
- 頁面顯示二維碼,提示掃碼,並通過此key建立websocket
- 用戶掃碼,獲取參數key,點擊登陸調用后台並傳遞key
- 后台根據微信端用戶登陸狀態拿到userdetail, 並在緩存(redis)中維護 key: userDetail 關聯關系
- 后台根據websocket: key通知對於前台頁面登陸
- 頁面用此key登陸
最后一步用戶通過key登陸就是本文的二維碼掃碼登陸部分,實際過程中注意二維碼超時,redis超時等處理
自定義LoginFilter
自定義過濾器,實現AbstractAuthenticationProcessingFilter,在attemptAuthentication方法中根據不同登陸類型獲取對於參數、 並生成自定義的 MyAuthenticationToken。
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
// 登陸類型:user:用戶密碼登陸;phone:手機驗證碼登陸;qr:二維碼掃碼登陸
String type = obtainParameter(request, "type");
String mobile = obtainParameter(request, "mobile");
MyAuthenticationToken authRequest;
String principal;
String credentials;
// 手機驗證碼登陸
if("phone".equals(type)){
principal = obtainParameter(request, "phone");
credentials = obtainParameter(request, "verifyCode");
}
// 二維碼掃碼登陸
else if("qr".equals(type)){
principal = obtainParameter(request, "qrCode");
credentials = null;
}
// 賬號密碼登陸
else {
principal = obtainParameter(request, "username");
credentials = obtainParameter(request, "password");
if(type == null)
type = "user";
}
if (principal == null) {
principal = "";
}
if (credentials == null) {
credentials = "";
}
principal = principal.trim();
authRequest = new MyAuthenticationToken(
principal, credentials, type, mobile);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
private void setDetails(HttpServletRequest request,
AbstractAuthenticationToken authRequest) {
authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
}
private String obtainParameter(HttpServletRequest request, String parameter) {
return request.getParameter(parameter);
}
自定義 AbstractAuthenticationToken
繼承 AbstractAuthenticationToken,添加屬性 type,用於后續判斷。
public class MyAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = 110L;
private final Object principal;
private Object credentials;
private String type;
private String mobile;
/**
* This constructor can be safely used by any code that wishes to create a
* <code>UsernamePasswordAuthenticationToken</code>, as the {@link
* #isAuthenticated()} will return <code>false</code>.
*
*/
public MyAuthenticationToken(Object principal, Object credentials,String type, String mobile) {
super(null);
this.principal = principal;
this.credentials = credentials;
this.type = type;
this.mobile = mobile;
this.setAuthenticated(false);
}
/**
* This constructor should only be used by <code>AuthenticationManager</code> or <code>AuthenticationProvider</code>
* implementations that are satisfied with producing a trusted (i.e. {@link #isAuthenticated()} = <code>true</code>)
* token token.
*
* @param principal
* @param credentials
* @param authorities
*/
public MyAuthenticationToken(Object principal, Object credentials,String type, String mobile, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
this.type = type;
this.mobile = mobile;
super.setAuthenticated(true);
}
@Override
public Object getCredentials() {
return this.credentials;
}
@Override
public Object getPrincipal() {
return this.principal;
}
public String getType() {
return this.type;
}
public String getMobile() {
return this.mobile;
}
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;
}
}
自定義 AuthenticationProvider
實現 AuthenticationProvider
代碼與 AbstractUserDetailsAuthenticationProvider 基本一致,只需修改 authenticate 方法 及 createSuccessAuthentication 方法中的 UsernamePasswordAuthenticationToken 為我們的 token, 改為:
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// 此處修改斷言自定義的 MyAuthenticationToken
Assert.isInstanceOf(MyAuthenticationToken.class, authentication, this.messages.getMessage("MyAbstractUserDetailsAuthenticationProvider.onlySupports", "Only MyAuthenticationToken is supported"));
// ...
}
protected Authentication createSuccessAuthentication(Object principal, Authentication authentication, UserDetails user) {
MyAuthenticationToken result = new MyAuthenticationToken(principal, authentication.getCredentials(),((MyAuthenticationToken) authentication).getType(),((MyAuthenticationToken) authentication).getMobile(), this.authoritiesMapper.mapAuthorities(user.getAuthorities()));
result.setDetails(authentication.getDetails());
return result;
}
繼承provider
繼承我們自定義的AuthenticationProvider,編寫驗證方法additionalAuthenticationChecks及 retrieveUser
/**
* 自定義驗證
* @param userDetails
* @param authentication
* @throws AuthenticationException
*/
protected void additionalAuthenticationChecks(UserDetails userDetails, MyAuthenticationToken authentication) throws AuthenticationException {
Object salt = null;
if(this.saltSource != null) {
salt = this.saltSource.getSalt(userDetails);
}
if(authentication.getCredentials() == null) {
this.logger.debug("Authentication failed: no credentials provided");
throw new BadCredentialsException(this.messages.getMessage("MyAbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
} else {
String presentedPassword = authentication.getCredentials().toString();
// 驗證開始
if("phone".equals(authentication.getType())){
// 手機驗證碼驗證,調用公共服務查詢后台驗證碼緩存: key 為authentication.getPrincipal()的value, 並判斷其與驗證碼是否匹配,
此處寫死為 1000
if(!"1000".equals(presentedPassword)){
this.logger.debug("Authentication failed: verifyCode does not match stored value");
throw new BadCredentialsException(this.messages.getMessage("MyAbstractUserDetailsAuthenticationProvider.badCredentials", "Bad verifyCode"));
}
}else if(MyLoginAuthenticationFilter.SPRING_SECURITY_RESTFUL_TYPE_QR.equals(authentication.getType())){
// 二維碼只需要根據 qrCode 查詢到用戶即可,所以此處無需驗證
}
else {
// 用戶名密碼驗證
if(!this.passwordEncoder.isPasswordValid(userDetails.getPassword(), presentedPassword, salt)) {
this.logger.debug("Authentication failed: password does not match stored value");
throw new BadCredentialsException(this.messages.getMessage("MyAbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
}
}
protected final UserDetails retrieveUser(String username, MyAuthenticationToken authentication) throws AuthenticationException {
UserDetails loadedUser;
try {
// 調用loadUserByUsername時加入type前綴
loadedUser = this.getUserDetailsService().loadUserByUsername(authentication.getType() + ":" + username);
} catch (UsernameNotFoundException var6) {
if(authentication.getCredentials() != null) {
String presentedPassword = authentication.getCredentials().toString();
this.passwordEncoder.isPasswordValid(this.userNotFoundEncodedPassword, presentedPassword, (Object)null);
}
throw var6;
} catch (Exception var7) {
throw new InternalAuthenticationServiceException(var7.getMessage(), var7);
}
if(loadedUser == null) {
throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");
} else {
return loadedUser;
}
}
自定義 UserDetailsService
查詢用戶時根據類型采用不同方式查詢: 賬號密碼根據用戶名查詢用戶; 驗證碼根據 phone查詢用戶, 二維碼可調用公共服務
@Override
public UserDetails loadUserByUsername(String var1) throws UsernameNotFoundException {
BaseUser baseUser;
String[] parameter = var1.split(":");
// 手機驗證碼調用FeignClient根據電話號碼查詢用戶
if("phone".equals(parameter[0])){
ResponseData<BaseUser> baseUserResponseData = baseUserService.getUserByPhone(parameter[1]);
if(baseUserResponseData.getData() == null || !ResponseCode.SUCCESS.getCode().equals(baseUserResponseData.getCode())){
logger.error("找不到該用戶,手機號碼:" + parameter[1]);
throw new UsernameNotFoundException("找不到該用戶,手機號碼:" + parameter[1]);
}
baseUser = baseUserResponseData.getData();
} else if("qr".equals(parameter[0])){
// 掃碼登陸根據key從redis查詢用戶
baseUser = null;
} else {
// 賬號密碼登陸調用FeignClient根據用戶名查詢用戶
ResponseData<BaseUser> baseUserResponseData = baseUserService.getUserByUserName(parameter[1]);
if(baseUserResponseData.getData() == null || !ResponseCode.SUCCESS.getCode().equals(baseUserResponseData.getCode())){
logger.error("找不到該用戶,用戶名:" + parameter[1]);
throw new UsernameNotFoundException("找不到該用戶,用戶名:" + parameter[1]);
}
baseUser = baseUserResponseData.getData();
}
// 調用FeignClient查詢角色
ResponseData<List<BaseRole>> baseRoleListResponseData = baseRoleService.getRoleByUserId(baseUser.getId());
List<BaseRole> roles;
if(baseRoleListResponseData.getData() == null || !ResponseCode.SUCCESS.getCode().equals(baseRoleListResponseData.getCode())){
logger.error("查詢角色失敗!");
roles = new ArrayList<>();
}else {
roles = baseRoleListResponseData.getData();
}
//調用FeignClient查詢菜單
ResponseData<List<BaseModuleResources>> baseModuleResourceListResponseData = baseModuleResourceService.getMenusByUserId(baseUser.getId());
// 獲取用戶權限列表
List<GrantedAuthority> authorities = convertToAuthorities(baseUser, roles);
// 存儲菜單到redis
if( ResponseCode.SUCCESS.getCode().equals(baseModuleResourceListResponseData.getCode()) && baseModuleResourceListResponseData.getData() != null){
resourcesTemplate.delete(baseUser.getId() + "-menu");
baseModuleResourceListResponseData.getData().forEach(e -> {
resourcesTemplate.opsForList().leftPush(baseUser.getId() + "-menu", e);
});
}
// 返回帶有用戶權限信息的User
org.springframework.security.core.userdetails.User user = new org.springframework.security.core.userdetails.User(baseUser.getUserName(),
baseUser.getPassword(), isActive(baseUser.getActive()), true, true, true, authorities);
return new BaseUserDetail(baseUser, user);
}
配置WebSecurityConfigurerAdapter
將我們自定義的類配置到spring security 登陸流程中
@Configuration
@Order(ManagementServerProperties.ACCESS_OVERRIDE_ORDER)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
// 自動注入UserDetailsService
@Autowired
private BaseUserDetailService baseUserDetailService;
@Override
public void configure(HttpSecurity http) throws Exception {
http // 自定義過濾器
.addFilterAt(getMyLoginAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
// 配置登陸頁/login並允許訪問
.formLogin().loginPage("/login").permitAll()
// 登出頁
.and().logout().logoutUrl("/logout").logoutSuccessUrl("/backReferer")
// 其余所有請求全部需要鑒權認證
.and().authorizeRequests().anyRequest().authenticated()
// 由於使用的是JWT,我們這里不需要csrf
.and().csrf().disable();
}
/**
* 用戶驗證
* @param auth
*/
@Override
public void configure(AuthenticationManagerBuilder auth) {
auth.authenticationProvider(myAuthenticationProvider());
}
/**
* 自定義密碼驗證
* @return
*/
@Bean
public MyAuthenticationProvider myAuthenticationProvider(){
MyAuthenticationProvider provider = new MyAuthenticationProvider();
// 設置userDetailsService
provider.setUserDetailsService(baseUserDetailService);
// 禁止隱藏用戶未找到異常
provider.setHideUserNotFoundExceptions(false);
// 使用BCrypt進行密碼的hash
provider.setPasswordEncoder(new BCryptPasswordEncoder(6));
return provider;
}
/**
* 自定義登陸過濾器
* @return
*/
@Bean
public MyLoginAuthenticationFilter getMyLoginAuthenticationFilter() {
MyLoginAuthenticationFilter filter = new MyLoginAuthenticationFilter();
try {
filter.setAuthenticationManager(this.authenticationManagerBean());
} catch (Exception e) {
e.printStackTrace();
}
filter.setAuthenticationSuccessHandler(new MyLoginAuthSuccessHandler());
filter.setAuthenticationFailureHandler(new SimpleUrlAuthenticationFailureHandler("/login?error"));
return filter;
}
}