Spring Cloud OAuth2(二) 擴充套件登陸方式:賬戶密碼登陸、 手機驗證
概要
基於上文講解的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;
}
}