Node with React: Fullstack Web Development 课程手记(二)——Google OAuth

2,025 阅读9分钟

OAuth

OAuth是一个关于授权(authorization)的开放网络标准,在全世界得到广泛应用,目前的版本是2.0版。常见的采用微信、QQ、微博、Facebook、Google账号登陆网站的过程都是采用了OAuth技术。这一章我们会以使用Google账号登陆第三方网站为例,展示如何使用这项技术。

Google OAuth工作流程

  • 整个OAuth过程主要设计三个方面,客户端(对于网站而言则是浏览器)、第三方服务器(对应网站的服务器)和Google服务器。当用户点击使用Google账号登陆网站时,第三方服务器会直接把这个请求传递给Google服务器,响应后页面跳转至Google的验证授权页面,询问用户是否同意授权。用户同意后,谷歌服务器会跳转至第三方服务器中,并且在跳转URL上会携带一个code参数,第三方服务器拿到code后会凭借这个code再次向Google服务器发送请求,并换取用户信息。拿到用户信息后,第三方服务器会检查数据库,如果没有这个用户则存入数据库,并登陆成功,如果有则直接登陆成功。与此同时,给浏览器种一个标识用户信息的cookie,此后在cookie的有效期内,浏览器接下来每次对第三方服务器的请求中都会携带cookie,因此可以表示用户身份,做一些需要权限才能做的事情。具体流程如下图所示:
  • 我使用passport这个库帮助我们实现验证流程。

    passportJS

  • 两个问题:
    • passportJS会自动化OAuth流程,但需要代码深入到流程细节中,并不能完全自动化整个流程
    • 库的结构,实际上我们需要两个库才能使用passportJS——passport、passport strategy,第一个是核心库,用以提供验证流程的工具方法,第二个是针对不同的授权提供方(Google、Facebook、Wechat etc.)所需要的定制方法,也就是说你如果需要同时提供Google、Facebook、Wechat三种验证方式,那你就需要三个strategy库。在 passportjs.org中提供了很多strategies库。
  • 安装passport到项目中
    npm install --save passport passport-google-oauth20
  • 20的意思是版本为2.0,因为npm的包名称中不能有.,所以就起名为20了,其实这里也可以不加20,那么安装的就是一个1.02.0的组合版。鉴于现在基本知名的auth provider都已经支持OAuth2.0,所以这里采用2.0版本。详情参见passport-google-oauth github
  • 使用passport
const passport  = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;

passport.use(new GoogleStrategy());
  • 在使用Google OAuth之前,需要两个参数appid和api secrect,要获取这两个参数,需要在 console.developers.google.com 上创建项目(只要有Google账号,very easy)
  • 创建完项目,进入项目面板,进入api板块,点击启用Google API,搜索Google +,选择 Google + API, 点击启用
  • 现在此API依然不能使用,需要点击点击创建凭据按钮,按照提示流程一直走到最后,生成凭据,主要包含两个信息clientID和client密钥。如果想要看详细步骤,参考这里

    • 这个流程里需要注意的点是,设置JavaScript授权域和授权回调URL,因为我们现在建立的是一个开发项目,两者分别设为 http://localhost:5000和http://localhost:5000/*
    • clientID: 用于生成登陆用的URL
    • clientSecrect: 用于证明该APP是否有权访问token
  • 接下来要把刚才生成的clientID和clientSectrect,传入Google OAuth模块中。注意,clientSecrect不能公布,谨慎起见clientID也应该保密。所以我们不希望别人通过查看源代码的形式获取这两个值。目前我们先通过不提交这部分代码的形式做到隐藏这部分信息。

  • 创建cong/keys.js,存放clientID和clientSecrect
    module.exports = {
      googleClientId: '1229722414-eeujg12q0q9gvisar.apps.googleusercontent.com',
      googleClientSecret: 'ANPiCt5QFTa'
    };
  • 在.gitignore中写入keys.js,确保包含敏感信息的文件不会被提交
  • 在index.js中,引入keys模块,并将对应的clientKey和clientID传入GoogleStrategy模块中。
    • 注意这里还添加了回调URL。这是因为当用户点击授权后,Google服务器会返回一个code到应用的服务器,那我们服务器应该如何接收并处理这个服务器的返回呢。Google服务器返回给app服务器信息,可以看做是一次请求(服务器不就是用来处理请求的吗),所以我们必须要指定请求的route是什么,因此我们需要一个回调URL的参数,google服务器会将code拼接到这个URL的参数里。
    • 这里还添加了一个回调函数,所有验证的目的就是为了拿到token,以便用户随后的操作,回调函数定义了拿到token做什么。目前仅仅先把token打出来看一下。
      const keys = require('./config/keys');
      passport.use(new GoogleStrategy({
      clientID: keys.googleClientId,
      clientSecret: keys.googleClientSecret,
      callbackURL: '/auth/google/callback'
      }, (accessToken, refreshToken, profile, done) => {console.log(accessToken)}));
  • 最后我们需要添加一个route handler,用以接收用户login的请求,并进入Google OAuth流程,如下面的代码所示。
    • 首先解释一下代码的意思:如果服务器接收到/auth/google的请求,使用passport启用Google OAuth的验证流程,需要获取的信息有用户资料和邮箱。
    • 这里面的字符串'google'看起来很让人费解,因为在之前的代码中我们并没有任何用这个字符串代表Google OAuth strategy的意思。这是passport广为人诟病的一点。事实上,Google OAuth strategy模块中设定了这一点,也就是说这个模块告诉passport如果passport.authenticate方法第一个参数传入了google,那么就采用Google OAuth strategy模块验证。
app.get(
    "/auth/google",
    passport.authenticate("google", {
        scope: ["profile", "email"]
    })
);
  • 现在启动我们的本地服务器,访问localhost:5000/auth/google,按理应该会弹出google认证的页面,但是不幸的是并没有,这时弹出的是一个400页面,大概的意思是说实际提供的验证回调地址和在console.developers.google.com中设定的不一致。还提供了一个链接,直接访问这个链接就进入了修改验证回调URL的页面。
    • 为什么会出现这个错误页?还记得之前我们把已获授权的重定向 URI这一项设为http://localhost:5000/*,事实上这里需要严格匹配。之前在代码中我们设定callbackURL/auth/google/callback,所以我们应该在这个修改页面中将已获授权的重定向 URI这一项设为http://localhost:5000/auth/google/callback,这样之后应该就能正常弹出授权页面了。
    • 为什么需要回调验证URL匹配?我们访问google服务器要求提供授权时,提供的参数是clientID,并且明文传输。攻击者拿到clientID,并把redirect_uri改为恶意网站,那么用户授权后就肯能会跳转到恶意网站,并提供所有的授权信息。显然,这种情况是坚决不能发生的,所以我们需要在google那边配置允许的回调URL,并严格匹配。如果不匹配是不会成功回调的。
  • 点击对应的Google账户登陆,会跳到一个错误页显示Cannot GET /auth/google/callback。我们还没有设置针对回调route的handler,所以当然会报错了。在这个页面的URL中,会看到一个参数code,这就是在之前流程图中提到的Google服务器返回的code。我们app的服务器拿到code后,就可以通过code再次向Google服务器发请求,并拿到用户的资料、邮箱等信息了。所以接下来需要补上对应的route handler。
    app.get('/auth/google/callback', passport.authenticate('google'));
  • 再次访问localhost:5000/auth/google,点击账户登录,可以看到在启动server的控制台中打印出了一坨东西。之前我们在配置passport中传入了一个回调函数,在回调函数中打印出了token。这一坨就是取到的token。
    • 实际上passport在回调URL的handler中自动将code传递给了google服务器,并换取了token、用户信息(资料、邮箱等)。这些信息时通过函数参数的形式传递回来的。 因此,在这之后,那个打印token的函数被调用,我们的app可以在这个回调函数中利用这些信息做一些不可描述的事情。
    • 在继续之前,我们可以先把这些返回的信息打印出来,看看长什么样子。修改代码,重启server,重新访问登陆连接,可以看到控制台中打印出了token(string)、profile(object)、done(function)。
      • accessToken: app后续访问用户信息的凭证。
      • accessToken过一段时间就会过期,refreshToken会允许我们刷新得到最新的token。
      • profile:用户所有的资料。
      • done函数的参数有三个:err(错误信息),user(用户信息),info(其他信息)
    • 为什么会pending?回调函数中,我们并没有给出响应response。
      passport.use(
      new GoogleStrategy(
        {
            clientID: keys.googleClientId,
            clientSecret: keys.googleClientSecret,
            callbackURL: "/auth/google/callback"
        },
        (accessToken, refreshToken, profile, done) => {
            console.log('accessToken', accessToken);
            console.log('refreshToken', refreshToken);
            console.log('profile', profile);
            console.log('done', done);
        }
      )
      );
  • 至此,所有授权的工作(在passport的帮助下)已经完成,接下来是创建用户信息到数据库、登陆完成。

使用nodemon使开发自动化

  • 至此,应该已经厌倦了修改代码,重启server的过程。幸运的是已经有工具使这一切自动化,这个工具就是nodemon
  • npm install --save-dev nodemon
  • 修改package.json
    "scripts": {
      "start": "node index.js",
      "dev": "nodemon index.js"
    },
  • 之后只需要在命令行中输入npm run dev,就可以启动服务器,并且每次修改代码保存后,nodemon都会帮我们自动重启服务器了。

    重构目前的代码

  • 之前我们把所有的逻辑都写在index.js文件中,为了便于维护和迭代,我们把逻辑分散在不同的目录下。目前我们把逻辑分为三个部分config,routes,services。三个部分的含义如下图所示。重构之后的目录如下所示。基本的工作就是把routehandler的逻辑移动到authRoutes.js中,把配置passport的逻辑,移动到passport.js中,然后在两个文件中引入依赖的包或者其他模块。再在index中引入这两个文件。
    ├── config
    │   └── keys.js
    ├── index.js
    ├── package-lock.json
    ├── package.json
    ├── routes
    │   └── authRoutes.js
    └── services
      └── passport.js

  • routes/authRoutes.js
const passport = require('passport');
module.exports =  (app) => {
    app.get(
        "/auth/google",
        passport.authenticate("google", {
            scope: ["profile", "email"]
        })
    );
    app.get("/auth/google/callback", passport.authenticate("google"));
}
  • servics/passport.js
const passport = require('passport');
const GoogleStrategy = require("passport-google-oauth20").Strategy;
const keys = require("../config/keys");

passport.use(
    new GoogleStrategy(
        {
            clientID: keys.googleClientId,
            clientSecret: keys.googleClientSecret,
            callbackURL: "/auth/google/callback"
        },
        (accessToken, refreshToken, profile, done) => {
            console.log('accessToken', accessToken);
            console.log('refreshToken', refreshToken);
            console.log('profile', profile);
            console.log('done', done);
        }
    )
);
  • index.js
    const express = require("express");
    const app = express();
    require('./services/passport');
    require('./routes/authRoutes')(app);
    app.get("/", (req, res) => {
      res.send({ hi: "there" });
    });
    )
    const PORT = process.env.PORT || 5000;
    app.listen(PORT);

next section