目前大部分公众号很少有直观的去写如何集成多种登录的解决方案,例如:短信登录,微信扫码登录,双用户表登录等,图形验证码相对简单,可以通过多种方案进行解决,本文讲述都为REST请求。去除使用security默认的表单登录方式。
AuthenticationProvider接口主要有两个方法,
package org.springframework.security.authentication;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
//认证处理器
public interface AuthenticationProvider {
//这里主要做相关校验,例如密码校验,短信验证码校验等,最终返回一个认证过的AuthenTicationToken
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
//这里主要负责是否需要该验证器进行校验
boolean supports(Class<?> authentication);
}
在默认调用的/oauth/token接口的源码中,可以查看org.springframework.security.authentication.ProviderManager类中的authenticate方法,其中通过循环所有的AuthenticationProvider 实现类,通过调用supports决定是否要采用该验证器进行校验,否则将进入下一个验证器。
原理:AuthenticationProvider通常按照认证请求链顺序去执行,一个返回非null响应表示程序验证通过, 不再尝试验证其它的provider;如果后续提供的身份验证程序 成功地对请求进行身份认证,则忽略先前的身份验证异常及null响应,并将使用成功的身份验证。如果没有provider提供一个非null响应,或者有一个新的抛出AuthenticationException,那么最后的AuthenticationException将会抛出。
由于Oauth2源码中最终需要获取一个验证过的Oauth2Authentication对象,参考org.springframework.security.oauth2.provider.password.ResourceOwnerPasswordTokenGranter的getOAuth2Authentication方法,默认创建了一个UsernamePasswordAuthenticationToken,所以我们在上述使用supports方法的时候,只能去适配这个Token。
贴上一个我自己写的后台admin-password登录代码以及admin-sms登录代码
package com.example.customoauth.authentication.admin;
import com.alibaba.fastjson.JSON;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;
/**
后端短信认证校验
* @author : Windy
* @version :1.0
* @since : 2020/12/22 15:51
*/
@Component
public class AdminSmsAuthenticationProvider implements AuthenticationProvider {
//由于我有两个用户表,所以针对不同的userDetailservice需要指定
@Autowired
@Qualifier("adminUserDetailsService")
private UserDetailsService adminUserDetailsService;
//认证方法
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
UsernamePasswordAuthenticationToken adminLoginToken = (UsernamePasswordAuthenticationToken) authentication;
System.out.println("===进入AdminSMS登录验证环节====="+ JSON.toJSONString(adminLoginToken));
UserDetails userDetails = adminUserDetailsService.loadUserByUsername(adminLoginToken.getName());
//匹配短信验证码。进行密码校验,最终需要返回一个验证过的Token
if("666666".equals(authentication.getCredentials().toString())){
return new UsernamePasswordAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities());
}
throw new BadCredentialsException("验证码不正确");
}
//判断是否支持当前Token,从而决定是否需要进行认证校验
//由于oauth2默认采用的userNamePasswordToken所以在最简方式中,可以依然采用
@Override
public boolean supports(Class<?> authentication) {
//为了简化代码,默认采用UsernamePasswordAuthenticationToken
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
}
admin-sms短信认证方式:
package com.example.customoauth.authentication.admin;
import com.alibaba.fastjson.JSON;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
/**
后端短信认证校验
* @author : Windy
* @version :1.0
* @since : 2020/12/22 15:51
*/
@Component
public class AdminPwdAuthenticationProvider implements AuthenticationProvider {
@Autowired
@Qualifier("adminUserDetailsService")
private UserDetailsService adminUserDetailsService;
@Autowired
private PasswordEncoder passwordEncoder;
//认证方法
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
UsernamePasswordAuthenticationToken adminLoginToken = (UsernamePasswordAuthenticationToken) authentication;
System.out.println("===进入Admin密码登录验证环节====="+ JSON.toJSONString(adminLoginToken));
UserDetails userDetails = adminUserDetailsService.loadUserByUsername(adminLoginToken.getName());
//matches方法,前面为明文,后续为加密后密文
//匹配密码。进行密码校验
if(passwordEncoder.matches(authentication.getCredentials().toString(),userDetails.getPassword())){
return new UsernamePasswordAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities());
}
throw new BadCredentialsException("用户名密码不正确");
}
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
}
最后,只需要将这两个provide 放入authenticationmanage即可
package com.example.customoauth.config;
import com.example.customoauth.authentication.admin.AdminPwdAuthenticationProvider;
import com.example.customoauth.authentication.admin.AdminSmsAuthenticationProvider;
import com.example.customoauth.services.SecurityUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.BeanIds;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
/**
* @author : Windy
* @version :1.0
* @since : 2020/12/8 16:13
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private static final String[] excludedAuthPages = {
"/login/**",
// "/login*",
};
@Override
@Bean(name = BeanIds.AUTHENTICATION_MANAGER)
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Autowired
private SecurityUserDetailsService securityUserDetailsService;
@Autowired
private AdminSmsAuthenticationProvider adminSmsAuthenticationProvider;
@Autowired
private AdminPwdAuthenticationProvider adminPwdAuthenticationProvider;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//此处注入一个默认的service,也可以不注入,我做测试时使用,根据需求,该service非admin-userservice
auth.userDetailsService(securityUserDetailsService);
//添加两个Provider
auth.authenticationProvider(adminSmsAuthenticationProvider);
auth.authenticationProvider(adminPwdAuthenticationProvider);
}
//安全拦截机制(最重要)
@Override
protected void configure(HttpSecurity http) throws Exception {
http.headers().contentTypeOptions().disable()
.frameOptions().sameOrigin()
.and()
.authorizeRequests()
.antMatchers(excludedAuthPages).permitAll()
.antMatchers(HttpMethod.OPTIONS).permitAll()
.requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll()
.and().authorizeRequests()
.anyRequest().authenticated()
.and().formLogin().disable()
.exceptionHandling()
.and().csrf().disable()
.logout().disable().authorizeRequests();
}
}
接下来,我们看以下登录接口。默认我们可以采用/oauth/token进行获取accessToken,具体可以参考org.springframework.security.oauth2.provider.endpoint.TokenEndpoint 的源码,里面写了整个获取流程,我们自定义也可以做一部分调整。tokenEndpoint为请求入口。
package com.example.customoauth.web;
import lombok.Data;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.security.oauth2.OAuth2ClientProperties;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.client.OAuth2RestTemplate;
import org.springframework.security.oauth2.client.token.grant.password.ResourceOwnerPasswordAccessTokenProvider;
import org.springframework.security.oauth2.client.token.grant.password.ResourceOwnerPasswordResourceDetails;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.endpoint.TokenEndpoint;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import springfox.documentation.annotations.ApiIgnore;
import java.security.Principal;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
/**
* @author : Windy
* @version :1.0
* @since : 2020/12/9 16:44
*/
@RestController
@RequestMapping("/")
public class LoginController {
@Autowired
private OAuth2ClientProperties oauth2ClientProperties;
@Autowired
private TokenEndpoint tokenEndpoint;
@PostMapping("/login/admin")
public OAuth2AccessToken adminLogin(UserRequest request) throws HttpRequestMethodNotSupportedException {
//创建客户端信息,客户端信息可以写死进行处理,因为Oauth2密码模式,客户端双信息必须存在,所以伪装一个
//如果不想这么用,需要重写比较多的代码
User clientUser= new User(oauth2ClientProperties.getClientId(),oauth2ClientProperties.getClientSecret(), new ArrayList<>());
//生成已经认证的client
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(clientUser,null, new ArrayList<>());
Map<String, String> parameters = new HashMap<String, String>();
//封装成一个UserPassword方式的参数体,放入手机号
parameters.put("username", request.getPhone());
//放入验证码
parameters.put("password", request.getVcode());
//授权模式为:密码模式
parameters.put("grant_type", "password");
//调用自带的获取token方法。
OAuth2AccessToken oAuth2AccessToken = tokenEndpoint.postAccessToken(token, parameters).getBody();
return oAuth2AccessToken;
}
//对外接口请求
@Data
public class UserRequest {
private String phone;
private String password;
private String randomid;
private String vcode;
}
}
以上就是一个用最简单的方案,实现多种登录模式的案例,重点就是巧妙的应用AuthenticationProvider,通过封装oauth2认证中需要的参数,实现自定义的认证方式。
后续的文章,会写另外比较复杂的模式,修改tokenGrante,从而达到token分离,不使用UserPasswordAuthenticationToken,采用自定义token进行相关认证操作。
以上解决方案,总体感觉还是有点投机取巧了一点,正确的方式,还是应当自定义拓展Oauth2的验证模式,以下参考文章就是这么做的,采用自定义模式,进行不同的认证校验,感觉更加规范。因为从代码逻辑严整性来看,短信验证码并无密码,不应当采用密码模式来进行相关处理,总觉得有点不规范。自己也是通过伪造信息来达到自己想要的验证效果。
接下来我将持续更新自己总结的Spring security oauth2的干货。拒绝生搬硬套,拒绝能用主义,我将给大家呈现的是实际项目中的实际写法,授权服务是一个很严谨庞大的服务,整个权限体系非常多。例如微信开放平台,目前我司也在成立一个开放平台,现在目前用的仅仅是spring security,这次改造成security oauth2 也需要调整不少东西,所以仔细阅读了大部分的文档,从而弄清整个环节,有合适的文章,我会继续完善更新。谢谢
参考他人文章:
Spring Security如何优雅的增加OAuth2协议授权模式