1. OAuth协议在日常生活中的重要性
有一款美化照片的应用——慕课微信助手,它会从微信中获取用户的自拍数据并自动美化,但是微信无法允许未知应用从自身获取用户的一些隐私数据。所以,如果该应用想要获取某个用户的隐私数据,必须获得该用户的授权,传统的授权方式是用户将用户名和密码交由该应用,然后该应用使用用户名和密码登录微信并获取想要的数据。
这种方式有很大的缺陷:
(1)应用可以访问用户在微信上的所有数据。
(2)用户只有修改密码才能收回授权
(3)密码泄露可能性会大大提高
OAuth协议就是处理以上问题的,当用户授权之后,OAuth协议会给该应用提供一个令牌,该令牌中包含了用户的所授予的权限信息、令牌的有效期等等,应用访问微信时可以使用该令牌获取用户的数据,由于令牌中包含了用户所授予的权限信息,所以用户是无法做权限以外的数据,比如查看聊天记录、发送消息等等。
2. OAuth协议的流程
整个流程中主要包含三个角色:
(1)资源所有者:微信用户
(2)第三方应用:微信慕课助手
(3)服务提供商:微信
(1)认证服务器:认证用户身份,并根据用户授权信息生成令牌
(2)资源服务器:验证令牌的正确性并返回资源
对于用户授权动作(第 2 步),spring security提供了四种模式:授权码模式、密码模式、客户端模式、简化模式。
授权码模式是以上四种中功能最完整、流程最严密的授权模式,几乎所有的应用都使用该模式进行授权,该模式的流程如下:
授权码模式和其他模式最大的区别就是授权码模式的同意授权动作是在是由服务提供商的认证服务器中完成的,而其他模式是在第三方应用完成的,所以当使用其他模式进行授权时,服务提供商不知道用户是否真的已经授权。
3. OAuth协议和Spring Security
在前一章我们说过,当向SecurityContext中放入一个已认证的认证信息时,表示你已经认证完成,所以我们可以通过OAuth协议向第三方应用获取用户信息,然后根据这些信息构建已认证的Authentication放入SecurityContext就表示第三方登录成功了。
当然,这些复杂的过程 Spring Security 框架已经帮我们完成了大部分的工作,它将这些流程封装到一个过滤器SecurityAuthenticationFilter
中,并且将该过滤器添加到了请求的过滤器链中。
第三方登录所使用到的核心类
(1)ServiceProvider(服务提供商):用于提供授权(发送授权码和令牌),还充当创建API实例的工厂。
(2)OAuth2Operations: 用于封装第三方与服务提供商的授权流程(第1-5步),通过该接口可以获得令牌。
(3)API:可以根据该接口获取用户在服务提供商中的数据(比如自拍数据)
(4)Connection(连接):获取API
(5)ConnectionFactory(连接工厂):获取Connection
(6)ApiAdapter:Connection中包含了Spring Social定义的标准的一些字段,但是不同的第三方返回的数据结构有可能不同,所以使用该类将第三方返回的数据结构转化成Connection中标准的数据结构。
(7)UsesConnectionRepository:用来存储第三方应用的用户和本地应用的用户之间的对应关系。
5. 实战——开发QQ登录
(1)QQUser:用来封装第三方应用所返回来的用户数据
public class QQUser {
private String ret;
private String msg;
private String openId;
private String is_lost;
private String province;
private String city;
private String year;
private String nickname;
......
}
(2)QQApi :用来获取从第三方返回来的用户数据
public interface QQApi {
public QQUser getinfo();
}
public class QQApiImpl extends AbstractOAuth2ApiBinding implements QQApi {
//获取用户信息的url
private static final String GET_USERINFO_URL = "https://graph.qq.com/user/get_user_info?access_token=%s&oauth_consumer_key=%s&openid=%s";
//获取用户openId的url
private static final String GET_OPENID_URL = "https://graph.qq.com/oauth2.0/me?access_token=%s";
private ObjectMapper objectMapper = new ObjectMapper();
private String clientId;
private String openId;
private String accessToken;
public QQApiImpl(String accessToken,String clientId) {
super(accessToken, TokenStrategy.ACCESS_TOKEN_PARAMETER);
String getOpenIdRes = getRestTemplate().getForObject(String.format(GET_OPENID_URL, accessToken), String.class);
this.openId = StringUtils.substringBetween(getOpenIdRes, "\"openid\":\"","\"}");
this.clientId = clientId;
this.accessToken = accessToken;
}
@Override
public QQUser getinfo() {
String getUserInfoUrl = String.format(GET_USERINFO_URL, accessToken, clientId, openId);
String userInfo = getRestTemplate().getForObject(getUserInfoUrl, String.class);
try {
QQUser qqUser = objectMapper.readValue(userInfo, QQUser.class);
qqUser.setOpenId(openId);
return qqUser;
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}
(3)ApiAdapter:用来将返回来的用户数据转化spring social规定的标准数据结构
public class QQAdapter implements ApiAdapter<QQApi> {
@Override
public boolean test(QQApi qqApi) {
return true;
}
@Override
public void setConnectionValues(QQApi qqApi, ConnectionValues connectionValues) {
QQUser getinfo = qqApi.getinfo();
connectionValues.setDisplayName(getinfo.getNickname());
connectionValues.setImageUrl(getinfo.getFigureurl());
connectionValues.setProfileUrl(null);
connectionValues.setProviderUserId(getinfo.getOpenId());
}
@Override
public UserProfile fetchUserProfile(QQApi qqApi) {
return null;
}
@Override
public void updateStatus(QQApi qqApi, String s) {
}
}
(4)QQConnectionFactory :用来获取connection
public class QQConnectionFactory extends OAuth2ConnectionFactory<QQApi> {
public QQConnectionFactory(String providerId, String clientId, String clientSecret) {
super(providerId, new QQServiceProvider(clientId, clientSecret), new QQAdapter());
}
}
(5)Oauth2Operations:用来完成OAuth流程中前五步的内容
public class QQOauth2Template extends OAuth2Template {
public QQOauth2Template(String clientId, String clientSecret, String authorizeUrl, String accessTokenUrl) {
super(clientId, clientSecret, authorizeUrl, accessTokenUrl);
/**
* 默认值是false, 只有设置为ture时,获取令牌时所携带的参数才会包括cilentId, clientSecret
* */
setUseParametersForClientAuthentication(true);
}
/**
* 在处理返回来的令牌的数据时,spring social会将返回来的json数据转化成map,然后封装令牌。
* 但是实际上qq返回来的是一个字符串,所以我们需要自己处理对返回来的数据进行处理
* */
@Override
protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap<String, String> parameters) {
String result = getRestTemplate().getForObject(accessTokenUrl, String.class);
String[] datas = StringUtils.splitByWholeSeparatorPreserveAllTokens(result, "&");
String accessToken = StringUtils.substringAfterLast(datas[0], "=");
String expireIn = StringUtils.substringAfterLast(datas[1], "=");
String refreshToken = StringUtils.substringAfterLast(datas[2], "=");
return new AccessGrant(accessToken, null, refreshToken, Long.valueOf(expireIn));
}
/**
* 应用根据授权码获取令牌的过程中,但是qq返回来的数据的contextType是 text/html,而restTemplate无法处理这种类型的数据,所以会抛出异常。
* 应该向restTemplate手动添加一个可以处理text/html请求的 MessageConverter
* */
@Override
protected RestTemplate createRestTemplate() {
RestTemplate restTemplate = super.createRestTemplate();
restTemplate.getMessageConverters().add(new StringHttpMessageConverter());
return restTemplate;
}
}
(6)QQServiceProvider :包括Api和Oauth2Operations
public class QQServiceProvider extends AbstractOAuth2ServiceProvider<QQApi> {
private static final String AUTHORIZE_URL = "https://graph.qq.com/oauth2.0/authorize";
private static final String ACCESSTOKEN_URL = "https://graph.qq.com/oauth2.0/token";
private String clientId;
public QQServiceProvider(String clientId, String clientSecret) {
super(new QQOauth2Template(clientId, clientSecret, AUTHORIZE_URL, ACCESSTOKEN_URL));
this.clientId = clientId;
}
@Override
public QQApi getApi(String accessToKen) {
return new QQApiImpl(accessToKen, clientId);
}
}
(7)SocialUserDetailService:从业务系统中查询第三方用户对应的业务用户信息
@Component
public class MySocialUserDeatilService implements SocialUserDetailsService {
private Logger logger = LoggerFactory.getLogger(getClass());
@Override
public SocialUserDetails loadUserByUserId(String providerId) throws UsernameNotFoundException {
logger.info("社交用户登录的id:{}", providerId);
return (SocialUserDetails) new User(providerId, "123456", AuthorityUtils.createAuthorityList("USER"));
}
}
(8)第三方登录的配置
@Configuration
@EnableSocial
public class SocailConfig extends SocialConfigurerAdapter {
private String providerId = "qq";
private String clientId = "yourClientId";
private String clientSercet = "yourClientSercet";
/**
* 将qq连接工厂添加到spring social中
* */
@Override
public void addConnectionFactories(ConnectionFactoryConfigurer connectionFactoryConfigurer, Environment environment) {
connectionFactoryConfigurer.addConnectionFactory(new QQConnectionFactory(providerId, clientId, clientSercet));
}
/**
* 用于持久化第三方用户和业务用户的对应关系的数据库
* */
@Override
public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
return new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
}
@Autowired
private DataSource dataSource;
/**
* 向过滤器链增加一个Social认证的过滤器
* */
@Bean
public SpringSocialConfigurer springSocialConfigurer(){
return new SpringSocialConfigurer();
}
}
(9)将第三方的配置(过滤器)添加到SpringSecurity的配置中
@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter{
@Autowired
private SpringSocialConfigurer springSocialConfigurer;
protected void configure(HttpSecurity http) throws Exception {
http.apply(springSocialConfigurer)
......
}
}
(10)前台页面,SocialAuthenticationFilter
会默认拦截/auth
的请求,所以qq登录的请求地址为/auth/providerId
,providerId
为初始化QQConnectionFactory
时传入的providerId
。
<h2>qq登录</h2>
<a href="/auth/qq"></a>
(11)执行sql语句:需要有第三方和本系统用户的对应关系的表,你需要手动执行该sql语句创建该表,sql语句保存在social核心包的org.spring.social/connect/jdbc/jdbc....sql
文件中。
当登陆完成,但是第三方用户在本业务系统中没有对应的用户时,springSecurity会抛出异常,然后SocialAuthenticationFilter
会捕获到该异常,然后springSecurity将会跳转到注册页,默认为/sighUp
,你可以自己配置注册的 url。
/**
* 向过滤器链增加一个Social认证的过滤器
* */
@Bean
public SpringSocialConfigurer springSocialConfigurer(){
SpringSocialConfigurer springSocialConfigurer = new SpringSocialConfigurer();
//如果第三方应用在本业务系统中没有对应的用户,则会跳转到该页面
springSocialConfigurer.signupUrl("你自己注册的页面地址");
return springSocialConfigurer;
}
/**
* 工具类:用来获取第三方用户信息; 用于绑定业务系统用户和第三方用户,将管理信息持久化到数据库中
* */
@Bean
public ProviderSignInUtils providerSignInUtils(ConnectionFactoryLocator connectionFactoryLocator){
return new ProviderSignInUtils(connectionFactoryLocator, getUsersConnectionRepository(connectionFactoryLocator));
}
@GetMapping("/regist")
pubilc void regist(User user, HttpServletRequest req){
String userid = userDao.insert(user) //向数据库中添加user用户,并返回唯一标示;
providerSignInUtils.doPostSignUp(userid, req);
}
如上述代码,注册时需要一个工具类来操作第三方用户信息,当你注册完成之后,可以调用该工具类的doPostSignUp()
将业务系统用户信息和第三方用户信息的关联关系持久化到数据库中。使用第三方用户登录时,如果没有对应的业务系统用户时,你想在后台默认创建一个对应的本业务系统的用户,你需要以下配置。
@Component
public class QQConnectionSignUp implements ConnectionSignUp {
@Override
public String execute(Connection<?> connection) {
//根据用户的第三方信息创建第三方用户,并且返回用户唯一标示
return connection.getDisplayName();
}
}
@Override
public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
JdbcUsersConnectionRepository usersConnectionRepository = new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
usersConnectionRepository.setConnectionSignUp(connectionSignUp); // here!!!
return usersConnectionRepository;
}
6. 实战——开发第三方绑定和解绑
- 查看绑定信息
spring social给我们提供了这样一个类(ConnectController
),该类给我们提供了很多方便的接口,比如查看第三方用户的绑定信息时,我们可以直接访问/connect
获取该应用的所有第三方信息如下所示:
@RequestMapping(method=RequestMethod.GET)
public String connectionStatus(NativeWebRequest request, Model model) {
setNoCache(request);
processFlash(request, model);
Map<String, List<Connection<?>>> connections = connectionRepository.findAllConnections();
model.addAttribute("providerIds", connectionFactoryLocator.registeredProviderIds());
model.addAttribute("connectionMap", connections);
return connectView();
}
@RequestMapping(value="/{providerId}", method=RequestMethod.GET)
public String connectionStatus(@PathVariable String providerId, NativeWebRequest request, Model model) {
setNoCache(request);
processFlash(request, model);
List<Connection<?>> connections = connectionRepository.findConnections(providerId);
setNoCache(request);
if (connections.isEmpty()) {
return connectView(providerId);
} else {
model.addAttribute("connections", connections);
return connectedView(providerId);
}
}
......
&emsp在查询完成所有的第三方连接信息之后,spring security会调用connectView()
方法跳转到connect/status
的视图,显示该登录用户对应的第三方用户的绑定信息,但是我们并没有提供这样的视图,所以我们需要在容器中定义一个这样的视图。
@Component("connect/status")
public class ConnectView extends AbstractView {
@Autowired
private ObjectMapper objectMapper;
@Override
protected void renderMergedOutputModel(Map<String, Object> map, HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws Exception {
//map中就是获取到的所有的第三方应用的连接信息,需要对这些数据进行处理,处理成自己想要的格式。
httpServletResponse.setContentType("application/json;charSet=utf-8");
PrintWriter writer = httpServletResponse.getWriter();
Map<String, String> mockData = new HashMap<>();
mockData.put("qq", "未绑定");
mockData.put("wechat", "已绑定");
writer.write(objectMapper.writeValueAsString(mockData));
writer.flush();
}
}
- 绑定(解绑)第三方用户
ConnectController
同样给我们提供了绑定(解绑)逻辑,所以我们只需要发出一个POST
(DELETE
)的请求即可,请求的地址为connect/provideId
。
@RequestMapping(value="/{providerId}", method=RequestMethod.POST)
public RedirectView connect(@PathVariable String providerId, NativeWebRequest request) {
ConnectionFactory<?> connectionFactory = connectionFactoryLocator.getConnectionFactory(providerId);
MultiValueMap<String, String> parameters = new LinkedMultiValueMap<String, String>();
preConnect(connectionFactory, parameters, request);
try {
return new RedirectView(connectSupport.buildOAuthUrl(connectionFactory, request, parameters));
} catch (Exception e) {
sessionStrategy.setAttribute(request, PROVIDER_ERROR_ATTRIBUTE, e);
return connectionStatusRedirect(providerId, request);
}
}
1. 绑定表单
<form type="post" action="connect/qq"/>
<input type="submit"/>
</form>
2. 解绑表单
<form type="post" action="connect/qq"/>
<input type="hidden" name="_method" value="DELETE" />
<input type="submit"/>
</form>
在绑定(解绑)完成之后,还是会出现上面的问题:缺少视图,因为绑定(解绑)完成之后,会跳转到 "connect/providerIdConnected"("connect/providerIdConnect")显示绑定的结果,也就是 "connect/qqConnected",所以我们需要提供该视图。
public class ConnectedView extends AbstractView {
@Override
protected void renderMergedOutputModel(Map<String, Object> map, HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws Exception {
if(map.get("connection") != null){
// 绑定成功处理
}else{
// 解绑成功处理
}
}
}
6. Session管理
在这之前,我们已经完成了用户名和密码、手机号和第三方用户登录,这三种认证方式都是基于session进行判断的,因为当请求到来时,过滤器链中的第一个过滤器SecurityContextPersistenceFilter
判断当前是否登录是从session中获取用户的认证信息的,如果session有认证信息,则表示用户已登录。
- session超时处理
在spring boot中,我们可以在配置文件中指定server.servlet.session.timeout
指定session的超时时间。注意spring boot的session的超时时间最少是一分钟,spring boot计算超时时间为 超时时间 = Math.max(你配置的超时时间,一分钟)。
在spring security中对于session超时处理,你可以提供一个url,也可以提供一个超时策略。当session失效时,会自动跳转到该url或执行该超时策略。
@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter{
protected void configure(HttpSecurity http) throws Exception {
http
.sessionManagement()
.invalidSessionUrl("/invalid/url")
.invalidSessionStrategy( 失效策略 )
}
- session并发控制
在项目中,你可能允许同一个账号和密码只能在一处登录。在之后登录的用户将会 "挤掉"前一个用户,你可以配置最大登录数为 1,如果你想为某个 "挤掉" 的用户显示一些信息,你可以指定一个 session 过期策略。
@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter{
protected void configure(HttpSecurity http) throws Exception {
http
.sessionManagement()
.maximumSessions(1)
.expiredSessionStrategy( session过期策略 )
}
如果你想要的效果是当并发登录的用户达到指定的最大值时,阻止之后的用户登录。你可以做以下配置:
@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter{
protected void configure(HttpSecurity http) throws Exception {
http
.sessionManagement()
.maximumSessions(1)
.maxSessionsPreventsLogin(true);
}
- 集群session管理
为保证服务的高可用,我们通常会将应用部署到多台机器上,但是此时根据session保存登录用户信息,将会遇到一个问题。如果我们没有配置一些特定的负载均衡策略(比如保证同一个ip地址的请求发送到一台机器上),将会使用户重复登录。比如登录请求发送到机器 1 中,认证成功后将用户信息保存到机器 1 的session中,但是由于负载均衡策略,用户的下一次请求可能会发送到不同的机器中,由于这些机器没有保存用户信息,所以用户不得不重新进行登录。
我们可以将session独立于服务进行保存,使得不同的机器向同一个地方查询用户信息,如下所示:
spring session提供了多种存储应用session的方式,常见的方式包括 JDBC、REDIS、MONGO等等,但是我们最好不要使用传统的数据库,因为spring security对于每个请求都会从session中尝试获取 securityContext对象,如果使用传统的数据库会限制应用的吞吐量。我们只需要在spring boot的配置文件中指定session的存储类型即可。
spring.session.store-type = redis
spring.redis.host = redisHost
spring.redis.port = redisPort
7. 退出登录
spring security的默认登出地址为/logout
,在访问改地址,spring security会做以下工作:
(1)使当前的session失效。
(2)请求出记住我服务的相关信息。
(3)清除SecurityContext中的认证信息。
(4)重定向到登录页。
如果你想对登出做一些自定义配置,你可以在spring security的配置类中做相关配置。
@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter{
protected void configure(HttpSecurity http) throws Exception {
http
.logout()
.logoutUrl("/logoutUrl")
.logoutSuccessHandler( 自定登出成功处理器 )
.logoutSuccessUrl("自定义登出成功后跳转的页面")
}