#背景
今回は、前回作成したelectronのアプリに認証機能を追加してみる。
https://qiita.com/yusuke-ka/items/a4767c511f03b6083afc
今回も前回に引き続きAuth0のブログを参考にしてみる。
https://auth0.com/blog/securing-electron-applications-with-openid-connect-and-oauth-2/
※(注意)自学習を目的として書いています。この記事に記載の内容は、あくまで自分(素人)の解釈となります。
electronアプリへの認証機能追加
前回作成したシンプルなelectronアプリにログイン機能、ログアウト機能を追加してみる。
ログイン機能の実装
まずは、単純にkeycloakのログイン画面にリダイレクトして、認証するところまでを実装してみる。
前回作ったmainフォルダの下に今度はauth-process.jsというファイルを置く。
const {BrowserWindow} = require('electron');
const createAppWindow = require('../main/app-process');
let win = null;
function createAuthWindow() {
destroyAuthWin();
win = new BrowserWindow({
width: 1000,
height: 600,
webPreferences: {
nodeIntegration: false,
enableRemoteModule: false
}
});
win.loadURL('http://localhost:8080/auth/realms/electron/protocol/openid-connect/auth?' +
'scope=openid profile offline_access&' +
'response_type=code&' +
'client_id=test&' +
'redirect_uri=http://localhost/callback');
const {session: {webRequest}} = win.webContents;
const filter = {
urls: [
'http://localhost/callback*'
]
};
webRequest.onBeforeRequest(filter, async () => {
// TODO トークン取得
createAppWindow();
return destroyAuthWin();
});
win.on('closed', () => {
win = null;
});
}
function destroyAuthWin() {
if (!win) return;
win.close();
win = null;
}
module.exports = {
createAuthWindow,
};
createAuthWindow()は、ログインウインドウを生成するfunctionで、最後にexportすることで公開している。
最初にウインドウを破棄するfunctionであるdestroyAuthWin()を一旦呼び出してから生成を開始している。
ホームページ用のウインドウとは異なり、ログインウインドウはセキュリティリスクを軽減するため以下のように設定している。
nodeIntegration: falseは、Node.jsの組み込みを実施しないための設定。
enableRemoteModule: falseは、レンダラープロセスがメインプロセスと通信しないための設定。
その後、win.loadURL()で呼び出すログインページを指定している。
ここは、前々回にkeycloakで設定したレルムにおける認証用のURLを指定しておく。
webRequest.onBeforeRequest()で特定のURLへのリクエストの実行前に実施する処理を書いている。
filterに**'http://localhost/callback*'**を指定しているので、keycloakでログインした後のリダイレクトのURLにリクエストを送る際に発動する。
処理としては、最初にトークンを取得して、ホームページ用のウインドウを開き、ログイン用のウインドウを閉じる。
ただ、トークンの取得はまだ実装していないので、現時点ではTODOとしておく。
続いて、main.jsを以下のように修正。
...
const createAppWindow = require('./main/app-process');
const {createAuthWindow} = require('./main/auth-process'); // 追加
async function showWindow() {
// return createAppWindow(); 一旦コメントアウト
return createAuthWindow();
}
...
createAppWindow()に代えて、createAuthWindow() を呼び出すようにしてみる。
ちなみに、createAuthWindow() の処理の中でcreateAppWindow()が呼び出される。
一旦ここで実行してみる。
> yarn start
無事にkeycloakのログイン画面が表示された。
ちなみに、今回は組み込みのBrowserWindowにログイン画面を表示している(Auth0のブログも同様)。この場合、セキュリティ的にはあまりよろしくないという意見もあるらしい。
大きなところでは下記の2つの問題点が挙げられる。
-
正規のサイトかどうかアプリの利用者には判定できないので、利用者に不安を与える。
-
表示したログイン画面からいろんなところに遷移できるようになっていると、組み込みのBrowserWindow内で、悪さをされる可能性が出てくる。
一方で、標準ブラウザを利用してログインさせるやり方もあるけど、いきなり別のブラウザが開く挙動は、ユーザービリティ的には微妙な気がする(ログイン後に標準ブラウザのタブを閉じる方法も考える必要がある)。
まぁ、今回は認証サービスも自前で作っていて(そもそもテスト用だけど。。)、googleやtwitterのアカウントと連携しているわけではないので、ユーザービリティ優先ということで。keycloakのログイン画面から他のページに遷移することもできなさそうだし。
ということで、用意しておいたユーザーでログインしてみる。
ちゃんと前回のホームページが表示された。
ログアウト機能の追加
トークン取得処理を先に実装すべきかもしれないが、「logout」ボタンを押しても何も起こらないのは寂しいので、先にログアウトを実装してみる。
auth-process.jsを以下のように編集。
...
function createLogoutWindow() {
const logoutWindow = new BrowserWindow({
show: false,
});
logoutWindow.loadURL('http://localhost:8080/auth/realms/electron/protocol/openid-connect/logout');
logoutWindow.on('ready-to-show', async () => {
logoutWindow.close();
// TODO クライアント側のログアウト処理
});
}
module.exports = {
createAuthWindow,
createLogoutWindow, // 追加
};
createLogoutWindow()というfunctionを追加してexportしている。
createLogoutWindow()では、非表示のウインドウを開いて、keycloakのlogout用エンドポイントを呼び出しているだけ。
続いて、renderersフォルダの下に、以下のようなhome.jsを追加する。
const { remote } = require("electron");
const authProcess = remote.require("./main/auth-process");
document.getElementById("logout").onclick = () => {
authProcess.createLogoutWindow();
remote.getCurrentWindow().close();
};
ログアウトボタンが押されたときにログアウト処理が実行されるようにしている。
home.htmlでこのスクリプトファイルを読み込むようにする。
<html lang="en">
...
<script src="home.js"></script>
</html>
実行して確認してみる。
> yarn start
ログアウトボタンを押すと、Electronアプリが閉じることは確認できた。
ただ、本当にログアウトできたか怪しいので、keycloak側で確認してみる。
ログアウトボタン押下前と押下後でセッションが減っていることが確認できた。
トークンの取得
ここからが本番。
先に「axios」と「jwt-decode」をインストールしておく。
> yarn add axios
> yarn add jwt-decode
今度はservicesというフォルダを作成し、その下にauth-service.jsというファイルを置いて以下のように実装した。
const jwtDecode = require("jwt-decode");
const axios = require('axios');
const crypto = require('crypto');
const url = require('url');
let codeVerifier = null;
let accessToken = null;
let profile = null;
function getAccessToken() {
return accessToken;
}
function getProfile() {
return profile;
}
async function loadTokens(callbackURL) {
const urlParts = url.parse(callbackURL, true);
const query = urlParts.query;
var params = new URLSearchParams();
params.append('grant_type', 'authorization_code');
params.append('client_id', 'test');
params.append('code', query.code);
params.append('redirect_uri', 'http://localhost/callback');
params.append('code_verifier', codeVerifier);
try {
const response = await axios.post('http://localhost:8080/auth/realms/electron/protocol/openid-connect/token', params);
accessToken = response.data.access_token;
profile = jwtDecode(response.data.id_token);
} catch (error) {
// TODO ログアウト
throw error;
}
}
function getChallenge() {
codeVerifier = base64URLEncode(crypto.randomBytes(32));
return base64URLEncode(sha256(codeVerifier));
}
function sha256(buffer) {
return crypto.createHash('sha256').update(buffer).digest();
}
function base64URLEncode(str) {
return str.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
module.exports = {
getChallenge,
getAccessToken,
getProfile,
loadTokens,
};
loadTokens()がトークン取得のメインロジック。認証後のリダイレクト時に呼び出せるようにするので、このfunctionはexportしておく。
loadTokens()の中で、トークン取得用のエンドポイントにPOSTリクエストを投げる。
リクエストパラメータにはURLSearchParamsを利用する必要があるので注意。また、PKCEを利用するので、code_verifierも指定している。
取得したaccess_token(accessToken)とid_token(profile)は、メモリ上にキャッシュしておき、getAccessToken()とgetProfile()で外から取得できるようにしておく。
getChallenge()では、最初にcode_verifierで利用する値を生成する。この値を利用して、code_challengeを生成する。
先に生成したcode_verifierの値はloadTokens()の中で、リクエストパラメータに指定するのでメモリ上にキャッシュしておく(codeVerifier)。
次に生成したcode_challengeの値は、keycloakのログイン画面呼び出し時に利用するため、getChallenge()の返り値として取得できるようにし、getChallenge()は、exportしてauth-process.jsから呼び出せるようにしておく。
PKCEの流れは下記のような感じ。
- ログイン画面のリクエスト時に指定されたcode_challengeの値を、keycloak側が認可コードに紐づけて保存しておく。
- 後のトークン取得リクエスト時に送られてきたcode_verifierを利用して、keycloak側でクライアントと同じロジックでチャレンジを生成。
- 1と2のチャレンジコードを比較して、同じクライアントから送られたリクエストであることを検証する。
つまり、認証後のリダイレクトURLに含まれる認可コードを何らかの方法で盗んだとしても、正しいcode_verifierの値を知っていなければ、トークン取得に失敗することになるということらしい。
auth-process.js側の修正は以下のような感じ。
const {BrowserWindow} = require('electron');
const createAppWindow = require('../main/app-process');
const authService = require('../services/auth-service'); // 追加
...
function createAuthWindow() {
...
win.loadURL('http://localhost:8080/auth/realms/electron/protocol/openid-connect/auth?' +
'scope=openid profile offline_access&' +
'response_type=code&' +
'client_id=test&' +
'code_challenge=' + authService.getChallenge() + '&' + // 追加
'code_challenge_method=S256&' + // 追加
'redirect_uri=http://localhost/callback');
...
webRequest.onBeforeRequest(filter, async ({url}) => { // パラメータにurlを追加
await authService.loadTokens(url); // 追加
createAppWindow();
return destroyAuthWin();
});
...
}
...
まずは、keycloakのログイン画面呼び出し時のURLのパラメータに「code_challenge」と「code_challenge_method」を追加している。
「code_challenge」の値は、auth-service.jsのgetChallenge()で取得した値を指定する。
「code_challenge_method」はkeycloak側の設定に合わせて「S256」を指定する。
続いて、keycloakのログイン画面で認証した後のリダイレクト時の事前処理で、auth-service.jsのloadTokens()を呼び出し、keycloakから取得した認可コードを利用してアクセストークンを取得する。
その後の、ホームのウインドウを生成し、ログイン用のウインドウを破棄する流れは変更なし。
せっかくなので、取得した情報をホームの画面で表示できるようにしてみる。home.htmlを以下のように修正。
...
<body>
<p>Home</p>
<div> <!-- ここから追加-->
<textarea id="token" rows="12" cols="120"></textarea>
<textarea id="profile" rows="8" cols="120"></textarea>
</div> <!-- ここまで-->
<button id="logout">Logout</button>
</body>
...
</html>
tokenとprofileを表示するテキストエリアを追加しただけ。
続いて、home.jsを編集して、auth-service.jsからアクセストークンとプロファイルの情報を取得して、テキストエリアにセットするようにする。
const { remote } = require("electron");
const authProcess = remote.require("./main/auth-process");
const authService = remote.require("./services/auth-service"); // 追加
const webContents = remote.getCurrentWebContents(); // 追加
// 追加
webContents.on("dom-ready", () => {
const token = authService. getAccessToken();
const profile = authService.getProfile();
document.getElementById("token").innerText = token;
document.getElementById("profile").innerText = JSON.stringify(profile);
});
...
実行して確認してみる。
> yarn start
用意したユーザーでログインすると、下記の画面が表示された。
無事にアクセストークンとプロファイルが取得できていることが確認できた。
リフレッシュトークンの利用
続いて、リフレッシュトークンを利用する実装を導入してみようと思う。
先に「keytar」をインストールしておく。
> yarn add keytar
「keytar」は「システムのキーチェーンでパスワードを取得、追加、置換、削除するためのネイティブモジュール」とのこと。
アクセストークンはメモリ上に保持しているが、リフレッシュトークンは、一度クライアントを落とした後に再起動しても利用できるように、何らかの形で永続化する必要がある。
ということで、比較的安全に保存するために、システムのキーチェーンを利用する。
「keytar」をインストールしたら、auth-service.jsに下記のようにリフレッシュトークンの取得処理を追加する。
const jwtDecode = require("jwt-decode");
const axios = require('axios');
const keytar = require("keytar"); // 追加
const os = require("os"); // 追加
const crypto = require('crypto');
const url = require('url');
let codeVerifier = null;
let accessToken = null;
let profile = null;
let refreshToken = null; // 追加
...
// function追加
async function refreshTokens() {
const refreshToken = await keytar.getPassword('electron-openid-test', os.userInfo().username);
if (refreshToken) {
var params = new URLSearchParams();
params.append('grant_type', 'refresh_token');
params.append('client_id', 'test');
params.append('refresh_token', refreshToken);
try {
const response = await axios.post('http://localhost:8080/auth/realms/electron/protocol/openid-connect/token', params);
accessToken = response.data.access_token;
profile = jwtDecode(response.data.id_token);
} catch (error) {
// TODO ログアウト
throw error;
}
} else {
throw new Error("No available refresh token.");
}
}
...
async function loadTokens(callbackURL) {
...
try {
const response = ...
...
// 追加
refreshToken = response.data.refresh_token;
if (refreshToken) {
await keytar.setPassword('electron-openid-test', os.userInfo().username, refreshToken);
}
} catch (error) {
...
}
}
...
module.exports = {
...
refreshTokens, // 追加
};
refreshTokens()のfunctionを追加して、トークンエンドポイントにトークンのリフレッシュを要求する。
refreshTokenが有効であれば、アクセストークンとプロファイル情報を返してくれる。refreshTokenを取得していない場合や、refreshTokenが無効な場合はエラーをあげる。
refreshTokenは、アクセストークンの取得時に一緒に返してくれているので、loadTokens()の中でメモリ上にキャッシュし、システムのキーチェーンにも保存しておく。
続いて、main.jsの最初でリフレッシュトークンの処理を実行するように、showWindow()の中身を変更する。
...
const authService = require('./services/auth-service'); // 追加
async function showWindow() {
try {
await authService.refreshTokens();
return createAppWindow();
} catch (err) {
createAuthWindow();
}
}
...
最初にrefreshTokens()を呼び出して、アクセストークンをリフレッシュしてから、ホームのウインドウを開く。
リフレッシュトークンが無効になっている場合などには、エラーがあがってくるので、catchしてログイン処理にまわす。
実行して確認してみると、とりあえず以下の動作を確認できた。
-
初回起動
→ ログイン画面が表示されて、ログインするとホームの画面が表示される -
一旦アプリを落として再起動
→ ログイン画面が表示されずにホームの画面が表示される
ただ、ログアウト時にリフレッシュトークンを破棄する処理を実装していないので、このままでは再度ログイン画面を表示させるためには、30日間放置するか、keycloakの管理画面で強制的にログアウトさせる必要がある。
※認証時のリクエストで「scope」に「offline_access」を指定しているため、keycloakの「Offline Session Idle」がタイムアウト時間(デフォルト30日)となる。
ログアウト時のトークン破棄
ということで、ログアウト時にトークンを破棄する実装を入れる。
...
async function refreshTokens() {
...
if (refreshToken) {
...
try {
...
} catch (error) {
await logout(); // 追加
throw error;
}
} else {
...
}
}
async function loadTokens(callbackURL) {
...
try {
...
} catch (error) {
await logout(); // 追加
throw error;
}
}
...
// 追加
async function logout() {
await keytar.deletePassword('electron-openid-test', os.userInfo().username);
accessToken = null;
profile = null;
refreshToken = null;
}
module.exports = {
...
logout, // 追加
};
logout()のfunctionを追加して、各種トークンを削除する処理を入れる。また、各種トークン取得処理の失敗時にこれを呼び出すようにする。
明示的にログアウトが指示された場合にもlogout()が呼び出せるように、exortして公開しておく。
続いて、明示的にログアウトが指示された場合の、呼び出し側のコード修正。
auth-process.jsを修正する。
...
function createLogoutWindow() {
...
logoutWindow.on('ready-to-show', async () => {
logoutWindow.close();
await authService.logout(); // 追加
});
}
...
ログアウトが指示されたときに、auth-service.jsのlogout()を呼び出すようにするだけ。
実行して確認してみると、以下のよう感じで意図した動作となった。
おまけ(Signed JWTを利用したクライアント認証の導入)
ここまでで一通りの実装は出来たはず(取得したアクセストークンは利用してないけど。。)なので、ここからはおまけ。
実は、最初は署名付きのJWTを利用して、クライアント認証を実施しようと考えていた。
keycloakはクライアントの設定で「Access Type」を「Confidential」にすることで、クライアント認証の設定ができるようになる。
で、「Credentials」のタブで「Signed JWT」を選択すると、署名付きのJWTを利用したクライアント認証を実施できるようになっている。
「Generate new keys and certificate」を押すと、キーペアの生成画面が表示されるので、「PKCS12」を選択し、適当にパスワードを設定して、「Generate and Download」ボタンを押下する。
「credentials」の画面に戻ると同時に「keystore.p12」がダウンロードされる。
ここまででkeycloak側の設定は完了なんだけれども、この時点で行き詰まってしまった。
クライアント認証するためには、この「keystore.p12」の秘密鍵を使って、クライアントで生成したJWTに署名を付ける必要があるんだけれども、そのためには、クライアント側で「keystore.p12」を保持する必要がある。
electronアプリのようなクライアントはエンドユーザーの端末にインストールされる感じかと思うので、どう足掻いても秘密鍵を盗まれるリスクは排除できないし、electronアプリに秘密鍵を同梱して配布したら、秘密鍵が悪用された場合などに、簡単に差し替え出来なくなる。
ということで、そもそもelectronアプリのようなクライアントの「Access Type」は「Public」を選ぶべきなんだろうと考え、クライアント認証は諦めた。(いろんな記事を見ても、「public」にして、PKCEで認可コード横取りに対処するのが一般的なんだろうと考えた。)
で今に至る。
一応、署名付きJWTのクライアント実装も試してみたので、載せておく。
...
async function loadTokens(callbackURL) {
const urlParts = url.parse(callbackURL, true);
const query = urlParts.query;
var params = new URLSearchParams();
params.append('grant_type', 'authorization_code');
params.append('client_id', 'test');
params.append('code', query.code);
params.append('redirect_uri', 'http://localhost/callback');
params.append('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
params.append('client_assertion', generateClientAssertion());
try {
const response = await axios.post('http://localhost:8080/auth/realms/electron/protocol/openid-connect/token', params);
accessToken = response.data.access_token;
profile = jwt.decode(response.data.id_token);
console.log(profile);
} catch (error) {
throw error;
}
}
function generateClientAssertion() {
const now = new Date();
const iatValue = now.getTime();
now.setMinutes(now.getMinutes() + 1);
const expValue = now.getTime();
const payload = {
aud: 'http://localhost:8080/auth/realms/electron/protocol/openid-connect/token', // トークンエンドポイントのURL
exp: expValue, // トークンの有効期限
jti: Math.random().toString(32).substring(2), // ユニークな値(今回はランダム文字列を簡易生成)
iat: iatValue, // トークンを署名した時刻
iss: 'http://localhost:3000/', // JWT を署名したクライアントの識別子
sub: 'test' // keycloakに登録したクライアントID
};
return sign(payload);
}
function sign(payload) {
const keyFile = fs.readFileSync('keys/keystore.p12'); // ファイル読み込み
const keyBase64 = keyFile.toString('base64'); // Stringで取得
const p12Der = forge.util.decode64(keyBase64); // base64からデコード
const p12Asn1 = forge.asn1.fromDer(p12Der); // ASN.1オブジェクトを取得
const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, 'test'); // p12として読み込み
const privateKey = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag })[forge.pki.oids.pkcs8ShroudedKeyBag][0].key; // 秘密鍵取得
const rsaPrivateKey = forge.pki.privateKeyToAsn1(privateKey); // RSA秘密鍵に変換
const privateKeyInfo = forge.pki.wrapRsaPrivateKey(rsaPrivateKey); // PrivateKeyInfoでラップ
const pemPrivate = forge.pki.privateKeyInfoToPem(privateKeyInfo); // PEM形式に変換
const signedJwt = jwt.sign(payload, pemPrivate, { algorithm: 'RS256'}); // JWTに署名
return signedJwt;
}
...
依存として「jsonwebtoken」と「node-forge」をインストールし、auth-service.jsのloadTokens()を上記のように書き換える。さらに「keys」というフォルダの下に「keystore.p12」を置く必要がある。
上記実装で、クライアント認証したうえでトークンが取得できた。
今回は確認のために、electronアプリの実装に埋め込んで試してみたが、もしクライアント認証を実施したいなら、別のやり方を考える必要がある。
例えば、トークンに署名するための専用サーバーを立ち上げて、認可コードを受け取って、署名したJWTを返すようなサービスを作るとか、認可コードを受け取って、署名したJWTを生成しつつトークンエンドポイントへのリクエストを中継するようなサービスを作る感じか。
秘密鍵はサーバー側にあるので、盗まれるリスクが少なく、後から差し替えも可能になる。
ただ、盗まれた認可コードでリクエストが来る可能性があるので、結局、PKCEのような仕組みを導入して、認証時のユーザーと同一かどうかを検証する必要がありそう。
非常にめんどくさいし、やらかしそう。
(2021/01/04 追記:別の記事で無理やり試してみました。)
ということで、特別な事情でもない限りは、「Access Type」は「Public」として実装する方がよいと思われる。
さいごに
3つの記事でに分けて、keycloakと連携してelectronのアプリに認証機能を追加するところまで実施してみました。
基本的にはAuth0のブログを参考にして実装しましたが、一気に理解するのは難しそうだったので、少しずつ実装を追加していく形にしました。おかげで、個人的には理解が深まった(気がする)けど、長編記事になってしまいました。なので、もともと詳しい人は、Auth0のブログを直接見た方がきっと分かりやすいんじゃないかと思います。
ただ、Keycloakの場合はトークン取得時のリクエストの形式(content-type)が違うなど、いくつかAuth0とは違う実装が必要だったので、その辺りの実装が参考になれば幸いです。
過去2回の記事は以下です。
ElectronアプリでKeycloakと連携(1. keycloakの設定編)
https://qiita.com/yusuke-ka/items/69d4146f344a95aa4662
ElectronアプリでKeycloakと連携(2. Electronアプリの作成編)
https://qiita.com/yusuke-ka/items/a4767c511f03b6083afc