Old Sunset Days

Node.jsでPassportによるOAuth2認証Googleログイン

日付 タグ node.js カテゴリ node.js

目次

PassportでGoogleのOAuth2認証ログイン

以前、Passportというモジュールを使うことで、ユーザー認証をした結果をセッション情報に保存するフローの記事を書いた。 (==> Node.jsでExpress上でPassportを利用する )

この時はそのサイトで独自に登録したユーザーでログインすることを前提としたサンプルだったのだが、Passportを利用すれば、GoogleアカウントやTwitterアカウントといったソーシャルアカウントを使ってのログインにも対応させることができる。

今回はその一例として、以前のサンプルを拡張して、GoogleアカウントでのOAuth2認証ログインをするサンプルを作ってみたいと思う。

なお、今回作ったサンプルのソースコードは参照用としてGithubの以下に置いてある。 https://github.com/hugodeblog/node-passport-google

Google Cloud Platform(GCP)のサイトで必要なキーの準備

まずはGoogleアカウントユーザーの情報を取得するには、Google Cloud Platform(GCP)上で必要なキー(クライアントIDやクライアントシークレット)を取得する必要がある。

クラウドサービスのGCPへログインして(ユーザー登録自体がまだの場合は、先にユーザー登録が必要) https://console.cloud.google.com/

ここで新規で新しいプロジェクトを作る。今回はNode.jsというプロジェクト名にしてみた。

APIとサービスのダッシュボードに一旦移動。

OAuth同意画面だが、G Suiteユーザーではないのでユーザーは外部。

OAuth同意画面のアプリ登録情報を入れていくが、

後ほどのPassportを利用したOAuth2認証で取得するユーザー情報のスコープではprofileとemailを取得するので、

上記のようにスコープのチェックを入れてある。

OAuth同意画面の設定が終わったら、次に認証情報の設定をしていく。

[認証情報の作成]で[OAuthクライアントID]を選択する。

アプリケーションの種類としては[ウェブアプリケーション]

今回立ち上げるサーバーはローカル環境で3000番ポートでサービスするので、

承認済みのJavaScript生成元

http://localhost:3000

承認済みのリダイレクトURI

http://localhost:3000/users/auth/google/callback

以上入力していくと、設定が完了して

ここで表示されるclientID, clientSecretをメモしておく。
これらの値は後ほどのプログラムで必要になる値だ。

必要なパッケージのインストール

今回もまずはテストディレクトリの作成とパッケージのインストールからスタート。

$ mkdir node-passport-gogole
$ cd node-passport-google
$ npx express-generator@4.x --view=hbs
$ npm install
$ npm install express-session --save
$ npm install session-file-store --save
$ npm install passport --save
$ npm install passport-local --save
$ npm install passport-google-oauth20 --save

Google OAuth2認証のためpassport-google-oauth20を利用している。

package.json を見てみると、本記事執筆時点ではバージョン2.0.0がインストールされた。


package.json

{
  "name": "node-passport-google",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "start": "node ./app.mjs"
  },
  "dependencies": {
    "cookie-parser": "~1.4.4",
    "debug": "~2.6.9",
    "express": "~4.16.1",
    "express-session": "^1.17.1",
    "hbs": "~4.0.4",
    "http-errors": "~1.6.3",
    "morgan": "~1.9.1",
    "passport": "^0.4.1",
    "passport-google-oauth20": "^2.0.0",
    "passport-local": "^1.0.0",
    "session-file-store": "^1.5.0"
  }
}

主なファイル構成とプログラムコード

プロジェクトの主要はファイルは以下である。

├── ./app.mjs
├── ./approotdir.mjs
├── ./appsupport.mjs
├── ./config
│   └── ./config/social.mjs
├── ./package-lock.json
├── ./package.json
├── ./routes
│   ├── ./routes/index.mjs
│   └── ./routes/users.mjs
└── ./views
    ├── ./views/error.hbs
    ├── ./views/index.hbs
    ├── ./views/layout.hbs
    ├── ./views/login-failure.hbs
    ├── ./views/login.hbs
    ├── ./views/signup-failure.hbs
    ├── ./views/signup-success.hbs
    └── ./views/signup.hbs

以前のPassportのサンプルと同等なものについてはあまり詳しく説明せず、GoogleアカウントのOAuth2認証に関係する部分についてコードを見ていきたい。


approotdir.mjs

import * as path from 'path';
import * as url from 'url';

const __filename = url.fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export const approotdir = __dirname;


appsupport.mjs

import { server, port } from './app.mjs';
import * as util from 'util';

/**
 * Normalize a port into a number, string, or false.
 */
export function normalizePort(val) {
    const port = parseInt(val, 10);

    if (isNaN(port)) {
        // named pipe
        return val;
    }

    if (port >= 0) {
        // port number
        return port;
    }

    return false;
}

/**
 * Event listener for HTTP server "error" event.
 */
export function onError(error) {
    if (error.syscall !== 'listen') {
        throw error;
    }

    const bind = typeof port === 'string'
        ? 'Pipe ' + port
        : 'Port ' + port;

    // handle specific listen errors with friendly messages
    switch (error.code) {
        case 'EACCES':
            console.error(`${bind} requires elevated privileges`);
            process.exit(1);
            break;
        case 'EADDRINUSE':
            console.error(`${bind} is already in use`);
            process.exit(1);
            break;
        default:
            throw error;
    }
}

/**
 * Event listener for HTTP server "listening" event.
 */
export function onListening() {
    const addr = server.address();
    const bind = typeof addr === 'string'
        ? 'pipe ' + addr
        : 'port ' + addr.port;
    console.log(`Listening on ${bind}`);
}


export function handle404(req, res, next) {
    const err = new Error('Not Found');
    err.status = 404;
    next(err);
}

export function basicErrorHandler(err, req, res, next) {
    // http://expressjs.com/en/guide/error-handling.html
    if (res.headersSent) {
        return next(err)
    }
    // set locals, only providing error in development
    res.locals.message = err.message;
    res.locals.error = req.app.get('env') === 'development' ? err : {};

    // render the error page
    res.status(err.status || 500);
    res.render('error');
}

async function catchProcessDeath() {
    console.log('shutdown ...');
    await server.close();
    process.exit(0);
}

process.on('uncaughtException', function(err) {
    console.error("Crashed - "+ (err.stack || err));
});

process.on('unhandledRejection', (reason, p) => {
    console.error(`Unhandled Rejection at: ${util.inspect(p)} reason: ${reason}`);
});

process.on('SIGTERM', catchProcessDeath);
process.on('SIGINT', catchProcessDeath);
process.on('SIGHUP', catchProcessDeath);

process.on('exit', () => { console.log('exiting...'); })


app.mjs

import { default as express } from 'express';
import { default as hbs } from'hbs';
import * as path from'path';
import { default as logger } from'morgan';
import { default as cookieParser } from'cookie-parser';
import { default as bodyParser } from'body-parser';
import * as http from 'http';
import { approotdir } from './approotdir.mjs';
const __dirname = approotdir;
import {
    normalizePort, onError, onListening, handle404, basicErrorHandler
} from './appsupport.mjs';

import { router as indexRouter } from './routes/index.mjs';
import { router as usersRouter } from './routes/users.mjs';

export const app = express();

// Viewセットアップ
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'hbs');
hbs.registerPartials(path.join(__dirname, 'partials'));

app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

// セッション設定
import session from 'express-session';
import sessionFileStore from 'session-file-store';
import { initPassport } from './routes/users.mjs';

const FileStore = sessionFileStore(session);
export const sessionCookieName = 'mycookie.sid';
const sessionSecret = 'keyboard hogehoge';
const sessionStore  = new FileStore({ path: "sessions" });

app.use(session({
    store: sessionStore,
    secret: sessionSecret,
    resave: false,
    saveUninitialized: false,
    name: sessionCookieName,
    cookie: {maxAge: 60*60*1000*24*7, secure: false}
}));
initPassport(app);

// ルーティング
app.use('/', indexRouter);
app.use('/users', usersRouter);

app.use(handle404);
app.use(basicErrorHandler);

export const port = normalizePort(process.env.PORT || '3000');
app.set('port', port);

export const server = http.createServer(app);

server.listen(port);
server.on('error', onError);
server.on('listening', onListening);

これらはほぼ (==> Node.jsでExpress上でPassportを利用する ) の通りである。


config/social.mjs

export default
{
    "Google": {
        "clientID": "ここにGCP上で設定されたIDが入る",
        "clientSecret": "ここにGCP上で設定されたシークレットが入る"
    }
}


users.mjs

import path from 'path';
import util from 'util';
import { default as express } from 'express';
import { default as passport } from 'passport';
import { default as passportLocal } from 'passport-local';
const LocalStrategy = passportLocal.Strategy;

import passportGoogle from 'passport-google-oauth20';
const GoogleStrategy = passportGoogle.Strategy;

import { default as config } from '../config/social.mjs';

import { sessionCookieName } from '../app.mjs';

export const router = express.Router();

export function initPassport(app) {
  app.use(passport.initialize());
  app.use(passport.session());
}

// テスト用のユーザーを作成
var userList = [
  {id: 0, provider: 'original', plid: 0, username: 'me', password: '&&&me123', accessToken: '', refreshToken:''},
  {id: 1, provider: 'original', plid: 1, username: 'you', password: '***you123', accessToken:'', refreshToken:''},
];

// 該当ユーザーパスワードチェック
// ローカルのサイトで作ったユーザーのみ
function passCheckUser(username, password) {
  for(let person of userList) {
    if(person.username === username && person.password === password)
    return { id: person.id, username: person.username };
  }
  return undefined;
}

// 該当ユーザー名がいるかチェックする
function findLocalUser(username) {
  for(let person of userList) {
    if(person.username === username)
    return { id: person.id, username: person.username };
  }
  return undefined
}

// ローカルで作成したユーザー追加
function addLocalUser(username, password) {

  // 同じユーザー名追加は失敗する
  if(findLocalUser(username)) {
    return undefined;
  }
  else {
    userList.push({
      id: userList.length, // idは連番でつけていく
      provider: 'original', // ローカルで作成したユーザーの場合はoriginal、Googleの場合はgoogle
      plid: userList.length, // platform idはローカルの場合はidと同じ
      username: username, // ローカルで作成したユーザーにはユーザー名(GoogleアカウントならdisplayNameを保存)
      password: password, // ローカルで作成したユーザーにはパスワードあり
      accessToken:'', // ローカルで作成したユーザーはaccessTokenなし
      refreshToken:'' // ローカルで作成したユーザーはrefreshTokenなし
    });
    return findLocalUser(username);
  }
}

// Providerの該当ユーザーがいるかチェックする
function findProviderUser(platform, plid, username, accessToken, refreshToken) {

  console.log('findProviderUser called with following params');
  console.log(platform, plid, username, accessToken, refreshToken);

  for(var i=0; i<userList.length; i++) {
    if(userList[i].provider === platform && userList[i].plid == plid)
    {
      // displayName、accessToken, refreshTokenが更新されているかチェック
      if(userList[i].username !== username)
      userList[i].username = username;

      if(userList[i].accessToken !== accessToken)
      userList[i].accessToken = accessToken;

      if(userList[i].refreshToken !== refreshToken)
      userList[i].refreshToken = refreshToken;

      return { id: userList[i].id, username: userList[i].username };
    }
  }
  return undefined;
}


// ログイン、サインアップ用ストラテジー
passport.use(new LocalStrategy({
  passReqToCallback: true
  },
  async (req, username, password, done) => {
    console.log(req.session);
    console.log(`username = ${username}, passowrd = ${password}`);
    try {
      // ログインの処理
      if(req.session.state === 'login') {
        // そもそも登録済みユーザー?
        if(!findLocalUser(username)) {
          req.session.notice = '登録されていないユーザー名です';
          return done(null, false);
        }
        // パスワードが一致するか?
        const result = passCheckUser(username, password);
        console.log(`result=${result}`);
        if (result != undefined) {
          return done(null, { id: result.id, username: result.username});
        } else {
          req.session.notice = 'パスワードが間違っています';
          return done(null, false);
        }
      }
      // サインアップ処理
      else if(req.session.state === 'signup') {
        // 同じ名前なら失敗
        if(findLocalUser(username)) {
          req.session.notice = '同じユーザー名が存在します';
          return done(null, false);
        }
        else {
          const result = addLocalUser(username, password);
          console.log(`result=${result}`);
          if (result != undefined) {
            return done(null, { id: result.id, username: result.username});
          }
          else {
            req.session.notice = 'ユーザー作成に失敗しました';
            return done(null, false);
          }
        }
      }
      else {
        req.session.notice = '定義されていない処理です';
        return done(null, false);
      }
    } catch (e) { done(e); }
  }
));


// Googleサインアップ用ストラテジー
passport.use(new GoogleStrategy({
    clientID: config.Google.clientID,
    clientSecret: config.Google.clientSecret,
    callbackURL: "/users/auth/google/callback",
    passReqToCallback: true
  },
  (req, accessToken, refreshToken, profile, done) => {
    console.log(`req.query.state=${req.query.state}`);
    try {
      // ログインの場合
      if(req.query.state === 'login')
      {
        console.log(`accessToken = ${accessToken}`);
        console.log(`refreshToken = ${refreshToken}`);
        console.log(`profile = ${util.inspect(profile)}`);
        var result = findProviderUser(
          'google',
          profile.id,
          profile.displayName,
          accessToken,
          refreshToken
        );
        // 既存ユーザー
        if (result != undefined) {
          console.log({ id: result.id, username: result.username});
          return done(null,  { id: result.id, username: result.username});
        }
        else // 未登録ユーザー
        {
          req.session.notice = 'Googleユーザーが見つかりません';
          return done(null, false);
        }
      }
      // サインアップの場合
      else if (req.query.state === 'signup') {
        console.log(`accessToken = ${accessToken}`);
        console.log(`refreshToken = ${refreshToken}`);
        console.log(`profile = ${util.inspect(profile)}`);
        var result = findProviderUser(
          'google',
          profile.id,
          profile.displayName,
          accessToken,
          refreshToken
        );
        // 既存ユーザー
        if (result != undefined) {
          req.session.notice = 'すでに登録済みGoogleユーザーです';
          return done(null, false);
        }
        userList.push({
          id: userList.length,
          provider: 'google',
          plid: profile.id,
          username: profile.displayName,
          password: '',
          accessToken: profile.accessToken,
          refreshToken: profile.refreshToken
        });
        console.log(userList);
        result = findProviderUser(
          'google',
          profile.id,
          profile.displayName,
          accessToken,
          refreshToken
        );
        console.log('新規ユーザー');
        console.log({ id: result.id, username: result.username});
        return done(null,  { id: result.id, username: result.username });
      }
      // ここに来るはおかしい
      else {
        req.session.notice = '想定していない動作です';
        console.log('想定外のフロー');
        return done(null, false);
      }
    } catch(err) { console.log(err); done(err); }
  }
));

passport.serializeUser(function(user, done) {
  console.log(`serialized user=${user}`);
  try {
    done(null, user);
  } catch (e) { done(e); }
});

passport.deserializeUser(async (user, done) => {
  console.log(`deserialized user=${user}`);
  try {
    done(null, user);
  } catch(e) { done(e); }
});


// ログインを試す
router.get('/login/google', function(req, res, next){

  // ログイン前に前のセッションを削除する
  req.session.destroy();
  req.logout();
  res.clearCookie(sessionCookieName);

  passport.authenticate('google', {
    scope: ["profile", "email"],
    accessType: 'offline',
    prompt: 'consent',
    state: 'login'
  })(req, res, next);
});

// サインアップを試す
router.get('/signup/google', function(req, res, next){

  // サインアップ前に前のセッションを削除する
  req.session.destroy();
  req.logout();
  res.clearCookie(sessionCookieName);

  passport.authenticate('google', {
    scope: ["profile", "email"],
    accessType: 'offline',
    prompt: 'consent',
    state: 'signup'
  })(req, res, next);
});

// 成功時のログイン先
router.get('/auth/google/callback',passport.authenticate("google", {
  failureRedirect: '/users/login-failure'}),
  // Passport認証成功時は以下の処理
  function(req,res){
    if(req.query.state === 'login') {
      console.log('ログイン成功');
      res.redirect('/');
    }
    else if(req.query.state === 'signup') {
      console.log('サインアップ成功');
      res.redirect('/users/signup-success');
    }
    else {
      console.log('想定外の動作です');
      res.redirect('/');
    }
});


// 認証ログイン画面
router.get('/login', function(req, res, next) {
  try {
    res.render('login', {title: 'ログイン', user: req.user ? req.user : undefined});
  } catch (e) { next(e); }
});


// ローカルサイトアカウントでのログイン
router.post('/login', function(req, res, next) {
  req.session.state = 'login';
  passport.authenticate('local', {
    successRedirect: '/', // ログイン成功時
    failureRedirect: 'login-failure', // ログイン失敗時
  })(req, res, next);
});

// ローカルサイトアカウントでのサインアップ
router.post('/signup', function(req, res, next) {
  req.session.state = 'signup';
  passport.authenticate('local', {
    successRedirect: 'signup-success', // サインアップ成功時
    failureRedirect: 'signup-failure', // サインアップ失敗時
  })(req, res, next);
});


// ログイン失敗
router.get('/login-failure', function(req, res, next) {
  try {
    res.render('login-failure', {message: req.session.notice ? req.session.notice : undefined});
    req.session.notice = '';
  } catch (e) { next(e); }
});

// サインアップページ
router.get('/signup', function(req, res, next) {
  try {
    res.render('signup', {title: 'サインアップ', user: req.user ? req.user : undefined});
  } catch (e) { next(e); }
});

// サインアップ成功
router.get('/signup-success', function(req, res, next) {
  try {
    res.render('signup-success');
  } catch (e) { next(e); }
});

// サインアップ失敗
router.get('/signup-failure', function(req, res, next) {
  try {
    res.render('signup-failure', {message: req.session.notice ? req.session.notice : undefined});
    req.session.notice = '';
  } catch (e) { next(e); }
});

// 認証キャッシュを削除する
router.get('/logout', function(req, res, next) {
  try {
    req.session.destroy();
    req.logout();
    res.clearCookie(sessionCookieName);
    res.redirect('login');
  } catch (e) { next(e); }
});

以下でGoogle OAuth2関連のモジュール読み込みと、clientID, clientSecretの読み込み。

import passportGoogle from 'passport-google-oauth20';
const GoogleStrategy = passportGoogle.Strategy;
import { default as config } from '../config/social.mjs';

ユーザー情報は今回もテストのためメモリ上の配列に保存することにする。そのため、サーバーを再起動するとユーザー情報は以下の初期値に戻る。

// テスト用のユーザーを作成
var userList = [
  {id: 0, provider: 'original', plid: 0, username: 'me', password: '&&&me123', accessToken: '', refreshToken:''},
  {id: 1, provider: 'original', plid: 1, username: 'you', password: '***you123', accessToken:'', refreshToken:''},
];

provider:'original'となっている上記ユーザーはサイトローカルで新規ユーザー登録した場合であるが、 Googleアカウントを使用して作成されたアカウントだと、


項目 説明
provider Googleアカウントならgoogle
plid Googleでのid (=profile.id)
username displayName (=profile.displaName)
password Googleアカウントなので空文字保存
accessToken OAuth2認証で返ってきたaccessToken
refreshToken OAuth2認証で返ってきたrefreshToken

という情報がuserListに追加される。

Googleアカウントでログイン
/users/login/google

Googleアカウントで新規登録
/users/signup/google

これらのルーティング処理例は以下である。

// ログインを試す
router.get('/login/google', function(req, res, next){

  // ログイン前に前のセッションを削除する
  req.session.destroy();
  req.logout();
  res.clearCookie(sessionCookieName);

  passport.authenticate('google', {
    scope: ["profile", "email"],
    accessType: 'offline',
    prompt: 'consent',
    state: 'login'
  })(req, res, next);
});

新たな別のアカウントでログインしたり、新規ユーザー登録する前に一旦ログアウト時と同じセッション、クッキーのクリアをしている。

その上で、スコープをscope: ["profile", "email"] でGoogleにOAuth2認証を要求する。

項目 説明
scope profileとemailをスコープとして要求、GCP上で設定したのと符号
accessType offline ユーザーがブラウザ上にいなくても発行されているrefreshTokenでアクセストークンを更新できる
prompt consentにより、ユーザーがサインインした後で OAuth 同意ダイアログが表示され、アプリへのアクセス許可の付与をユーザーに求める
state 要求に含まれるアプリによって生成される値で、この値は、結果にも含まれるのでストラテジー認証ロジック内で参照可能

https://developers.google.com/identity/protocols/oauth2/web-server - Using OAuth 2.0 for Web Server Applications

以下がGoogleログイン、サインアップ共通のPassportストラテジーである。

認証情報で使っているコールバックのURLが
http://localhost:3000/users/auth/google/callback
で1つであるため、

Passport認証ストラテジーによるチェックがログイン目的か、サインアップかの違いによって処理分けするためstateを利用している。

// Googleログインサインアップ用ストラテジー
passport.use(new GoogleStrategy({
    clientID: config.Google.clientID,
    clientSecret: config.Google.clientSecret,
    callbackURL: "/users/auth/google/callback",
    passReqToCallback: true
  },
  (req, accessToken, refreshToken, profile, done) => {
    console.log(`req.query.state=${req.query.state}`);
    try {
      // ログインの場合
      if(req.query.state === 'login')
      {
        console.log(`accessToken = ${accessToken}`);
        console.log(`refreshToken = ${refreshToken}`);
        console.log(`profile = ${util.inspect(profile)}`);
        var result = findProviderUser(
          'google',
          profile.id,
          profile.displayName,
          accessToken,
          refreshToken
        );
        // 既存ユーザー
        if (result != undefined) {
          console.log({ id: result.id, username: result.username});
          return done(null,  { id: result.id, username: result.username});
        }
        else // 未登録ユーザー
        {
          req.session.notice = 'Googleユーザーが見つかりません';
          return done(null, false);
        }
      }

上記がGoogleアカウント認証でログインした時のストラテジー。

本サンプルではaccessTokenrefreshToken 自体は特に利用していないが保存しておく。

何かしらの理由で失敗した時には、理由の文言をreq.session.notice に保存しておき、後ほどのビュー処理で参照するようにしている。

トップページ処理のルーティングは以下であるが、これについては以前のサンプルから変わっておらず特筆すべき点はない。


index.mjs

import * as util from 'util';
import { default as express } from 'express';
export const router = express.Router();

// ホームページ
router.get('/', async (req, res, next) => {
  console.log(req.user);
  try {
    // 全メモリストを取得して表示
    res.render('index', {title: 'Passport Test', user: req.user ? req.user : undefined});
  } catch (err) {error(err); next(err);}
});

ビュー処理関係の処理

ビュー処理関係の処理は以下である。


error.hbs

<h1>{{message}}</h1>
<h2>{{error.status}}</h2>
<pre>{{error.stack}}</pre>


layout.hbs

<!DOCTYPE html>
<html>
  <head>
    <title>{{title}}</title>
    <link rel='stylesheet' href='/stylesheets/style.css' />
  </head>
  <body>
    {{{body}}}
  </body>
</html>


index.hbs

<h1>{{title}}</h1>
<p>トップページです</p>
{{#if user}}
<h2>ログインユーザー</h2>
<p>ユーザー認証されています</p>
<ul>
  <li>ユーザーID:{{user.id}}</li>
  <li>ユーザー名:{{user.username}}</li>
</ul>
<a href="/users/logout">ログアウトする</a>
<hr>
<h2>登録ユーザーのみのコンテンツ</h2>
<p>これが見れるのは登録ユーザーのみです</p>
{{else}}
<p>ユーザー認証されていません</p>
<p>コンテンツを閲覧にするにはログインするか、ユーザー登録してください</p>
<a href="/users/login">ログイン</a><br>
<a href="/users/signup">新規登録</a>
{{/if}}

これらについては以前とほぼ同じであるので説明は割愛させていただく。


login.hbs

<h1>{{title}}</h1>
<p>ログインするページです</p>
{{#if user}}
<p>すでにログイン済みです</p>
<ul>
  <li>ユーザーID:{{user.id}}</li>
  <li>ユーザー名:{{user.username}}</li>
</ul>
<a href="/users/logout">ログアウトする</a>
{{else}}
<form action="/users/login" method="post">
    <div>
        <label>ユーザーID:</label>
        <input type="text" name="username" required="required"/>
    </div>
    <div>
        <label>パスワード:</label>
        <input type="password" name="password" required="required"/>
    </div>
    <div>
        <input type="submit" value="ログイン"/>
    </div>
</form>
<p> Social Loginを利用する</p>
<ul>
<li><a href="/users/login/google">Googleでログイン</a><br></li>
<li><a href="#">Twitterでログイン</a><br></li>
<li><a href="#">Facebookでログイン</a><br></li>
</ul>
{{/if}}
<hr>
<a href="/">トップページに戻る</a><br>

GoogleでログインのためのURL/users/login/google 追加がある。
TwitterとFacebookのコードは本サンプルには入っていないので、あくまでもページの構成案でいれているだけの状態。


login-failure.hbs

<h1>ログインに失敗しました</h1>
{{#if message}}
<p>理由:{{ message }}</p>
{{/if}}
<a href="/">トップページに戻る</a><br>
<a href="/users/login">ログインする</a>

ログインのストラテジーで書いたが、何かしらの理由で失敗した場合は、ストラテジー内でreq.session.notice に理由文言が保存され、それがビューのmessage に渡される仕組みである。

// ログイン失敗
router.get('/login-failure', function(req, res, next) {
  try {
    res.render('login-failure', {message: req.session.notice ? req.session.notice : undefined});
    req.session.notice = '';
  } catch (e) { next(e); }
});

例えば/users/login-failure ルーティング処理でパラメータ渡しは上記のようになっている。


signup.hbs

<h1>{{ title }}</h1>
<p>ユーザー登録をするページです</p>
{{#if user}}
<p>すでにログイン済みです</p>
<ul>
  <li>ユーザーID:{{user.id}}</li>
  <li>ユーザー名:{{user.username}}</li>
</ul>
<a href="/">トップページに戻る</a><br>
<a href="/users/logout">ログアウトする</a>
{{else}}
<form action="/users/signup" method="post">
    <div>
        <label>ユーザーID:</label>
        <input type="text" name="username" required="required"/>
    </div>
    <div>
        <label>パスワード:</label>
        <input type="password" name="password" required="required"/>
    </div>
    <div>
        <input type="submit" value="登録"/>
    </div>
</form>
<p> Social Loginを利用する</p>
<ul>
<li><a href="/users/signup/google">Googleアカウントで新規登録</a><br></li>
<li><a href="#">Twitterアカウントで新規登録</a><br></li>
<li><a href="#">Facebookアカウントで新規登録</a><br></li>
</ul>
{{/if}}
<hr>
<a href="/">トップページに戻る</a><br>

新規アカウント作成のページもGoogleアカウントでのサインアップ用URL/users/signup/google へのリンクが追加で入っている。

ここでもTwitter、Facebookは構成案としてのみ入れているだけで実装は入っていない。


signup-failure.hbs

<h1>サインアップに失敗しました</h1>
{{#if message}}
<p>理由:{{ message }}</p>
{{/if}}
<a href="/">トップページに戻る</a><br>
<a href="/users/signup">サインアップする</a>

ログイン同様サインアップ失敗時にもエラー要因のメッセージがセットされている場合はそれを表示するようにしている。

ただし、GoogleアカウントでのPassport認証処理の場合は失敗した時のURL遷移先は/failureRedirect: '/users/login-failure' のみであるであるため、Googleアカウントでの失敗遷移はログインでも新規ユーザー登録でも/users/login-failure になる。


signup-sucess.hbs

<h1>サインアップに成功しました</h1>
<a href="/">トップページへ</a><br>

サインアップ成功時にはサインアップが成功したことを明示してトップページへ誘導。

実際の動作の検証

$ npm run start

> node-passport-google@0.0.0 start
> node ./app.mjs

Listening on port 3000

サーバーを起動させた上で、実際に以下をテストしてみた。

  1. 本ブログサイトのgoogleアカウントで新規アカウントを作成
  2. 新規サインアップが成功してサイアンップ成功が表示
  3. トップページに移動するとアカウントID、アカウント名が表示されることを確認
  4. 一旦ログアウトする
  5. 今しがた登録したgoogleアカウントでログインをしてみる
  6. ログインが成功してトップページに移動

以下が実際の動作状況である。

また、エラーが発生するケースについても動作を確認して想定通りの動作となっている。

  1. 本ブログサイトのgoogleアカウントで新規アカウントを作成
  2. 新規サインアップが成功してサイアンップ成功が表示
  3. 一旦ログアウトする
  4. 再度同じGoogleアカウントで新規アカウント作成をしようとする
  5. 同じアカウントで作成しようとしているのでサインアップ失敗になる

以下が実際の動作状況である。