前言:这是本人在学习 Spring Security技术栈开发企业级认证与授权 第五章 使用SpringSocial开发第三方登陆 课程时做的笔记,供复习之用,不会很全.
目录
第一章 OAuth协议简介
1.1 OAuth协议要解决的问题是什么
我们开发了一个慕课微信助手,可以美化微信用户的自拍数据,这是一个对双方都有好处的事情,用户照片能美化,可能就更喜欢用微信了,对于慕课微信助手的话就相当于微信的用户都是我的潜在用户就不用一个个去拓展用户了.
这里最大的问题是微信不会让我们随意去读取用户的信息,所以我们需要用户的授权.
传统的做法是用户给我们他的用户名和密码,这样会带来很多问题
问题1: 应用可以访问用户在微信上的所有数据
问题2:用户只有修改密码,才能收回授权
问题3:密码泄露的可能性大大提高
OAuth协议就是为了解决上述问题,主要思路是用户不再交给我们他的用户名和密码,而是交给我们一个令牌(Token),我们再去访问微信上用户的自拍数据时,不是用账户密码.上面的问题就都不会存在.
令牌上写着你只能访问用户的自拍数据不能访问别的数据.
令牌会有有效期,比如一个月.
因为我们根本每交出密码,所有也就不存在密码泄露的问题了.
1.2 OAuth协议中的各种角色
服务提供商(Provider):谁提供令牌谁就是服务提供商,如微信.
服务提供商中还有两个角色:
认证服务器(Authorization Server):认证用户的身份,产生令牌
资源服务器(Resource Server):保存用户的资源,验证令牌,最终我们第三方应用发请求的时候是发往资源服务器的,会带着认证服务器发出去的Token,由资源服务器去验证Token,如果验证通过了就会把资源给他.虽然在逻辑上认证与资源服务器是两个角色,但是在物理上它们可以是一台机器,一个应用.
资源所有者(ResourceOwner):用户
第三方应用(Client):第三方服务提供商
1.3 OAuth协议运行的流程
第二种统一授权有四种模式:授权码模式,密码模式,客户端模式,简化模式
1.3.1 授权码模式
授权码模式是这四种模式中功能最完整,流程最严密的授权模式.现在互联网上的服务提供商,如微博,微信,QQ,百度,淘宝全都是采用这个授权码模式来完成整个OAuth流程的.
如果第三方客户端需要用户授权会将用户导向认证服务器,用户用意授权的动作会在认证服务器上来完成,如果用户同意授权,那么认证服务器会将用户重新倒回到第三方应用上去,倒回到哪个地址上是第三方应用与认证服务器事先商量好的.同时会携带一个授权码,第三方应用在收到授权码后,它会拿着授权码向认证服务器申请令牌,这一步是在客服端内部完成的,对客户不可见,认证服务器会核对授权码是不是我第三步发回去的,如果是确认无误,则会发放令牌(Token)
授权码模式的特点
1.在第三步会返回授权码将它与其它模式分开.
2.用户授权是在认证服务器上完成的,密码模式和客户端模式,都是在第三方应用上完成的.完成后第三方应用向认证服务器申请令牌的时候带着一些信息说用户授权给我了,请求令牌,但是认证服务器不能确定用户是否是真的授权了,还是是伪造的.
3.从认证服务器返回的不是令牌而是授权码,要求第三方应用必须有服务器.而不是仅仅返回给浏览器凭借脚本去执行就行了.
第二章 Spring Social的基本原理
上面我们也了解到了,OAuth协议实际上是一个授权协议,让用户不将用户名密码交给第三方应用的情况下,让第三方应用有权限访问用户存储在服务提供商上的一些资源.
前面讲SpringSecurity的时候讲过,当你向SecurityContext里面放入一个经过验证的Authentication的时候,你就登陆成功了,如果我们引导用户走完下图中的所有内容,就相当于用户用服务提供商上的基本信息登陆了我们的第三方应用.
Spring Social把整个流程封装到了一个叫SocialAuthenticationFilter的过滤器里面,然后把过滤器嫁到了SpringSecurity的过滤器上,当你访问某个请求时,SocialAuthenticationFilter会把这个请求拦下来,带着你把整个流程走完.
下面将介绍SpringSocial是如何把这样一个流程封装到接口和类里面的.以及类与依赖的调用关系.
1. 我们是与服务提供商打交道的,所以要介绍的第一个接口是ServiceProvider,它就是服务提供商的抽象类,针对每一个具体的服务提供商,它们都要提供一个接口的具体的实现,SpringSocial提供了一个AbstractOAuth2ServiceProvider,它帮我们实现了一些固有的东西,当我们提供QQ的服务提供商的实现时,我们只要继承这个抽象类就可以了(我们现在用的都是OAuth2协议,国外的有一些OAuth1协议,我们在这里说到的都是OAuth都是2协议).
在服务提供商六步中,第一步到第五步都是标准的流程,第六步是个性化的流程,第六步每个服务提供商提供的数据信息与数据结构都是不一样的,针对这两块,在ServiceProvider里分别有两个封装
第一个接口是OAuth2Operations,这个接口封装了第一步到第五步这样一个标准的OAuth协议的处理流程,SpringSocial给我们提供了一个实现OAuth2Template.
第二个接口是Api,第二个接口实际上没有一个明确的接口,因为每一个服务提供商对用户的基本信息调用都是有去别的,所以我们要自己写一个接口来获取用户信息.这里SpringSocial也提供了一个抽象类,叫做AbstractOAuth2ApiBinding来帮助我们更快的去实现我们的第六步所需Api接口的实现.
2. 第七步我们是在第三方应用内部完成的,第一个接口叫做Connetion,这个Connetion不是数据库的Connetion,它的作用是封装我们前六步获取到的用户信息,实际代码中用到的实现类是OAuth2Connection.
3. Connection由ConnetionFactory创建,我们实际用到的类是OAuth2ConnetionFactory,这个工厂负责创建我们的Connetion实例,为了创建这个对象一定要走前面的流程,就一定需要我们的ServiceProvider,所以ConnetionFactory中包含ServiceProvider实例.
这里的问题是Connetion的数据结构是固定的,而根据服务提供商的不同我们获取的信息的数据结构不是固定的,如何将不固定的数据结构转换为固定的呢,可以用ApiAdapter的接口的实现来完成.顾名思义就是在Api和Connetion之间做一 个适配,
4. connetion封装的是服务提供商里的用户信息,在业务中我们一般会把业务系统中的用户存在user数据库表中,我们怎么把user表中的用户与connetion中的用户关联到一起呢,即你怎么知道这个QQ是数据库中的张三登陆的呢?这个对应关系是对应在数据库中的UserConnetion表中,这个表由UsersConnectionRepository接口来操纵
SpringSocial的基本概念就是上面那些,都是我们写代码的时候要涉及到的一些概念,就像我们之前写表单登陆成功的逻辑要实现个接口SuccessHandler,与那个类似,上面的也是一些零散的接口,我们去实现它,这样我们才能把我们自己QQ登陆与微信登陆的逻辑架在SpringSocial上,实际上SpringSocial已经把绝大多数第三方登陆的逻辑所需要实现的代码都已经实现了,我们在这里做的只是与我们自己做的相关的一些东西.我们把这个实现了就可以把整个流程跑起来,跑起来后再进入SpringSocial看看它都做了什么.
第三章 实现QQ登陆
写的流程:Api->OAuth2Operations->ServiceProvider->ApiAdaper->ConnectionFactory->Connection->UsersConnectionRepository
3.1 Api
新建包social.qq.api
QQUserInfo:从官网拷贝的返回结果
QQ接口:
实现类:
appid是在QQ互联上申请的应用的id,format是替换字符串中的%s,在构造函数中获取openid
通过openid,appId,accessToken获取用户信息,object.mapper是把json转换成指定类型的对象
实现类介绍:
打开AbstractOAuth2ApiBinding
accessToken是存储前五步获得令牌的,因为每个人获取到的令牌都是全局变量,所以QQImpl不是单例对象.restTemplate是帮我们向服务商发送http请求的.
查找qq登陆api,openid相当于用户在qq中的唯一标识.
openId的请求的返回结果:
通过如下查询就可以访问用户数据了:
如果访问正确会返回的结果示例:
错误的返回示例:
当选择了ACCESS_TOKEN_PARAMETER策略后,根据token去查询时会将Token作为查询参数写在url上.
QQUserInfo:
从官网返回的信息中直接拷贝
3.2 QQServiceProvider
是为了处理一到五步的请求,涉及到了一些信息,如将用户导向服务器的地址.
新建connet包,建立新的类QQServiceProvider继承AbstractOAuth2ServiceProvider<S>,有一个泛型,泛型是刚才的Api QQ.
QQImpl是一个多实例的,所以我们这里需要new,appId需要我们自己处理,到时候需要的时候把AppId传进来就好了.对于QQ来说有唯一的appid.
为了继承抽象类,还要实现一个构造函数,在构造函数中返回OAuth2Template,为了返回OAuth2Template必须返回四个参数.
clientId,clientSecret,authorizeUrl,accessTokenUrl
appId与appSecret是服务提供商针对我们的项目给我们的.
authorizeUrl是第一步将用户导向服务器的url
accessTokenUrl是第四步是申请令牌时需要的url.
3.3 QQAdapter
新建类QQAdapter实现ApiAdapter<S> ,泛型为之前的Api,这里是QQ,实现后需要满足一些方法.
test方法表示是否能连通
setConnetionValues在Api与Connetion之间做一个适配
ProfileUrl是个人主页.fetchUserProfile通过Api拿到标准用户信息,updateStatus在某些网站才有这个概念,发message,等等更新微薄的.这里就不用做了
3.4 ConnectionFactory
新建类QQConnectionProvider需要继承OAth2ConnetionFacory<S>,泛型是API,这里是QQ.
继承后需写构造函数,providerId是供应商的唯一标识,serviceProvider是我们上面写的,第三个参数是我们上面的QQAdapter.
其实serviceProvider就相当于用户信息,QQAdapter就相当于一个转换器.有了信息与对应的信息转换器,我们就能得到标准化的信息.
connection不用我们处理,由我们之前写的代码构建出来.
3.5 UsersConnectionRepository
要把我们的数据保存到我们的数据库中,需要UsersConnectionRepository,这个已经提供给我们了但是需要配置一下.
新建UsersConnectionRepository需要三个参数,dataSource就是数据库的配置,connectionFactoryLocator的作用是查找项目中的connectionFactory(项目中可能有其它的factory如qq微信的),textencrypt是加密解密用的工具.
UsersConnectionRepository是为了操作数据库表,所以我们要新建数据库表,可以加个前缀,但是要repository.setTablePrefix
UserId是用户的ID,providerId是服务提供商的id,第三个是openid,这个为联合主键.
3.5.1 从userId转换为用户信息
之前我们说过UserDetailsService,根据用户名去查找校验,如果查找成功了就封装到UserDetails中,并返回UserDetails,再放到session中表示登陆成功了.
与此机制类似,SpringSocial提供了一个机制叫SocialUserDetailsService.需要满足loadUserByUserId,传进来的是找到的UserId.要做到是找到UserDetails的实现并返回
记得将这个类移动到core项目中,而不是在browser项目中,因为都要用.
3.6 配置
比如AppId与Appsecret都要在此进行配置
新建类QQProperties继承SocialProperties(由spring提供)
新建SocialProperties
将SocialProperties加入Security的配置中
这三个配置项将要用在QQConnectionFactory的构造函数中.
新建QQAutoConfig
因为我这里配的是一个连接工厂,只有在配置里配置了AppId与AppSecret时才希望App生效,如果系统里没有配,希望配置不生效.
当配置文件里imooc.security.social.qq.app-id被配置了,拿底下的配置才会生效.
在配置文件里进行配置
3.7 SocialFilter
在SpringSecurity中将其引入,这个配置的作用就是在当前的过滤器链上,去加一个过滤器,过滤器会拦截特定的请求,然后去引导用户去做社交登陆.我们配置了SpringSocialConfigurer就是为了增加过滤器.
3.8 加一个QQ登陆的入口
所有/auth开头的请求都会被SocialAuthentication拦截,第二段Url是你的ProviderId.
SocialAuthenticationFilter
第四章 处理QQ登陆时遇到的问题
4.1 redirect url is illegal
4.1.1 域名问题
在之前的第三步,返回Client并携带授权码,返回的Client的地址就是就是现在的redirect地址,地址是如何确定的呢,就是我们在QQ互联上注册时要填写一个回调域,回调域只要配置一个域名就可以了.
我们现在的回调域是下图,我们现在的回调域是有问题的,回调域只要填写域名就可以了,比如365.com,我们传过去的uri是下图,我们访问的地址与服务器跳转回来的地址都应该是登陆时填写的地址.即/auth/qq,那我们现在的uri是localhost,实际回调的是365.com,我们现在要做的就是让redirect参数和rederect地址保持一致.
现在我们将365.com映射到了本机地址,但是它访问的是80端口,因为安全的原因,80端口被禁用了,所有访问80端口的都会跳到9090端口,所以在application.properties中做如下配置.
结果就是当访问www.pinzhi365.com,就会访问本机的localhost:9090端口
4.1.2 地址问题
现在我们的地址是/auth/qq,如何改变呢?
我们之前配过SpringSocialConfigure,并将它注了进去
打开SpringSocialConfigure,发现在其中new了SocialAuthenticationFilter,并把过滤器加到了过滤器链上,可以看到在往过滤器链上加之前执行了postProcess方法
所以我们要做的是继承SpringSocialConfigure并实现自己的postProcess方法.
书写ImoocSpringSocialConfigure
这样我们将/auth变成了可配置的.
修改前端
这样我们访问的地址就是365.com/qqLogin/callback.do与回调地址相同了.
4.2 给请求做授权
4.2.1 第三方登陆流程
首先回顾一下大概的流程
过滤器去拦截请求,将信息包装到Authentication实现中,然后传递给AuthenticationManager,Manager根据其管理的实现不同挑一个provider来处理传进来的校验信息,在处理的过程中其会调用我们书写的SocialUserDetailsService接口的实现来获取用户的信息,将用户的信息封装到SocialUserDetails接口的实现中,进行检查和校验,如果都通过了那么会把用户信息放到我们之前封装的Authentication中,然后把Authentication标记成经过认证的.
在SpringSocial提供的第三方登陆中涉及了一些特殊的东西,比如说过滤器在封装Authentication给Manager的时候,用到的一个接口叫做SocialAuthenticationService,此Service将执行整个OAuth的流程,在执行的过程中会调用我们的ConnectionFactory,ConnectionFactory会拿到ServiceProvider,ServiceProvider中有一个OAuth2Operations,它会帮助SpringSocial完成整个流程,完成流程后会拿到服务提供商的信息,服务提供商的信息会封装到Connection中,Connection会被封装到SocialAuthenticationToken,Token会被交由Manager中,然后被交由SocialAuthenticationProvider来处理此Token,provider在处理时会根据传过来的connetion里的服务提供商的信息,使用我们jdbcUsersConnectionRepository去从数据库中查询一个UserId出来,查找到UserId再调用我们的SocialUserDetialsService查出来我们的SocialUserDetails,最后再把SocialUserDetails放在SocialAuthenticationToken中,标记为已经过身份认证,放在SecurityContext里,最终放在session里.
蓝色的都是系统实现的,橘色的是我们自己写的,
4.2.2 授权时出现问题
打开OAuth2AuthenticationService查看源码
判断有没有授权码,如果没有就抛出异常.如果有就拿授权码去换令牌.通过打断点我们发现是换令牌的过程中出现了异常.
返回的错误显示着不能抽取QQ返回的信息,可以看到它是拿OAuthOperations去交换的令牌
打开OAuthOperations实现类,打开postAccessGrant,发现它是拿自己的OAuthTemplate发了一个OAuth请求,请求的数据在回来的时候要转成一个map,用restTemplate表示期望返回的是json格式的数据,然而上面的信息显示返回的格式是text/html.
所以抛出了异常,并返回了null,继续往下走,Token是空,继续返回空.如果它是空,抛出异常,打开失败处理器,发现会重定向到signIn页面上去,又因为SignIn页面没授权,最后出现了问题.
面对这种情况,我们替换掉默认的OAuth2Temeplate,写一个自己的实现,然后在自己的实现里加一个MessageConverter就可以了.
新建类QQOAuth2Template
解决了整个问题还有一个问题
按照OAuth模板的想法,发出去rest请求后,返回的结果应该是json,把json解析成map,传给extractAccessGrant方法.在这个方法中从map中去获取access_token,scope,expireIn等等
然后拿这四个字段去new一个AccessGrant,这个类是对OAuth中Tokend的一个封装.
但是我们这里返回的不是json,是一个字符串,所以我们不能解析成map,应该自己写解析工具
最后一个问题:
userParameterForClientAuthentication默认是没有值false,我们要改成true才行.
把原来的provider中的Auth2Template改成QQOAuth2Template
都完成后即可查询用户的信息
第五章 处理注册逻辑
在上面我们已经查询到了用户信息,但是在交给AuthenticationProvider去做校验的时候出了问题,它将我们导向了一个叫signUp的路径,要解释这个问题,我们来看一下SocialAuthenticationProvider.
它有一个authenticate方法,接收一个Authentication,接收后首先判断是不是SocialAuthenticationToken,这里面有我们的Connetion,我们从Connection中拿到用户的信息,如UserId(在QQ中的id)与providerId,然后去数据库中查找,将找到的UserId返回,但现在我们的数据库是空的,所以会报错.报错将会由SocialAuthenticationFilter(最上层的过滤器)来处理,当异常被捕获后会做一个判断,如果signupUrl不为空,它会认为你设置了一个注册的页面,因为是没有UserId跳过来的,这里认为你该去注册一个新的用户,所以就会跳到注册页的url上,默认的注册页是signup,因为我们没有对signup做授权,所以就会出现问题.
面对这个问题,我们自己写一个注册页,把signupUrl写成我们自己的注册页,再让授权给注册页就可以了.
我们希望达到的效果,如果用户没有配注册页就提醒用户配置一个注册页,
在application.properties中配置
在demo项目中书写注册页,在注册时有两种可能一种可能是这个用户是一个全新的用户,之前也没注册过,一种是用户之前有自己的用户名密码,用户做的是一个绑定.
在demo中书写注册逻辑.
给注册页的url加到SpringSecurity的配置中
最后我们要让我们的过滤器知道,当找不到UserId时我们要跳到我们自己的注册页面上去.
重新启动,第三方登录,发现跳转到了注册页
现在有两个问题
第一接我们希望能将用户的QQ信息显示在登陆页上,这样界面更加友好.
第二是我们点了注册后会跳转到我们的注册方法中进行处理,不管注册还是绑定,我们都能获得一个UserId,我们如何把UserId传回给Social并和我们之前拿到的社交信息一起存数据库中的表中.
为了处理这两个问题,Spring提供了一个工具类ProviderSignInUtils.我们在SocialConfig中对其进行配置,
书写SocialUserInfo
告诉前台哪个第三方用户在做登陆,openId,用户的昵称,用户的头像.
书写Controller层,在BrowserSecurityController中书写.
providerSignUtils从session中去取Connection信息,
Connection信息是在没检测到UserId时被存储到session中的.
第二个问题:将UserId注册并传回.用的还是ProviderSignInUtils
我们将信息传给user后,到数据库中注册到User表,再用providerSignUitls到数据库中注册到Userconnetion表,因为需要从session中取值,所以还需呀传一个request.这里为了方便没写具体的注册逻辑.
最后将其配置到安全配置.
这个路径按理说是demo项目里的路径,只有demo项目知道它要到这里面注册,在后面我们在授权的时候将只有用户知道的路径剥离,让用户自己去写.
实际登陆:
输入QQ凭证,进入注册页,进行注册,userId为xxx,再次输入QQ凭证,可以直接进入系统,打印出的凭证显示userId为xxx.
我们也可能不想跳到注册页面,而是想直接输入凭证后就能登陆,那么我们就需要自己为用户注册.
回到之前的SoicalAuthenticationProvider类中,我们来看一下findUserWithConnection的逻辑.
在第85行去查找用户的Id,如果没找到用户的Id,将会判断connectionSignUp是不是空,如果这个属性的实现不是空的,会调用execute方法来注册user并返回userId,如果返回的userId不为空,就将当前的newUserId,与connection一起插入到数据库中.也就是说根据社交用户的信息默认的去建一个用户,直接把你登陆上.
现在我们要做的就是在jdbcUsersConnectionRepository中