一笑·科奉

vue + thinkphp5(API) + OAuth 2.0登录研究与实现

作者: 一笑, 写于: 2017-09-09 02:32:46

刚接触vue+api开发,Client为纯JS+HTML,Server为API模式(一次开发多处调用),但,登录这块有些迷茫。如果使用原用户名+密码+cookie的形式,服务端和客户端必须在同一个域内才有效。多Client同一Server的模式就无法实现。想学QQ/淘宝等使用oauth2.0实现单点登录。这样公司其他系统也有望实现统一登录。每个系统需要分别独立申请授权,且不能互串授权。

以下流程为前端或后端获取登录授权及API资源所需流程图:

image.png

思路有了,就要看OAuth2.0是否可以实现,以及适应以下4种模式的哪一种模式:

  1. Authorization code grant(授权码模式)

  2. Implicit grant(简化/隐形模式)

  3. Client credentials grant(客户端模式)

  4. Resource owner password credentials grant(用户名密码模式)

具体实现步骤:

第一步:架设一个OAuth2 Server(具体步骤:https://www.kefong.com/post/6.html  )

第二步:配置OAuth2 Server以让其能实现登录授权

在百度搜索OAuth2.0,排名第一的就是“阮一峰的理解OAuth 2.0”,从他文章中看出密码模式最像我要的那种模式,但是他有一句“用户向客户端提供自己的用户名和密码”,让我觉得又不完全符合QQ那种单点登录模式。且这篇文章只是带领初步认识OAut2.0,并无具体实现代码。

密码模式(Resource Owner Password Credentials Grant)中,用户向客户端提供自己的用户名和密码。客户端使用这些信息,向"服务商提供商"索要授权。

在这种模式中,用户必须把自己的密码给客户端,但是客户端不得储存密码。这通常用在用户对客户端高度信任的情况下,比如客户端是操作系统的一部分,或者由一个著名公司出品。而认证服务器只有在其他授权模式无法执行的情况下,才能考虑使用这种模式。

功夫不负有心人,国外一篇文章让我茅塞顿开:Which OAuth 2.0 grant should I implement?

If the client is a web application that has runs entirely on the front end (e.g. a single page web application) you should implement the password grant for a first party clients and the implicit grant for a third party clients.

意思是:像Vue.js做的这种单页Web应用,就应该使用“用户名密码模式”,而API这端需要使用“简化模式”(server api端竟然也需要验证,这是之前没想到的,不多说继续研究)

从上可知,方向是对的,就是要使用“密码模式”来实现。

第三步:开启OAuth2 server UserCredentials验证并测试

官方文档说:在之前架设好的OAuth2 server(传送门:https://www.kefong.com/post/6.html)的server.php最下方增加如下代码,这样才可以使用grant_type=password模式:

// create some users in memory
$users = array('kefong' => array('password' => '123456', 'first_name' => '', 'last_name' => ''));
// create a storage object
$storage = new OAuth2\Storage\Memory(array('user_credentials' => $users));
// create the grant type
$grantType = new OAuth2\GrantType\UserCredentials($storage);
$server->addGrantType($grantType);

在cmd中执行curl测试:

curl http://t3ex-oauth.lp/token.php -d "client_id=testclient&client_secret=testpass&grant_type=password&username=kefong&password=123456"

成功拿到access_token。

{"access_token":"cda479ba7497e14b732e344a6a8161de9c75f522","expires_in":3600,"token_type":"Bearer","scope":null,"refresh_token":"b70a52a5f4ecebfa228d5260ad2990c323a427d1"}

但是$users变量为什么是定死的?不应该是读取数据库中的吗?

观察:代码中20行、23行同样是addGrantType,却是直接调用的Pdo,我想到把30行也改为调用pdo(即:注释掉第28行$storage...),然后在oauth_users表增加相应username应该就可以了。

image.png

于是注释掉这一行:

//$storage = new OAuth2\Storage\Memory(array('user_credentials' => $users));

然后在oauth_users表中增加一条数据(username='kefong',password='123456'):

INSERT INTO `oauth_users` VALUES ('kefong', 'e10adc3949ba59abbe56e057f20f883e', 'Brent', 'Shaffer', null, null, null);

注:e10adc3949ba59abbe56e057f20f883e为123456的md5值

在cmd中执行调试:

curl http://t3ex-oauth.lp/token.php -d "client_id=testclient&client_secret=testpass&grant_type=password&username=kefong&password=123456"

呃,报错:无效的用户名和密码:

{"error":"invalid_grant","error_description":"Invalid username and password combination"}

难道是我想当然了?还是我哪里搞错了?oauth_users表不是做此用处?还是说密码不是用md5加密的?

下载了官方的demo,运行后查看其生成的sqlite db中的users.password长度明显不是md5值(长度不一样)。复制demo表中的username/password到自架设的db中。

QQ图片20170917103950.png

示例中说明这一串password,未加密前是:testpass,然后,运行curl测试:

curl http://t3ex-oauth.lp/token.php -d "client_id=testclient&client_secret=testpass&grant_type=password&username=demouser&password=testpass"

果然是password加密方式不对,直接使用demo中的username/password,成功获取到access_token,结果如下:

{"access_token":"518e9d07177b797fdc77895b07f22e4af2db40fd","expires_in":3600,"token_type":"Bearer","scope":null,"refresh_token":"55d34012127d32a3c9e9b84bf649437b384a22ec"}

结论证明我的研究方向是对的(使用pdo作为$storage),那么,密码是如何加密的呢?

查找其实现代码,顺藤摸瓜的找到pdo.php的第310行:sha1($password),原来不是使用md5,而是使用sha1加密方式。

image.png

OK,将md5加密的密码,换成sha1加密后的密码后,再次运行curl:

curl http://t3ex-oauth.lp/token.php -d "client_id=testclient&client_secret=testpass&grant_type=password&username=kefong&password=123456"

成功获取到access_token,撒花撒花~~

image.png

我的Oauth2 Server已可正常使用grant_type=password方式获取到access_token,如何用此实现像新浪/QQ等第三方登录的功能呢?研究方向对吗?是否有必要这样做?

image.png

经过思想斗争,暂不实现以上单点登录功能,后续再研究,先不要跑题。还是继续研究单页面Web如何登录。

再重新整理下实现思路:

client端(Vue.js)提供username/password登录画面,user点击登录后,post方式提交username/password给server(api),server端再向OAuth2 server获取access_token,写入cookie并将access_token返回给client端,client端将access_token与生效日期一并写入cookie,登录成功。

第四步:Vue.js实现登录并调用/获取OAuth2 server验证令牌

首先client端画一个界面

image.png

然后,调用api登录接口(理论上也可以直接调用OAuth2 server验证登录,但我认为有两个缺点:1、暴露了client_id和client_secret;2、需解决跨域问题(点这里看如何解决跨域问题))

我是使用axios post的数据,登录成功后,把access_token保存到cookie中(原本想用localStorage保存的,但是听说localStorage在隐身模式下无法使用),用cookie还有个优点就是可以充分利用access_token的生效时间,让其到时后,自动失效。

具体实现流程图如下:

image.png

其中:

store相关处理代码:

const store = new Vuex.Store({
	state:{
		is_login:false,
		access_token:null,
		refresh_token:null,
		expires_in:null,
		is_refresh_token:false
	},
	getters:{
		isLogin:function(state){
			return state.is_login;
		},
		accessToken:function(state){		
			state.access_token = util.cookie('access_token');
			//console.log('|' + state.access_token);
			return state.access_token;
		},
		isRefreshToken:function(state){
			return state.is_refresh_token;
		}
	},
	mutations:{
		putIsLogin:function(state, is_login){
			state.is_login = is_login;
		},
		putAccessToken:function(state, access_token, expires_in, refresh_token){
			state.access_token = access_token;
		},
		refreshToken:function(state){
			state.access_token = util.cookie('access_token');
			state.refresh_token = util.cookie('refresh_token');
			//console.log('+'+ state.access_token);
			//console.log('refresh_token:'+state.refresh_token);
			if(util.empty(state.access_token) && !util.empty(state.refresh_token)){
				//console.log('+|'+ state.access_token);
				var currentHref = window.location.href;
				state.is_refresh_token = true;
				//post 刷新 access_token
				axios.post(__API__ + 'index/refreshToken',{
					refresh_token:state.refresh_token
				}).then(function(response){
					//console.log(response.data);
		        	util.cookie('access_token',response.data.access_token, response.data.expires_in);
		        	util.cookie('refresh_token',response.data.refresh_token);
		        	state.is_login = true;
		        	state.access_token = response.data.access_token;
		        	state.expires_in = response.data.expires_in;
		        	state.refresh_token = response.data.refresh_token;
		        	state.is_refresh_token = false;
		        	window.location.href = '#/index/blank';
		        	window.location.href = currentHref;
				}).catch(function(error){
					console.log(error);
				});
			}
		}
	}
});

router相关代码:

router.beforeEach((to, from, next) => {
	//console.log('router.beforeEach');
	//每次跳转都验证下是否需要更新access_token
	router.app.$store.commit('refreshToken');
	//如果当前页面需要登录,且,access_token值是空的,则跳转到登录页面
	if(to.matched.some(record => record.meta.requiresAuth) === true && util.empty(router.app.$store.getters.accessToken))
	{
		if(router.app.$store.getters.isRefreshToken == false){
			next('/index/login/')
		}
	}
	else
	{		
		next();
	}
});

App.vue相关代码:

export default {
	name: 'app',
	data () {
		return {}
	},
	computed:{
	},
	created:function(){		
		//如果需要验证、且 未登录		
		this.checkLogin()
	},
	methods:
	{
		checkLogin:function()
		{
			//每次跳转都验证下是否需要更新access_token
			this.$store.commit('refreshToken');
			
			if(util.empty(this.$store.getters.accessToken))
			{
				if(this.$router.history.current.matched.some(record => record.meta.requiresAuth)){
					this.$router.push({path: '/index/login/'});
				}
				this.$store.commit('putIsLogin',false);//设置为未登录状态
			}else{				
				this.$store.commit('putIsLogin',true);//设置为已登录
			}
		}
	}
}

login.vue相关代码

export default {
  name: 'login',
  data () {
    return {
    	form:{
    		username:'kefong',
    		password:'123456'
    	}
    }
  },
  methods:{
	  submit:function(event){
		  var that = this;

		  //console.log(this.page);
		  //防止重复提交
		  event.target.submit.disabled = true;
		  axios({
			  method: 'post',
              data: this.form,
              url: __API__ + 'index/login'
          }).then(function(response) {
        	  //console.log(response);
        	  if(response.data.access_token){
        		  //记录access_token(因为有失效日期,所以和刷新token分开记)
        		  util.cookie('access_token',response.data.access_token, response.data.expires_in);
        		  util.cookie('refresh_token',response.data.refresh_token);
        		  that.$store.commit('putIsLogin',true);
        		  that.$store.commit('putAccessToken', response.data.access_token, response.data.expires_in, response.data.refresh_token);
        		  that.$router.push({path: '/'});
        	  }else{
        		  console.log('error');
        	  }
        	  
              event.target.submit.disabled = false;
          }).catch(function(error){
              console.log(error);
          });
	  }
  }
}

在API的_initialize方法中,判断access_token是否有权限的PHP代码:

$resource = $this->checkResource(input('get.access_token'));
$resource = json_decode($resource);
if(!isset($resource->success)){
    $this->error($resource);
}

PHP如何使用POST/GET方法调用oauth2.0 server不多写。因为特别简单,也没有任何的复杂逻辑。而且,API端,我暂时没有记录token相关信息。一个登陆、刷新access_token的完整功能就此搞定。

第五步:Vue.js使用获取到的access_token调用API及API server的实现

现在可以调用API啦,我API这边还需要根据登陆USER做一些权限的判断,突然发现登陆成功后,我连当前USER的user_id都没有保存,现在无论是客户端,还是API端,都不知道当前访问者是哪个USER。。

我想到三个办法:

  1. 方法一:登陆时除了获取access_token外,还应同时验证并获取API本地user_id,并与获取到的access_token绑定

  2. 方法二:登陆时只获取access_token,不再验证本地USER表,而是使用oauth server的resource.php获取到user_id,然后将其保存到cookie中

  3. 方法三:在获取到access_token的同时,让token.php也一并返回user_id,然后将其保存到cookie

思考之后,我选择使用方法二。

因为:方法一如果api端的cookie已失效,且用户只有access_token时,就无法再获取到user_id了;方法三,则把user_id同时暴露给了前端,我觉得不安全,其他都和第二种很相似;方法二,因每次调用API都需要经过resource.php的验证,所以只要access_token有效,就一直可以获取到对应的user_id,甚至可以把resource.php授权先暂存到cookie(1小时),这样无需每次都重复验证和获取user_id。

于是:resource.php修改为:

// include our OAuth2 Server object
require_once __DIR__.'/server.php';

// Handle a request to a resource and authenticate the access token
if (!$server->verifyResourceRequest($request)) {
	$server->getResponse()->send();
	die;
}
$token = $server->getAccessTokenData($request);
echo json_encode(array('success' => true, 'user_id' => $token['user_id'], 'message' => 'You accessed my APIs!'));

到此结束。剩下的就是VUE与API的事情了。

分类: OAuth 2.0, 浏览: 744, 评论: 0
原创文章转载请注明:转自《一笑·科奉》 原文地址:https://www.kefong.com/post/5.html