😀

ElectronでGoogle ログイン認証をしてみる

2022/11/28に公開2

Electronで管理画面とか作ったりしたら、サーバー側の認証とかセキュリティとかきにしなくてええやん。
と思い最近Electronにモチベーションを震わさせています。

とは言えいくらデスクトップアプリケーションといえど、最低限のセキュリティはつけなアカンでしょ。
ということでElectronで

  • Google google+ APIを使ってユーザー情報を取得
    • 該当するユーザーならアプリケーションを起動

上記の事を試しに作って見た時のメモになります。

今回やること

  1. Electronアプリを起動
  2. renderer-processからmain-processに対して認証確認を行う
  3. main-processが認証情報(google+ API plus.people.get)を確認して起動してよいユーザーか否かを判定
    1. 起動してはいけないユーザーの場合はアプリを落とす
    2. 起動していいユーザーの場合はアプリを継続起動する

参考にしたサイト

環境

  • OSX ElCapitan 10.11

事前準備に必要なもの

  • NodeJS v5.1.0 - v7.2.0
    • v5.1.0で最初作っていたのでその間のバージョンなら多分大丈夫
    • NodeJSが入っていな方は OSXでnodebrewインストールメモ
      を参考にしていれてください
  • electron v1.4.8
    • 入れていない人は npm install -g electron と打とう
  • Google API Console から google+ APIリクエストの許可をしておく

Google認証の準備をする

下記のような感じで認証情報を追加

  • OAuth クライアントID
  • webアプリケーション
  • リダイレクト先なんだけどもelectronでリクエストを送るときは browserのloadURLで実行するのでどんなURLでも結局戻ってくるので何でもOK(なのでhttp://localhostにしている)

認証情報1

認証情報2

上記設定が完了したら認証情報ページから「JSONをダウンロード」ボタンを押して保存しておいてください。
(後で使います)

API Managerから Google+ APIを有効しておく

ライブラリから google+で検索してAPIを有効にしておいてください。

ここからが本題

electronのセットアップから始めます

Electronアプリを起動する

まずはここから

path/to/
mkdir path/to/project
cd path/to/project
# 適当に答えてpackage.jsonを作る
npm init

# electron-json-storageは
# ~/Library/Application Support/<package.json[name]> にjsonファイルを保存してstorageとして扱えるライブラリ
# is-emptyは空のオブジェクトの判定用ライブラリ(地味に空オブジェクトの判定がダルいので)
npm install electron-json-storage googleapis is-empty --save
touch main.js
touch index.html
# google API叩く時に必要
mkdir config
mv <googleの認証情報JSON>.json config/google-secret.json
# 認証情報を間違えてpushとかしないように・・・
echo 'config/google-secret.json' >> .gitignore

main-process

公式のQuickStartをコピー

main.js
const {app, BrowserWindow} = require('electron')

const path = require('path')
const Url = require('url')

let win

function createWindow () {
  win = new BrowserWindow({width: 800, height: 600})

  win.loadURL(Url.format({
    pathname: path.join(__dirname, 'index.html'),
    protocol: 'file:',
    slashes: true
  }))

  // Open the DevTools.
  win.webContents.openDevTools()


  // Emitted when the window is closed.
  win.on('closed', () => {
    // Dereference the window object, usually you would store windows
    // in an array if your app supports multi windows, this is the time
    // when you should delete the corresponding element.
    win = null
  })
}

app.on('ready', createWindow)

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit()
  }
})

app.on('activate', () => {
  if (win === null) {
    createWindow()
  }
})

renderer-process

別に何でもよい

index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Hello World!</title>
  </head>
  <body>
	<h1>hello Electron</h1>
    <script type="text/javascript">
	//@todo ここにgoogle認証の処理を書いていく
	</script>
  </body>
</html>

main-processとrendererプロセスがわからない方は下記を参考

Electron(日本語版Document) クイックスタート

Electronを起動してみる

cd path/to/project
electron .

アプリケーションが立ち上がり hello World!が表示されたらElectronのセットアップは完了

Renderer-ProcessからMain-Processに認証確認を確認する

index.htmlに下記を追記する(Renderer-Process)

index.html
  <body>
	<h1>hello Electron</h1>
    <script type="text/javascript">
	// 追加
	const ipc = require('electron').ipcRenderer
	ipc.send('auth', 'ping')
	ipc.on('auth-reply', function (event, arg) {
		console.log(arg) //pongが帰ってくればOK
	})
	</script>
  </body>

Main-Process側からRenderer-Process受信イベントを追加する

main.js
.
.
.
app.on('activate', () => {
  if (win === null) {
    createWindow()
  }
})

ipc.on('auth', function (event, _) {
  event.sender.send('auth-reply', 'pong')
})

上記の状態で electron . を実行してDevelopperToolsのconsoleにpongと表示されればOKです。

上記でRenderer-Process -> Main-Process -> Renderer-Process のイベントの流れができました。

Main-Processを認証情報を返すように修正してみる

GoogleのOAuth2でトークン情報を取得するところとかあるので少し長め

main.js
const {app, BrowserWindow} = require('electron')
const ipc = require('electron').ipcMain

const path = require('path')
const Url = require('url')

// 追加(ディレクトリ構成はAtomのディレクトリ構成を参考にした)
const Auth = require('./src/main-process/auth')

let win //<= BrowserWindowのオブジェクト
.
. (中略)
.
ipc.on('auth', function (event, _) {
  auth = new Auth(win)
  // googleの認証画面設定だとfile://プロトコルは設定できないのでロジック側で再度リダイレクトさせる必要がある
  auth.setCallbackUrl(path.join(__dirname, 'index.html'))
  // 許可するドメインを指定する
 auth.authorizationDomains.allow = ['hoge.co.jp']
  auth.authenticate(function(error, response){
    // 認証失敗時はアプリを落とす
    if (error) app.quit()
    event.sender.send('auth-reply', {
      user: response,
      error: error
    })
  })
})
src/main-process/auth.js
const empty = require('is-empty')
const storage = require('electron-json-storage')
const OAuth2 = require('googleapis').auth.OAuth2
const plus   = require('googleapis').plus('v1')
const googleSecret = require('../../config/google-secret.json')
const Url          = require('url')
const queryString  = require('querystring')

class Auth {
  constructor (browser) {
    this.oAuth2Client = new OAuth2(
      googleSecret.web.client_id,
      googleSecret.web.client_secret,
      googleSecret.web.redirect_uris[0]
    )
    this.browser = browser
    this.scopes  = [
      'https://www.googleapis.com/auth/plus.login',
      'https://www.googleapis.com/auth/plus.me',
      'https://www.googleapis.com/auth/userinfo.email',
      'https://www.googleapis.com/auth/userinfo.profile'
    ]
    this.authorizationDomains = { allow: [], deny: [] }
  }

  allowAuthorizationDomain(domains = []) {
    this.authorizationDomains.allow = domains
  }

  denyAuthorizationDomain(domains = []) {
    this.authorizationDomains.deny = domains
  }

  setCallbackUrl(url) {
    this.callbackUrl = url
  }

  authenticate (callback) {
    Promise.resolve()
    .then(() => {
      //アプリケーションにトークン情報があるか確認する
      return this.loadTokenFromApplicationStorage()
    })
    .then((tokenAndAuthenticated) => {
      //アプリケーションにトークン情報を保存する
      return this.setAuthenticatedAndCredentials(tokenAndAuthenticated)
    })
    .catch(() => {
      //アプリケーションにトークン情報がない場合はgoogleからトークン情報を取得する
      return this.tryFetchTokenFromGoogle()
    })
    .then(() => {
      //ユーザー情報を取得する
      return this.fetchUserMeFromGoogle()
    })
    .then((response) => {
      return callback(null, response)
    })
    .catch((err) => {
      return callback(err, null)
    })
  }

  setAuthenticatedAndCredentials (tokenAndAuthenticated) {
    return new Promise((resolve) => {
      this.authenticated = tokenAndAuthenticated.authenticated
      this.oAuth2Client.setCredentials({
        access_token: tokenAndAuthenticated.token.access_token,
        refresh_token: tokenAndAuthenticated.token.id_token
      })
      resolve()
    })
  }

  //Google+ API people.getを実行
  fetchUserMeFromGoogle () {
    return new Promise((resolve, reject) => {
      plus.people.get({
        userId: 'me',
        auth: this.oAuth2Client
      }, (err, response) => {
        if (err) {
          if (this.authenticated == false && err.code == 401) return this.retryAuthenticate()
          return reject(err)
        }
        /** 
         * レスポンス
         * {
         *   emails: [ { value: 'xxxxxxx@hoge.co.jp', type: 'account' } ], 
         *    .
         *    .
         *    .
         *   domain: 'hoge.co.jp'
         * }
		 **/
		 // domainsで拒否したいドメインがあった場合エラーにする
        if (this.authorizationDomains.deny.length > 0) {
          for (let i = 0; i < this.authorizationDomains.deny.length; i++) {
            if (response.domain == this.authorizationDomains.deny[i]) return reject(new Error('Authorization Domains deny Error'))
          }
          return resolve({
              email: response.emails[0].value,
              name:  response.displayName,
              thumbnail: response.image.url
          })
        }

		 // domainsで許容したいドメインがあった場合レスポンスを返す
        if (this.authorizationDomains.allow.length > 0) {
          for (let i = 0; i < this.authorizationDomains.allow.length; i++) {
            if (response.domain == this.authorizationDomains.allow[i]) return resolve({
              email: response.emails[0].value,
              name:  response.displayName,
              thumbnail: response.image.url
            })
          }
          return reject(new Error('Authorization Domains allow Error'))
        }

        resolve({
          email: response.emails[0].value,
          name:  response.displayName,
          thumbnail: response.image.url
        })
      })
    })
  }

  # アクセストークンの期限切れの場合に呼び出される
  retryAuthenticate () {
    Promise.resolve()
    .then(() => {
      return this.tryFetchTokenFromGoogle()
    })
    .then((tokenAndAuthenticated) => {
      return this.setAuthenticatedAndCredentials(tokenAndAuthenticated)
    })
  }

  // GoogleのOAuth2を実行してtoken情報を取得する
  tryFetchTokenFromGoogle () {
    return new Promise((resolve, reject) => {
      Promise.resolve()
      .then(() => {
        return this.requestOAuthCode()
      })
      .then((code) => {
        return this.fetchTokenFromGoogle(code)
      })
      .then((tokens) => {
        storage.set('tokens', tokens, function(error) {
          return resolve({ token: tokens, authenticated: true })
        })
      })
      .catch((err) => {
        reject(err)
      })

    })
  }

  // Googleのtoken情報を取得する
  fetchTokenFromGoogle (code) {
    return new Promise((resolve, reject) => {
      this.oAuth2Client.getToken(code, (err, tokens) => {
        if (err) return reject(err)
        return resolve(tokens)
      })
    })
  }

  // Googleのtoken情報を取得するためのcodeを取得する
  requestOAuthCode (reject, resolve) {
    return new Promise((resolve, reject) => {
      let url = this.oAuth2Client.generateAuthUrl({ access_type: 'offline', scope: this.scopes})
	   // chrominumからHTTPアクセスをしてGoogle認証確認画面を表示させる
      this.browser.loadURL(url)
      // Google API 認証情報で設定したリダイレクト先が表示されたときのイベント
      this.browser.webContents.on('did-get-redirect-request', (event, oldUrl, newUrl) => {
        let query = queryString.parse(Url.parse(newUrl).query)
        if (query.error || !query.code) return reject(query.error, null)
        this.browser.loadURL(Url.format({
            pathname: this.callbackUrl,
            protocol: 'file:',
            slashes: true
        }))
        return resolve(query.code)
      })
    })
  }
  // ~/Library/Application Support/<package.json[name]>/tokens.jsonというファイルが存在すればそのtoken情報を取得する
  loadTokenFromApplicationStorage () {
    return new Promise((resolve, reject) => {
      // path ~/Library/Application Support/<package.json[name]>
      storage.get('tokens', function(error, tokens) {
        if (empty(tokens)) return reject()
        return resolve({token: tokens, authenticated: false})
      })
    })
  }
}

module.exports = Auth

authenticateのメソッドみれば大体したいことはわかるはず

取得したユーザー情報をRenderer-Processに表示させる

取得できてるので後は簡単ですね

index.html
  <body>
	<h1>hello Electron</h1>
    # 追加
    <div id="username"></div>
    # 追加
    <img id="userthumb" src="" />
    <script type="text/javascript">
	const ipc = require('electron').ipcRenderer
	ipc.send('auth', 'ping')
	ipc.on('auth-reply', function (event, arg) {
      # 追加
      document.getElementById('username').innerHTML = arg.user.name
      # 追加
      document.getElementById('userthumb').src = arg.user.thumbnail
	})
	</script>
  </body>

サムネイルとユーザー名が表示されればOKです。

最後に

今回はtoken情報等はローカルアプリケーションのファイル・ディレクトリにしまっていますが、
これをDBとかに格納すればもうちょいセキュリティが強固になります。

Electron自体にハマらず、NodeJSの非同期処理を綺麗に書こうとハマっていたのは内緒です・・・・・
(あ・・・・・・・・言ってる・・・・・・・)

Discussion

melodyclue_routermelodyclue_router

サーバー側の理解が不十分で理解できない部分があるのですが、
「Electronで管理画面とか作ったりしたら、サーバー側の認証とかセキュリティとかきにしなくてええやん。」の理由を教えていただきたいです。

色々調べてみたところ、自分の理解としては「Electronは認証に関係するトークンなどをセキュアに保存できるから」というところなんですが、合ってますでしょうか?

意味不明なこと言ってたらすみません...

nabepynabepy

ElectronでOAuth2を調べたらこちらに辿りつきました。

Electronで管理画面を作ったら、サーバー側の認証とかセキュリティとかきにしなくてええやん

この方がどこ視点の方かはわかりませんが、仮にサーバ開発者であれば、OAuth2をサーバに実装させクライアントに認可情報を与えるようにすれば、セッションとか気にしなくていいよねと言いたいと感じたのですが、まずそれはElectronではなくOAuth2のことであり、OAuth2を実装しても認証やセキュリティを考慮しなくてよいということにはならないし、ましてElectronはサンドボックスを持っているとはいえ高々クライアントアプリに過ぎないので、それを踏まえて「サーバー側の認証とかセキュリティを気にしない」実装をElectronで実現できるという根拠は示したほうがいいですね。記事を見る感じGoogleAPIを利用しているのでサーバ開発者でもなさそうな気はします。

それより私が疑問に思ったのは、当時サービス終了したGoogle+をなぜ敢えて使っているかということですね。