一、前言
前面在《SpringSecurity系列 之 认证过程和原理》一文中,我们已经学习了SpringSecurity的认证过程,实现过程如下图所示:
根据这个认证过程,我们如何实现集成多种第三方登录的方案呢?我们这里提供了一种思路:首先我们提供一个实现了AbstractAuthenticationProcessingFilter抽象类的过滤器,用来代替UsernamePasswordAuthenticationFilter逻辑,然后提供一个AuthenticationProvider实现类代替AbstractUserDetailsAuthenticationProvider或DaoAuthenticationProvider,最后再提供一个UserDetailsService实现类。
二、通用过滤器实现–ThirdAuthenticationFilter
这个ThirdAuthenticationFilter过滤器我们可以仿照UsernamePasswordAuthenticationFilter来实现(也实现了AbstractAuthenticationProcessingFilter抽象类),主要是重新定义了attemptAuthentication()方法,这里需要根据“authType”参数值的类别构建不同的AbstractAuthenticationToken,具体实现如下:
public class ThirdAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
//验证类型,比如Sms,uernamepassword等
private String authTypeParameter = "authType";
//对应用户名或手机号等
private String principalParameter = "principal";
//对应密码或验证码等
private String credentialsParameter = "credentials";
private boolean postOnly = true;
public ThirdAuthenticationFilter() {
super(new AntPathRequestMatcher("/login/doLogin", "POST"));
}
@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());
}
String authType = request.getParameter(authTypeParameter);
if(StringUtils.isEmpty(authType)){
authType = AuthTypeEnum.AUTH_TYPE_DEFAULT.getAuthType();
}
String principal = request.getParameter(principalParameter);
String credentials = request.getParameter(credentialsParameter);
AbstractAuthenticationToken authRequest = null;
switch (authType){
case "sms":
authRequest = new SmsAuthenticationToken(principal, credentials);
((SmsAuthenticationToken)authRequest).setCode((String)request.getSession().getAttribute("code"));
break;
case "github":
authRequest = new GithubAuthenticationToken(principal, credentials);
break;
case "default":
authRequest = new UsernamePasswordAuthenticationToken(principal, credentials);
}
authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
return this.getAuthenticationManager().authenticate(authRequest);
}
}
这里我们通过登录页面传递的"authType"参数来区分登录类型,然后我们这里为了简单把认证名称和验证码分别用参数"principal"和"credentials"来表示,在不同的认证方法里,他们可能对应的含义不一样,比如:在默认的用户名密码登录方式中,"principal"和"credentials"分别对应用户名和密码,而在短信验证码登录中,又分别对应了手机号码和验证码;而在github登录中,又具有自己的特殊性,我们在后面再专门的介绍。
完成了上述实现,我们需要把该过滤器配置到SpringSecurity的过滤器链中,首先我们这里专门定义了一个配置类ThirdAuthenticationSecurityConfig,这里我们专门用来配置集成第三方登录相关的内容(主要实现了AuthenticationProvider实现类的注入,并前面定义的过滤器添加到UsernamePasswordAuthenticationFilter前面),具体实现如下:
@Component
public class ThirdAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
@Autowired
private SmsAuthenticationProvider smsAuthenticationProvider;
@Autowired
private GithubAuthenticationProvider githubAuthenticationProvider;
@Override
public void configure(HttpSecurity http) throws Exception {
ThirdAuthenticationFilter filter = new ThirdAuthenticationFilter();
http.addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class);
AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
filter.setAuthenticationManager(authenticationManager);
//短信验证
http.authenticationProvider(smsAuthenticationProvider);
//github验证
http.authenticationProvider(githubAuthenticationProvider);
super.configure(http);
}
}
定义了ThirdAuthenticationSecurityConfig 配置类,我们还需要在SpringSecurity配置类中应用才能生效,具体实现如下:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/error","/login/**","/login/goLogin","/login/doLogin","/login/code","/login/authorization_code").anonymous()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login/goLogin")
.loginProcessingUrl("/login/doLogin")
.failureUrl("/login/error")
.permitAll()
.successHandler(new QriverAuthenticationSuccessHandler("/index/toIndex"));
//这里我们省略了一些配置 ……
//应用前面定义的配置
http.apply(thirdAuthenticationSecurityConfig);
}
至此,我们定义的通用第三方过滤器就完成了,并且也完成了在SpringSecurity中生效的配置。下面我们就开始分别实现不同类型登录的具体过程。
在ThirdAuthenticationFilter 类的attemptAuthentication()方法中,我们通过authType类型,然后创建对应的Authentication实现来实现不同方式的登录,这里我们主要实现了如下三种方式,我们分别梳理一下。
三、默认的登录过程
默认的登录过程,即根据用户名密码进行登录,需要使用到UsernamePasswordAuthenticationToken,当“authType”参数为"default"时,这里就会创建UsernamePasswordAuthenticationToken对象,然后后续通过ProviderManager的authenticate()方法,最后就会调用AbstractUserDetailsAuthenticationProvider(DaoAuthenticationProvider)的 authenticate()方法,最终又会调用定义的UserDetailsService实现类。这是默认的过程,这里就不再重复其中的逻辑,除了UserDetailsService实现类需要自己定义,其他都是SpringSecurity提供的实现类。
四、短信验证码登录实现
短信验证码登录,是最贴近用户名密码登录的一种方式,所以我们完全可以仿照用户名密码这种方式实现。我们这里先梳理一下短信验证码登录的业务逻辑:首先,登录界面输入手机号码,然后再点击“获取验证码”按钮获取短信验证码,然后输入收到的短信验证码,最后点击“登录”按钮进行登录认证。和用户名密码登录相比,短信验证码登录多了一个获取验证码的过程,其他其实都是一样的,我们下面逐步实现短信验证码登录:
首先,我们要提供一个获取验证码的接口,定义如下:
@RestController
@RequestMapping("/login")
public class SmsValidateCodeController {
//生成验证码的实例对象
@Autowired
private ValidateCodeGenerator smsCodeGenerator;
//调用服务商接口,发送短信验证码的实例对象
@Autowired
private DefaultSmsCodeSender defaultSmsCodeSender;
@RequestMapping("/code")
public String createSmsCode(HttpServletRequest request, HttpServletResponse response) throws ServletRequestBindingException {
ValidateCode smsCode = smsCodeGenerator.generate(new ServletWebRequest(request));
String mobile = (String)request.getParameter("principal");
request.getSession().setAttribute("code",smsCode.getCode());
defaultSmsCodeSender.send(mobile, smsCode.getCode());
System.out.println("验证码:" + smsCode.getCode());
return "验证码发送成功!";
}
}
在上述方法中,我们注入了smsCodeGenerator和defaultSmsCodeSender两个实例对象,分别用来生成验证码和发送短信验证码,这个可以根据项目的实际情况进行定义和实现,这里不再贴出其中的实现。同时在createSmsCode()方法中,还有一点需要注意的就是,我们发出去的短信验证码需要进行保存,方便后续登录时进行验证,这个也可以选择很多方法,比如说会话、数据库、缓存等,我这里为了简单,直接存到了session会话中了。
然后,我们前面定义ThirdAuthenticationFilter过滤器时,根据登录方式不同,需要对应的Authentication对象,这里我们还需要创建短信验证登录需要的Authentication类,这里我们可以仿照UsernamePasswordAuthenticationToken类进行编写,实现如下:
public class SmsAuthenticationToken extends AbstractAuthenticationToken {
//对应手机号码
private final Object principal;
//对应手机验证码
private Object credentials;
//后台存储的短信验证码,用于验证前端传过来的是否正确
private String code;
public SmsAuthenticationToken(String mobile, Object credentials){
super(null);
this.principal = mobile;
this.credentials = credentials;
this.code = code;
setAuthenticated(false);
}
public SmsAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities, Object credentials){
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true);
}
@Override
public Object getCredentials() {
return this.credentials;
}
@Override
public Object getPrincipal() {
return this.principal;
}
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");
}
super.setAuthenticated(false);
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
@Override
public void eraseCredentials() {
super.eraseCredentials();
credentials = null;
}
}
在SmsAuthenticationToken 类中,我们增加了一个code属性,其实该属性不是必须的,我这里是为了方便传递存储在session会话中的验证码而添加的,如果使用缓存或数据库进行存储验证码,该属性就可以省略。
在AuthenticationManager的authenticate()方法中,会根据Authentication类型选择AuthenticationProvider对象,所以我们这里自定义短信验证码需要的AuthenticationProvider对象,实现如下:
@Component
public class SmsAuthenticationProvider implements AuthenticationProvider{
@Autowired
@Qualifier("smsUserDetailsService")
private UserDetailsService userDetailsService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
SmsAuthenticationToken token = (SmsAuthenticationToken) authentication;
String mobile = (String)token.getPrincipal();
//首先,验证验证码是否正确
String code = (String)token.getCredentials();
String sCode = token.getCode();
if(StringUtils.isEmpty(code) || !code.equalsIgnoreCase(sCode)){
throw new BadCredentialsException("手机验证码错误(Bad credentials),请重试!");
}
//然后,查询对应用户
UserDetails user = userDetailsService.loadUserByUsername(mobile);
if (Objects.isNull(user)) {
throw new InternalAuthenticationServiceException("根据手机号:" + mobile + ",无法获取对应的用户信息!");
}
SmsAuthenticationToken authenticationResult = new SmsAuthenticationToken(user.getUsername(), user.getAuthorities(), token.getCredentials());
authenticationResult.setDetails(token.getDetails());
return authenticationResult;
}
@Override
public boolean supports(Class<?> authentication) {
return SmsAuthenticationToken.class.isAssignableFrom(authentication);
}
}
在SmsAuthenticationProvider 中,supports()方法决定了该实例对象仅支持SmsAuthenticationToken对象的验证。同时,根据authenticate()方法传递参数authentication对象(包括了登录信息:手机号和验证码,session存储的验证码),我们这里session存储的验证码,是因为我们采用了会话存储的方式,如果使用数据库,我们这里就可以通过手机号,去数据库或缓存查询对应的验证码,然后和authentication对象传递过来的验证码进行比对,验证成功,说明登录认证成功,否则登录认证失败。登录成功后,我们就可以调用userDetailsService对象的loadUserByUsername()方法获取登录用户的其他相关信息(权限等),具体实现在自定义的SmsUserDetailsService类中实现,具体如下:
@Component("smsUserDetailsService")
public class SmsUserDetailsService implements UserDetailsService {
private Logger logger = LoggerFactory.getLogger(SmsUserDetailsService.class);
@Autowired
private SysUserService sysUserService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//1、查询用户信息
SysUser user = new SysUser();
user.setMobile(username);
SysUser qUser = sysUserService.getOne(new QueryWrapper<>(user),true);
if(qUser == null) {
logger.info("手机号为”" + username + "“的用户不存在!!!");
throw new UsernameNotFoundException("手机号为”" + username + "“的用户不存在!!!");
}
//2、封装用户角色
UserRole userRole = sysUserService.getRoleByUserId(qUser.getId());
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority(String.valueOf(userRole.getRoleId())));
return new LoginUser(qUser.getUsername(), qUser.getPassword(),authorities);
}
}
自此,我们通过实现了Authentication、AuthenticationProvider和UserDetailsService接口或抽象类就完成了短信验证码登录的方式。
五、Github登录
和短信验证码登录认证相比,Github登录又会有自己的特殊性,我们这里先梳理一下基于Github进行登录验证的大致逻辑:首先,点击Github登录认证按钮,然后会跳转到github登录界面,输入github系统的用户名密码,登录成功,就会跳转到我们自己的系统中的首页。和基于用户名密码的登录方式相比,Github登录不需要类似用户名和密码这样的输入(在自己的系统中),同时又需要根据获取到的github用户信息,换取在自己系统对应的用户信息。具体实现步骤如下:
首先,我们需要创建一个Github账户,然后在Settings Developer-settings-OAuth Apps配置我们需要进行登录认证的系统,如下所示:
新建 Oauth Apps,填写如下信息:
完成上述配置后,我们需要使用到Client ID和Client secrets对应的值,同时callback URL对应Github登录成功进行回调的地址,而Homepage URL是登录成功之后跳转的地址。
我们接下来需要先定义回调的接口,实现如下:
@Controller
@RequestMapping("/login")
public class GithubValidateController {
@Autowired
private GithubClientService githubClientService;
@RequestMapping("/authorization_code")
public void authorization_code(HttpServletRequest request, HttpServletResponse response, String code) throws ServletRequestBindingException, IOException {
//github登录验证,并获取access_token
Map<String,String> resp = githubClientService.queryAccessToken(code);
//跳转本系统的登录流程,获取用户信息,实现两个系统用户的对接
String url = "http://localhost:8888/qriver-admin/login/doLogin";
this.sendByPost(response, url,resp.get("access_token"),"github");
//this.sendByPost(response, url,"access_token","github");
}
public void sendByPost(HttpServletResponse response,String url, String principal, String authType) throws IOException {
response.setContentType("text/html");
PrintWriter out = response.getWriter();
out.println("<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01 Transitional//EN\">");
out.println("<HTML>");
out.println(" <HEAD><TITLE>Post 方法</TITLE></HEAD>");
out.println(" <BODY>");
out.println("<form name=\"submitForm\" action=\"" + url + "\" method=\"post\">");
out.println("<input type=\"hidden\" name=\"principal\" value=\"" + principal + "\"/>");
out.println("<input type=\"hidden\" name=\"authType\" value=\"" + authType + "\"/>");
out.println("</from>");
out.println("<script>window.document.submitForm.submit();</script> ");
out.println(" </BODY>");
out.println("</HTML>");
out.flush();
out.close();
}
}
“/login/authorization_code”接口对应了我们在Github中配置的回调函数,即在Github登录验证成功后,就会回调该接口,我们就是就在回调方法中,模拟了用户名密码登录的方式,调用了SpringSecurity登录认证需要的“/login/doLogin”接口。这里,我们通过queryAccessToken()方法根据回调传递的code获取对应的accessToken,然后把accessToken作为登录使用的principal 参数值,之而立不需要传递密码,因为我们经过Github授权,就可以认为完成了登录认证的判断过程了。
其中GithubClientService类,提供了获取accessToken和用户信息的两个方法,具体实现方式如下:
@Service
public class GithubClientService {
//前面在github中配置时产生的
private String clientId = "######";
private String clientSecret = "######";
private String state = "123";
private String redirectUri = "http://localhost:8888/qriver-admin/login/authorization_code";
@Autowired
private RestTemplate restTemplate;
@Nullable
private WebApplicationContext webApplicationContext;
//获取accessToken
public Map<String, String> queryAccessToken(String code ){
Map<String, String> map = new HashMap<>();
map.put("client_id", clientId);
map.put("client_secret", clientSecret);
map.put("state", state);
map.put("code", code);
map.put("redirect_uri", redirectUri);
Map<String,String> resp = restTemplate.postForObject("https://github.com/login/oauth/access_token", map, Map.class);
return resp;
}
//获取用户信息
public Map<String, Object> queryUser(String accessToken){
HttpHeaders httpheaders = new HttpHeaders();
httpheaders.add("Authorization", "token " + accessToken);
HttpEntity<?> httpEntity = new HttpEntity<>(httpheaders);
ResponseEntity<Map> exchange = restTemplate.exchange("https://api.github.com/user", HttpMethod.GET, httpEntity, Map.class);
System.out.println("exchange.getBody() = " + exchange.getBody());
return exchange == null ? null : exchange.getBody();
}
}
其实,完成了上述的配置和方式后,后续的方式就和短信验证码的逻辑一样了,这里我们简要的再梳理一下。
首先,我们也需要定义一个基于Github登录需要的Authentication实现类,具体实现和前面的SmsAuthenticationToken类似,这里不再重复贴代码了。
然后,我们再定义一个AuthenticationProvider实现类GithubAuthenticationProvider,具体实现如下:
@Component
public class GithubAuthenticationProvider implements AuthenticationProvider{
@Autowired
@Qualifier("githubUserDetailsService")
private UserDetailsService userDetailsService;
@Autowired
private GithubClientService githubClientService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
GithubAuthenticationToken token = (GithubAuthenticationToken) authentication;
String accessToken = (String)token.getPrincipal();
//根据accessToken 获取github用户信息
Map<String, Object> userInfo = githubClientService.queryUser(accessToken);
//然后,根据github用户,查询对应系统用户信息
UserDetails user = userDetailsService.loadUserByUsername((String)userInfo.get("login"));
if (Objects.isNull(user)) {
throw new InternalAuthenticationServiceException("根据accessToken:" + accessToken + ",无法获取对应的用户信息!");
}
GithubAuthenticationToken authenticationResult = new GithubAuthenticationToken(user.getUsername(), user.getAuthorities(), token.getCredentials());
authenticationResult.setDetails(token.getDetails());
return authenticationResult;
}
@Override
public boolean supports(Class<?> authentication) {
return GithubAuthenticationToken.class.isAssignableFrom(authentication);
}
}
在GithubAuthenticationProvider 类的authenticate()方法中,参数authentication中对应的是Github授权后传递的accessToken值,我们这里需要根据accessToken值换取Github用户信息,这里通过queryUser()方法实现,然后根据github用户名去获取对应的系统用户信息。如果根据github用户名用户获取的系统用户为空,我们可以根据自己的需求,自动生成一个用户或者跳转到注册页面,让用户注册一个页面,这里为了简单,我们直接抛出了一个异常。
关于自定义UserDetailsService实现类,主要需要实现根据github用户名查询对应系统用户的功能,这里不再贴出代码。
自此,基于Github实现的登录验证也完成了其中核心的业务逻辑。其实,基于Github的登录,我们也可以通过独立的过滤器进行处理,我们这里主要是为了统一集成第三方登录方式,所以采取了前面的方式,同时基于Github登录方式,在SpringSecurity Oauth中也封装了相关配置,直接使用会更加简单,后续我们将学习这种简单的方式,敬请期待!!!