- 投稿日:2020-05-30T23:08:10+09:00
GitHub Actions上でPuppeteerを動かす
GitHub ActionsでPuppeteerを動かしたいなと思い試してみたメモです。
結果、特にそこまで気にせずに起動してくれました。
準備
割と普通ですね。
yarn add puppeteerプログラム
GitHubのPuppeteer Headfulのページを見ると
process.env.PUPPETEER_EXEC_PATH
でバイナリを指定しろみたいな記載があるけど、特に指定せずに普通インストールして使えました。app.jsconst puppeteer = require('puppeteer'); (async () => { const URL = `https://twitter.com/n0bisuke`; const browser = await puppeteer.launch({}); const page = await browser.newPage(); await page.goto(URL); //URLにアクセス // Get the "viewport" of the page, as reported by the page. const dimensions = await page.evaluate(() => { return { width: document.documentElement.clientWidth, height: document.documentElement.clientHeight, title: document.title, deviceScaleFactor: window.devicePixelRatio }; }); console.log('Dimensions:', dimensions); console.log('タイトル:', dimensions.title); await browser.close(); })();ちなみにローカルでもこのままnode app.jsで使えます。
ymlの記述
.github/workflows/run-puppeteer.yml
を作成run-puppeteer.ymlname: RUN puppeteer on: push: branches: [ master ] jobs: build: runs-on: ubuntu-latest strategy: matrix: node-version: [14.x] # 以下が実際のステップ steps: - uses: actions/checkout@v2 - name: Use Node.js 14.x uses: actions/setup-node@v1 with: node-version: '14.x' - name: install command run: yarn install - name: RUN app.js run: node app.jsデプロイして確認
ちゃんと動いてるっぽい
所感
Vercelなどserverless環境で動かすときとは変更あったけど、今回は特に無さそうですね。
ファイル書き込みがどうなるかとかは試せてないのでその辺気になりますね。
全体コードはこちらにまとめておきます。
- 投稿日:2020-05-30T21:37:35+09:00
Vercel上でPuppeteerを動かす
試してみたら動かせました。調べてもそれっぽいの無さそうだったのでメモです。
参考にしてるコードとお断り
ググったらこちらのプルリクが当たったので、こちらをもとに書いてますが、現状の公式なやり方かはわかりません。
その他参考: VercelでLINE BOTを動かす 2020年5月版
実装
chrome-aws-lambda + puppeteer-coreを利用
vercelはAWS Lambda上で動いてる模様なので、動作するChromium Binaryもchrome-aws-lambdaを使うと良いっぽいです。
また、バイナリはchrome-aws-lambdaを利用するということで、バイナリが梱包されてないpuppeteer-coreを使います。
$ yarn add chrome-aws-lambdaここで最新バージョンの
chrome-aws-lambda
がインストールされますが、この時のバージョンにあわせてpuppeteer-core
もインストールしましょう。僕の場合、chrome-aws-lambdaをインストールしたら3.1.1だったのでpuppeteer-coreもバージョン指定してインストールします。(なにも指定しないでインストールしたら
3.2.0
がインストールされて、デプロイ後に怒られました。$ yarn add puppeteer-core@3.1.1
- package.jsonのdependenciesで確認
こんな感じでこの二つのモジュールのバージョンが合ってれば大丈夫です。
"dependencies": { "chrome-aws-lambda": "^3.1.1", "puppeteer-core": "3.1" }プログラムの作成
プロジェクトのルートにapiフォルダを作成し、その中に任意のjsファイルを作成します。
api/run-puppeteer.js
とします。Node.jsなコードもLambdaぽい書き方になります。
api/run-puppeteer.jsconst chrome = require('chrome-aws-lambda'); const puppeteer = require('puppeteer-core'); module.exports = async (req, res) => { const { URL = 'https://twitter.com/n0bisuke' } = req.query; const browser = await puppeteer.launch({ args: chrome.args, executablePath: await chrome.executablePath, headless: chrome.headless, }); const page = await browser.newPage(); await page.goto(URL); //URLにアクセス // Get the "viewport" of the page, as reported by the page. const dimensions = await page.evaluate(() => { return { width: document.documentElement.clientWidth, height: document.documentElement.clientHeight, title: document.title, deviceScaleFactor: window.devicePixelRatio }; }); console.log('Dimensions:', dimensions); await browser.close(); //アクセスしたページのタイトルを取得 res.send(`${URL}のページタイトルは「${dimensions.title}」だよー!`); }こんな感じになりました。
補足
apiフォルダにjsファイルを作ってこんな感じに書くのが基本ぽいです。AWS Lambdaをちゃんと使ったことないけど、一つ上の階層でrequire/importしてる処理があるんでしょうね。
module.exports = (req, res) => { const { name = 'World' } = req.query res.send(`Hello ${name}!`) }参考: https://vercel.com/docs/runtimes#official-runtimes
デプロイして試す
$ vercelデプロイして、
https://発行されたドメイン/api/jsファイル名
にアクセスします。
今回の場合はhttps://発行されたドメイン/api/run-run-puppeteer
です。
api/run-puppeteer.js
の中身参照ですが、アクセスすると、puppeteerが動いて指定したURL(デフォはhttps://twitter.com/n0bisuke
)にアクセスしてタイトルを取得してきます。その他: scheenshotの画像保存が出来なかった
これは設定問題かもしれないですが、ログにこんな感じのが出てたので、Vercel上でファイル書き込みは出来ないのかも。
"errorMessage":"EROFS: read-only file system, open 'example.png'(出来そうだって話あれば教えて下さい!)
補足: 毎回デプロイが面倒なのでローカルで試す
api/run-puppeteer.js
をこんな感じで書いてローカルでも試せました。
- ローカル環境
- mac os catalina
- Node.js v14.3.0
puppetterを追加します。
$ yarn add puppetter
process.env.AWS_LAMBDA_FUNCTION_VERSION
があればVercel上での動作と判断してます。api/run-puppeteer.jslet chrome = {}; let puppeteer = {}; //puppeteer main process const run = async (puppeteer, chrome={}, URL='https://twitter.com/n0bisuke') => { const browser = await puppeteer.launch({ args: chrome.args, executablePath: await chrome.executablePath, headless: chrome.headless, }); const page = await browser.newPage(); await page.goto(URL); // Get the "viewport" of the page, as reported by the page. const dimensions = await page.evaluate(() => { return { width: document.documentElement.clientWidth, height: document.documentElement.clientHeight, title: document.title, deviceScaleFactor: window.devicePixelRatio }; }); await browser.close(); return dimensions; } if(process.env.AWS_LAMBDA_FUNCTION_VERSION){ //Vercel chrome = require('chrome-aws-lambda'); puppeteer = require('puppeteer-core'); }else{ //Local Test puppeteer = require('puppeteer'); const URL = `https://protoout.studio`; run(puppeteer,{},URL).then(res => console.log(res)); } module.exports = async (req, res) => { const { URL='https://twitter.com/n0bisuke' } = req.query; const dimensions = await run(puppeteer, chrome, URL); res.send(`${URL}のページタイトルは「${dimensions.title}」だよー!`); }手元で実行
$ node api/run-puppeteer.js
vercel dev
コマンドでも試せるかもですが、サーバー起動などで時間かかるのでこの手の処理だけならこっちの方が早いかなぁという感触です。まとめや所感など
最終形のコードはディレクトリごとあげておきました。
chrome-aws-lambdaというのがあることを知れたのが収穫かも。あと、
process.env.AWS_LAMBDA_FUNCTION_VERSION
で判定してるけどもっとよいやり方ないのかな......あとやはりファイル書き込みが出来ない(スクショ保存)のかも気になるので誰か分かる方いたらコメントを...笑
Herokuなどでも問題ないけどVercel上でもpuppeteer動かしたい場合もあるかもしれないので誰かの参考になれば幸いです。
- 投稿日:2020-05-30T21:11:22+09:00
ESLint簡単導入 × VSCode
node.js をインストールする
ざっくりコマンド
$ brew install nodebrew # HomebrewでNodeBrewをインストール $ nodebrew setup # Nodebrewのセットアップ $ nodebrew install-binary latest # 最新版のnode.jsをインストール $ nodebrew ls # インストールしたnode.jsのバージョンを確認 $ nodebrew use v12.4.0 # ls で確認したバージョンを指定 $ echo 'export PATH=/usr/local/var/nodebrew/current/bin:$PATH' >> ~/.bash_profile # パスを通す(bashの例)参考:MacにNode.jsをインストール
参考:【2018年版】macのhomebrewでnodebrew入れてからnode.jsを入れるまでESLintをインストールする
ざっくりコマンド
$ cd {プロジェクトルート} # プロジェクトルートに移動 # package.json作成していない場合 $ npm init # 対話式でpackage.json作成 (適当にエンターでOK) $ npm install eslint --save-dev # eslintのインストール (開発環境にしか必要ないはずなので --save-dev を付与) $ npm install -g eslint-cli # eslintをグローバルインストールしなくてもeslintコマンドがパス通るようになる $ eslint --init # ESLintの初期化 (.eslintrcファイルの作成)
eslint --init
の対話内容# コードスタイルも強制してもらおうかなと一番厳しめな一番下を選択 ? How would you like to use ESLint? To check syntax only To check syntax and find problems ❯ To check syntax, find problems, and enforce code style # フロントのJSしか書かないつもりなので一番上を選択 ? What type of modules does your project use? ❯ JavaScript modules (import/export) CommonJS (require/exports) None of these # フレームワークは使わないので一番下を選択 ? Which framework does your project use? React Vue.js ❯ None of these # TypeScriptは使わないので N ? Does your project use TypeScript? (y/N) N # フロントのJSしか書かないつもりなので Browser のみ選択 ? Where does your code run? ❯◉ Browser ◯ Node # 設定ファイルを自分で書くのは大変なのでスタイルガイドを使用すべく一番上を選択 ? How would you like to define a style for your project? ❯ Use a popular style guide Answer questions about your style Inspect your JavaScript file(s) # よく分からないけれど Airbnb を選択 ? Which style guide do you want to follow? ❯ Airbnb: https://github.com/airbnb/javascript Standard: https://github.com/standard/standard Google: https://github.com/google/eslint-config-google # 設定ファイルといえばJSONのイメージなのでJSONを選択 ? What format do you want your config file to be in? JavaScript YAML ❯ JSON # どうやら Airbnb だとESLintのバージョンを下げなければいけないらしくて大人しく従う Y ? The style guide "airbnb" requires eslint@^5.16.0 || ^6.8.0. You are currently using eslint@7.1.0. Do you want to downgrade? (Y/n) Y # 関連するパッケージをインストールしろと言われたので Y eslint-config-airbnb-base@latest eslint@^5.16.0 || ^6.8.0 eslint-plugin-import@^2.20.1 ? Would you like to install them now with npm? (Y/n) Y参考:ESLintと「eslint --init」による「.eslintrc.json」の生成
VSCodeの拡張機能をインストールする
- ESLint をインストールする
- Cmd + Shift + P > ESLint: enable ESLint を実行
- 自動的にJSファイルがチェックされるようになるはず・
- 投稿日:2020-05-30T20:22:08+09:00
Expressを使ってみた ~ Expressに「ようこそ」と言われるまで ~
そもそも Express とは
Node.jsのWebアプリケーションフレームワーク
express-generator
アプリケーションジェネレータツール。これを使うと、アプリケーションの骨組みを素早く作成できる
インストールしてみる
$ npm install -g express-generator実際にアプリを作るためには
- まずはヘルプ機能を確認
$ express -h
- さっそくExpressアプリを作成
- アプリ名:myapp
- viewエンジン:pug
$ express --view=pug myapp
- 依存関係をインストール
$ cd myapp $ npm install
- アプリの実行
$ DEBUG=myapp:* npm PORT=XXXX start
- ブラウザで http://localhost:XXXX/ をロードしてアプリにアクセス
...すると!
Expressがすでにいい感じの骨組みを作ってくれている!参考文献
Express
https://expressjs.com/en/starter/generator.html投稿の経緯
はじめまして。私は、独学でプログラムを勉強しており、
縁あって、Expressに遭遇したため、アウトプットの一環として残しました。
すごく便利そうだけれど、まだExpressの凄さをよく理解できてないので、
ドキュメントを読み込んで、Qiitaで共有したいです。
- 投稿日:2020-05-30T19:47:16+09:00
FIDO2(WebAuthn)に入門してみた - ユーザ登録処理
始めに
※この記事は、 WebAuthn を使用したユーザ登録フローに関する学習メモです。
作成したソースコード一式はこちらに格納しておきます。
FIDO2 について
TL; DR
FIDOという非営利団体が推進する認証技術、規格のこと
生体認証などで用いられるような専用の機器が不要
FIDO2 = WebAuthn + CTAP
W3C WebAuthn
FIDO認証のサポートを可能にするためにブラウザおよびプラットフォームに組み込まれている標準Web APIのこと。登録と認証の機能を持つ。
navigator.credentials.create()
:publicKeyオプションと併用すると、新しいアカウントの登録または、既存アカウントへの新しい非対称鍵ペア(公開鍵と秘密鍵)の関連付けを行うための新しい認証情報を作成します。
navigator.credentials.get()
:publicKeyオプションと併用すると、サービスに対する認証のために、ログインまたは二要素認証要素として既存の認証情報セットを使用します。CTAP(Client to Authentication Protocol)
名前の通り、クライアントと認証器の通信プロトコルです。
仕組み
FIDOのプロトコルでは、標準的な公開鍵暗号方式を用いて、認証を実現しています。
以下、基本的な処理シーケンスです。
このように、クライアント(認証器)/サーバ間で認証情報(パスワード等)をやり取りしないため、従来のID/Password方式の認証方式よりも安全であると、言われています。また、クライアント側に認証に必要な秘密鍵を保持することによって、ユーザがパスワードを記憶する必要がなくなるというメリットも存在します。
ただし、Yubikeyや端末本体等の認証器を紛失した場合は、パスワード紛失と同じようにセキュリティリスクがあります。
認証サーバ(Relying Party)を自作してみた
実装言語・FW
- 認証サーバ;Nest.js v6.13.3 (https://gitlab.com/s.kawamura/webauthn-nestjs-sample)
- JavaScript Application;Angular v9.0.4 (https://gitlab.com/s.kawamura/webauthn-angular-sample)
- Database;MongoDB
で作成しました。好きな言語、フレームワークで作成してみてください。
登録の処理シーケンス
※MDMより引用
- 認証サーバに対して、ユーザーの登録要求を行う。 リクエストのプロトコルやフォーマットは特にWebAuthnで規定されているわけではありません。
- 認証サーバで生成したchallenge, ユーザ情報、サーバの情報をクライアントに返却する。 以下の点に気を付ける必要があります。
- challengeは、ランダムに生成したバッファーであること。(少なくとも16バイト以上)
- challengeは、必ずサーバ上で生成すること。(登録過程のセキュリティを確保するため)
- クライアント/サーバ間でバッファーソースは、base64url encode/decodeして扱う
- 認証器に対して、認証情報の生成要求を行う。 認証サーバから取得したデータを元に、
navigator.credentials.create()
呼び出しに必要なパラメータを組み立てる。 ※challenge等はbase64url encodeされているため、create()
呼び出し前にデコードする必要があります。- ユーザーの確認後、非対称鍵ペアとAttestationを生成する。
- 生成したデータをクライアントに返却する。
- 認証器から取得した認証情報をサーバに送信する。 こちらも、リクエストのプロトコルやフォーマットは特にWebAuthnで規定されているわけではありません。 ※ArrayBufferプロパティは、サーバに送信する前にbase64url エンコードする必要があります。
- 認証情報のチェックを実施する。 クライアントから送信された、認証情報に対して以下のチェックを行います。
- challengeが認証サーバで作成されたものと同一であるか?
- originが期待通りのoriginであるか?
- attestationObjectが有効か?
WebAuthnで規定されていない箇所に関しては自分で仕様を考えて実装する必要があります。
従って、今回作成するのは以下の赤枠部分となります。(認証サーバとJavaScript Application)
認証サーバ作成
思ったよりも量が多くなってしまったので、かいつまんで掲載します。
ソースコードすべてを参照したい方は、こちらから参照してください。
ユーザの登録要求 ~ challenge、ユーザ情報、サーバ情報のレスポンス
最終的に以下のようなリクエスト、レスポンスとなるように作りこんでいきます。(登録の処理シーケンスの0 ~ 1の部分)
リクエスト
POST http://localhost:3000/webauthn/register HTTP/1.1 Content-Type: application/json { "email": "sample@sample.com" }レスポンス
HTTP/1.1 201 OK Content-Type: application/json;charset=UTF-8 { "status":"ok", "challenge":"<ランダムな文字列>", // ArrayBufferをbase64Urlエンコードしたもの "rp":{ "name":"webauthn-server-sample" }, "user":{ "id":"<ランダムな文字列>", // ArrayBufferをbase64urlエンコードしたもの "name":"sample name", "displayName":"sample display name" }, "attestation":"direct" }webauthn.service.ts@Injectable() export class WebauthnService { constructor(@InjectModel('User') private userModel: Model<User>) { } /** * 認証器が鍵の生成に必要なパラメータを生成します。 * @param createUserDto リクエストボディー */ async createUserCreationOptions(createUserDto: CreateUserDto): Promise<UserCreationOptions> { // challenge, userIdをuuidを元に作成する const challenge = Buffer.from(Uint8Array.from(uuid(), c => c.charCodeAt(0))); const userId = Buffer.from(Uint8Array.from(uuid(), c => c.charCodeAt(0))); // UserCreationOptionsのパラメータを組み立てる const userCreationOptions: UserCreationOptions = { email: createUserDto.email, challenge: base64url.encode(challenge), rp: { name: 'webauthn-server-nestjs-sample', }, user: { id: base64url.encode(userId), name: createUserDto.email, displayName: createUserDto.email, }, attestation: 'direct', }; // DBに保存する const saveResult = await this.saveUser(userCreationOptions); // falsyだった場合、nullを返却する if (!saveResult) { return null; } return userCreationOptions; }認証情報のチェック
最終的に、以下のようなリクエスト、レスポンスとなるように作りこんでいきます。
リクエスト
POST http://localhost:3000/webauthn/response HTTP/1.1 Content-Type: application/json { "rawId": "Gw7nqgWzci8jwIX9yXzYtynmJbQAAAAAAAAAAAAAAAAAAAAA...", "response": { "attestationObject": "o2NmbXRoZmlkby11MmZnYXR0U3RtdKJ...", "clientDataJSON": "eyJjaGFsbGVuZ2UiOiJLMlEwdHdnXzV..." }, "id": "Gw7nqgWzci8jwIX9yXzYtynmJbQAAAAAAAAAAAAAAAAAAAAA...", "type": "public-key" }レスポンス
HTTP/1.1 201 OK Content-Type: application/json;charset=UTF-8 { "status":"ok", }challenge, originの検証
リクエストボディに含まれる
clientDataJSON
をbase64Urlデコードし、JSONにパースすると以下のようなJSONを取得できます。{ "challenge": "upYb6sib9exL7fvSfQhIEazOkBh8_YJXVPzSx0T16B0", "origin": "http://localhost:4200", "type": "webauthn.create" }これに対して、
- challengeは、サーバで生成されたchallengeと一致しているか?(データベースに保存しておいたchallengeと一致しているか?)
- originは、期待通りか?
という検証を行えば十分です。
AttestationObjectの検証
AttestationObjectは、base64urlエンコードされているCBORとなっています。実際の構成は以下の通りとなっています。
※W3Cより引用
検証では、AttestationObjectをパースして得られるパラメータを使用して、Attestation Signatureの有効性を検証します。
実際の検証ロジックは、fido-seminar-webauthn-tutorialが非常に参考になりました。
webauthn.service.ts/** * AttestationObjectの検証を行います。 * @param createCredentialDto 認証器が生成した認証データ */ private async verifyAuthenticatorAttestationResponse(createCredentialDto: CreateCredentialDto): Promise<VerifiedAuthenticatorAttestationResponse> { // 認証器でbase64urlエンコードされているので、認証サーバでデコードする const attestationBuffer = base64url.toBuffer(createCredentialDto.response.attestationObject); // attestationObjectをCBORデコードする const ctapMakeCredentialResponse: CborParseAttestationObject = Decoder.decodeAllSync(attestationBuffer)[0]; Logger.debug(ctapMakeCredentialResponse, 'WebAuthnService', true); const response: VerifiedAuthenticatorAttestationResponse = { verified: false, }; if (ctapMakeCredentialResponse.fmt === 'fido-u2f') { const authDataStruct = this.parseMakeCredAuthData(ctapMakeCredentialResponse.authData); if (!authDataStruct.flags) { throw new Error('User was NOT presented durring authentication!'); } const clientDataHash = crypto.createHash('SHA256').update(base64url.toBuffer(createCredentialDto.response.clientDataJSON)).digest(); const reservedByte = Buffer.from([0x00]); const publicKey = this.convertToRawPkcsKey(authDataStruct.cosePublicKey); const signatureBase = Buffer.concat([reservedByte, authDataStruct.rpIdHash, clientDataHash, authDataStruct.credID, publicKey]); const pemCertificate = this.convertPemTextFormat(ctapMakeCredentialResponse.attStmt.x5c[0]); const signature = ctapMakeCredentialResponse.attStmt.sig; response.verified = this.verifySignature(signature, signatureBase, pemCertificate); const validateResult = this.verifySignature(signature, signatureBase, pemCertificate); // Attestation Signatureの有効性を検証する return validateResult ? { verified: validateResult, authInfo: { fmt: 'fido-u2f', publicKey: base64url.encode(publicKey), counter: authDataStruct.counter, credId: base64url.encode(authDataStruct.credID), }, } : response; } }クライアントサイド作成
htmlとcssは適当に作ってください。(丸投げ)
sign-up.service.ts/** * ユーザ登録処理のサービスクラスです。 */ @Injectable({ providedIn: 'root' }) export class SignUpService { constructor(private readonly httpClient: HttpClient) { } /** * ユーザの登録処理を実行します。 * @param email メールアドレス */ async signUp(email: string): Promise<boolean> { // challengeの作成 const registerResponse = await this.createChallenge(email); // `navigator.credentials.create()呼び出しのために必要なパラメータの組み立て const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = { challenge: Buffer.from(base64url.decode(registerResponse.data.challenge)), rp: registerResponse.data.rp, user: { id: Buffer.from(base64url.decode(registerResponse.data.user.id)), name: registerResponse.data.user.name, displayName: registerResponse.data.user.displayName, }, attestation: registerResponse.data.attestation, pubKeyCredParams: [{ type: 'public-key' as 'public-key', alg: -7, }], authenticatorSelection: { authenticatorAttachment: 'cross-platform', requireResidentKey: false, userVerification: 'discouraged' } }; // 明示的にPublicKeyCredentialにキャストする const attestationObject = await this.createAttestationObject(publicKeyCredentialCreationOptions) as PublicKeyCredential; // 公開鍵をサーバに送信する return this.registerPublicKey(attestationObject); } /** * WebAuthn認証サーバに対して、チャレンジの生成要求を行います。 * @param email メールアドレス */ private async createChallenge(email: string): Promise<User> { const registerResponse = await this.httpClient.post<User>(Uri.USER_REGISTER, { email }, { headers: { 'Content-Type': 'application/json' }, observe: 'response', }).toPromise(); console.log(registerResponse.body); return registerResponse.body; } /** * 認証器に対して公開鍵の生成要求を行います。 * @param publicKeyCreationOptions 認証情報生成オプション */ private async createAttestationObject(publicKeyCreationOptions: PublicKeyCredentialCreationOptions): Promise<Credential> { return navigator.credentials.create({ publicKey: publicKeyCreationOptions }); } /** * 認証情報をBase64Urlエンコードして認証サーバにPOSTします。 * @param credential 認証器で生成した認証情報 */ private async registerPublicKey(publicKeyCredential: PublicKeyCredential): Promise<boolean> { const attestationResponse = await this.httpClient.post(Uri.ATTESTATION_RESPONSE, { rawId: base64url.encode(Buffer.from(publicKeyCredential.rawId)), response: { attestationObject: base64url.encode(Buffer.from(publicKeyCredential.response.attestationObject)), clientDataJSON: base64url.encode(Buffer.from(publicKeyCredential.response.clientDataJSON)), }, id: publicKeyCredential.id, type: publicKeyCredential.type }, { headers: { 'Content-Type': 'application/json' }, observe: 'response' }).toPromise(); return attestationResponse.body ? true : false; } }完成イメージ
終わりに
次は、認証のフローについて書きます。
参考
- 投稿日:2020-05-30T19:47:16+09:00
MANスタックでFIDO2(WebAuthn)に入門してみた
始めに
※この記事は、 WebAuthn を使用したユーザ登録フローに関する学習メモです。
作成したソースコード一式はこちらに格納しておきます。
FIDO2 について
TL; DR
FIDOという非営利団体が推進する認証技術、規格のこと
生体認証などで用いられるような専用の機器が不要
FIDO2 = WebAuthn + CTAP
W3C WebAuthn
FIDO認証のサポートを可能にするためにブラウザおよびプラットフォームに組み込まれている標準Web APIのこと。登録と認証の機能を持つ。
navigator.credentials.create()
:publicKeyオプションと併用すると、新しいアカウントの登録または、既存アカウントへの新しい非対称鍵ペア(公開鍵と秘密鍵)の関連付けを行うための新しい認証情報を作成します。
navigator.credentials.get()
:publicKeyオプションと併用すると、サービスに対する認証のために、ログインまたは二要素認証要素として既存の認証情報セットを使用します。CTAP(Client to Authentication Protocol)
名前の通り、クライアントと認証器の通信プロトコルです。
仕組み
FIDOのプロトコルでは、標準的な公開鍵暗号方式を用いて、認証を実現しています。
以下、基本的な処理シーケンスです。
このように、クライアント(認証器)/サーバ間で認証情報(パスワード等)をやり取りしないため、従来のID/Password方式の認証方式よりも安全であると、言われています。また、クライアント側に認証に必要な秘密鍵を保持することによって、ユーザがパスワードを記憶する必要がなくなるというメリットも存在します。
ただし、Yubikeyや端末本体等の認証器を紛失した場合は、パスワード紛失と同じようにセキュリティリスクがあります。
認証サーバ(rp)を作って遊んでみる
登録の処理シーケンス
※MDMより引用
- 認証サーバに対して、ユーザーの登録要求を行う。 リクエストのプロトコルやフォーマットは特にWebAuthnで規定されているわけではありません。
- 認証サーバで生成したchallenge, ユーザ情報、サーバの情報をクライアントに返却する。 以下の点に気を付ける必要があります。
- challengeは、ランダムに生成したバッファーであること。(少なくとも16バイト以上)
- challengeは、必ずサーバ上で生成すること。(登録過程のセキュリティを確保するため)
- クライアント/サーバ間でバッファーソースは、base64url encode/decodeして扱う
- 認証器に対して、認証情報の生成要求を行う。 認証サーバから取得したデータを元に、
navigator.credentials.create()
呼び出しに必要なパラメータを組み立てる。 ※challenge等はbase64url encodeされているため、create()
呼び出し前にデコードする必要があります。- ユーザーの確認後、非対称鍵ペアとAttestationを生成する。
- 生成したデータをクライアントに返却する。
- 認証器から取得した認証情報をサーバに送信する。 こちらも、リクエストのプロトコルやフォーマットは特にWebAuthnで規定されているわけではありません。 ※ArrayBufferプロパティは、サーバに送信する前にbase64url エンコードする必要があります。
- 認証情報のチェックを実施する。 クライアントから送信された、認証情報に対して以下のチェックを行います。
- challengeが認証サーバで作成されたものと同一であるか?
- originが期待通りのoriginであるか?
- attestationObjectが有効か?
WebAuthnで規定されていない箇所に関しては自分で仕様を考えて実装する必要があります。
従って、今回作成するのは以下の赤枠部分となります。(認証サーバとJavaScript Application)
実装言語・FW
- 認証サーバ;Nest.js v6.13.3 (https://gitlab.com/s.kawamura/webauthn-nestjs-sample)
- JavaScript Application;Angular v9.0.4 (https://gitlab.com/s.kawamura/webauthn-angular-sample)
- Database;MongoDB
で作成しました。好きな言語、フレームワークで作成してみてください。
認証サーバ作成
思ったよりも量が多くなってしまったので、かいつまんで掲載します。
ソースコードすべてを参照したい方は、こちらから参照してください。
ユーザの登録要求 ~ challenge、ユーザ情報、サーバ情報のレスポンス
最終的に以下のようなリクエスト、レスポンスとなるように作りこんでいきます。(登録の処理シーケンスの0 ~ 1の部分)
リクエスト
POST http://localhost:3000/webauthn/register HTTP/1.1 Content-Type: application/json { "email": "sample@sample.com" }レスポンス
HTTP/1.1 201 OK Content-Type: application/json;charset=UTF-8 { "status":"ok", "challenge":"<ランダムな文字列>", // ArrayBufferをbase64Urlエンコードしたもの "rp":{ "name":"webauthn-server-sample" }, "user":{ "id":"<ランダムな文字列>", // ArrayBufferをbase64urlエンコードしたもの "name":"sample name", "displayName":"sample display name" }, "attestation":"direct" }webauthn.service.ts@Injectable() export class WebauthnService { constructor(@InjectModel('User') private userModel: Model<User>) { } /** * 認証器が鍵の生成に必要なパラメータを生成します。 * @param createUserDto リクエストボディー */ async createUserCreationOptions(createUserDto: CreateUserDto): Promise<UserCreationOptions> { // challenge, userIdをuuidを元に作成する const challenge = Buffer.from(Uint8Array.from(uuid(), c => c.charCodeAt(0))); const userId = Buffer.from(Uint8Array.from(uuid(), c => c.charCodeAt(0))); // UserCreationOptionsのパラメータを組み立てる const userCreationOptions: UserCreationOptions = { email: createUserDto.email, challenge: base64url.encode(challenge), rp: { name: 'webauthn-server-nestjs-sample', }, user: { id: base64url.encode(userId), name: createUserDto.email, displayName: createUserDto.email, }, attestation: 'direct', }; // DBに保存する const saveResult = await this.saveUser(userCreationOptions); // falsyだった場合、nullを返却する if (!saveResult) { return null; } return userCreationOptions; }認証情報のチェック
最終的に、以下のようなリクエスト、レスポンスとなるように作りこんでいきます。
リクエスト
POST http://localhost:3000/webauthn/response HTTP/1.1 Content-Type: application/json { "rawId": "Gw7nqgWzci8jwIX9yXzYtynmJbQAAAAAAAAAAAAAAAAAAAAA...", "response": { "attestationObject": "o2NmbXRoZmlkby11MmZnYXR0U3RtdKJ...", "clientDataJSON": "eyJjaGFsbGVuZ2UiOiJLMlEwdHdnXzV..." }, "id": "Gw7nqgWzci8jwIX9yXzYtynmJbQAAAAAAAAAAAAAAAAAAAAA...", "type": "public-key" }レスポンス
HTTP/1.1 201 OK Content-Type: application/json;charset=UTF-8 { "status":"ok", }challenge, originの検証
リクエストボディに含まれる
clientDataJSON
をbase64Urlデコードし、JSONにパースすると以下のようなJSONを取得できます。{ "challenge": "upYb6sib9exL7fvSfQhIEazOkBh8_YJXVPzSx0T16B0", "origin": "http://localhost:4200", "type": "webauthn.create" }これに対して、
- challengeは、サーバで生成されたchallengeと一致しているか?(データベースに保存しておいたchallengeと一致しているか?)
- originは、期待通りか?
という検証を行えば十分です。
AttestationObjectの検証
AttestationObjectは、base64urlエンコードされているCBORとなっています。実際の構成は以下の通りとなっています。
※W3Cより引用
検証では、AttestationObjectをパースして得られるパラメータを使用して、Attestation Signatureの有効性を検証します。
実際の検証ロジックは、fido-seminar-webauthn-tutorialが非常に参考になりました。
webauthn.service.ts/** * AttestationObjectの検証を行います。 * @param createCredentialDto 認証器が生成した認証データ */ private async verifyAuthenticatorAttestationResponse(createCredentialDto: CreateCredentialDto): Promise<VerifiedAuthenticatorAttestationResponse> { // 認証器でbase64urlエンコードされているので、認証サーバでデコードする const attestationBuffer = base64url.toBuffer(createCredentialDto.response.attestationObject); // attestationObjectをCBORデコードする const ctapMakeCredentialResponse: CborParseAttestationObject = Decoder.decodeAllSync(attestationBuffer)[0]; Logger.debug(ctapMakeCredentialResponse, 'WebAuthnService', true); const response: VerifiedAuthenticatorAttestationResponse = { verified: false, }; if (ctapMakeCredentialResponse.fmt === 'fido-u2f') { const authDataStruct = this.parseMakeCredAuthData(ctapMakeCredentialResponse.authData); if (!authDataStruct.flags) { throw new Error('User was NOT presented durring authentication!'); } const clientDataHash = crypto.createHash('SHA256').update(base64url.toBuffer(createCredentialDto.response.clientDataJSON)).digest(); const reservedByte = Buffer.from([0x00]); const publicKey = this.convertToRawPkcsKey(authDataStruct.cosePublicKey); const signatureBase = Buffer.concat([reservedByte, authDataStruct.rpIdHash, clientDataHash, authDataStruct.credID, publicKey]); const pemCertificate = this.convertPemTextFormat(ctapMakeCredentialResponse.attStmt.x5c[0]); const signature = ctapMakeCredentialResponse.attStmt.sig; response.verified = this.verifySignature(signature, signatureBase, pemCertificate); const validateResult = this.verifySignature(signature, signatureBase, pemCertificate); // Attestation Signatureの有効性を検証する return validateResult ? { verified: validateResult, authInfo: { fmt: 'fido-u2f', publicKey: base64url.encode(publicKey), counter: authDataStruct.counter, credId: base64url.encode(authDataStruct.credID), }, } : response; } }クライアントサイド作成
htmlとcssは適当に作ってください。(丸投げ)
sign-up.service.ts/** * ユーザ登録処理のサービスクラスです。 */ @Injectable({ providedIn: 'root' }) export class SignUpService { constructor(private readonly httpClient: HttpClient) { } /** * ユーザの登録処理を実行します。 * @param email メールアドレス */ async signUp(email: string): Promise<boolean> { // challengeの作成 const registerResponse = await this.createChallenge(email); // `navigator.credentials.create()呼び出しのために必要なパラメータの組み立て const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = { challenge: Buffer.from(base64url.decode(registerResponse.data.challenge)), rp: registerResponse.data.rp, user: { id: Buffer.from(base64url.decode(registerResponse.data.user.id)), name: registerResponse.data.user.name, displayName: registerResponse.data.user.displayName, }, attestation: registerResponse.data.attestation, pubKeyCredParams: [{ type: 'public-key' as 'public-key', alg: -7, }], authenticatorSelection: { authenticatorAttachment: 'cross-platform', requireResidentKey: false, userVerification: 'discouraged' } }; // 明示的にPublicKeyCredentialにキャストする const attestationObject = await this.createAttestationObject(publicKeyCredentialCreationOptions) as PublicKeyCredential; // 公開鍵をサーバに送信する return this.registerPublicKey(attestationObject); } /** * WebAuthn認証サーバに対して、チャレンジの生成要求を行います。 * @param email メールアドレス */ private async createChallenge(email: string): Promise<User> { const registerResponse = await this.httpClient.post<User>(Uri.USER_REGISTER, { email }, { headers: { 'Content-Type': 'application/json' }, observe: 'response', }).toPromise(); console.log(registerResponse.body); return registerResponse.body; } /** * 認証器に対して公開鍵の生成要求を行います。 * @param publicKeyCreationOptions 認証情報生成オプション */ private async createAttestationObject(publicKeyCreationOptions: PublicKeyCredentialCreationOptions): Promise<Credential> { return navigator.credentials.create({ publicKey: publicKeyCreationOptions }); } /** * 認証情報をBase64Urlエンコードして認証サーバにPOSTします。 * @param credential 認証器で生成した認証情報 */ private async registerPublicKey(publicKeyCredential: PublicKeyCredential): Promise<boolean> { const attestationResponse = await this.httpClient.post(Uri.ATTESTATION_RESPONSE, { rawId: base64url.encode(Buffer.from(publicKeyCredential.rawId)), response: { attestationObject: base64url.encode(Buffer.from(publicKeyCredential.response.attestationObject)), clientDataJSON: base64url.encode(Buffer.from(publicKeyCredential.response.clientDataJSON)), }, id: publicKeyCredential.id, type: publicKeyCredential.type }, { headers: { 'Content-Type': 'application/json' }, observe: 'response' }).toPromise(); return attestationResponse.body ? true : false; } }完成イメージ
終わりに
次は、認証のフローについて書きます。
参考
- 投稿日:2020-05-30T19:07:04+09:00
Node.js インストールからREPL、ファイル実行まで(mac)
インストール
nodejs
nodejsのサイトでDLしちゃいましょう。
ターミナルでやる方法もありますが、別でググって下さい。% node -v v13.8.0インストールできたらターミナルで確認。npmも一緒にインストールされます。
npmはpythonでいうpipです。npm -v 6.14.5
Nodejs REPL
$ node > > 3 + 2 6 >nodeシェルをうまく使うと開発にも役立ちます。
便利なREPLコマンド
.break セッション内でブロックから抜け出す .clear セッション内でブロックから抜け出す .editor 複数行のコードを書ける .exit セッション終了 .help ヒントの表示 .load ローカルなファイルにアクセスする .save セッションのコードをファイルに保存する
Node.jsでファイルを実行する
nodeの後にファイル名を指定することでJavascriptファイルをNode.jsで実行します
main.js
let message = "Hi, This is Node.js."; console.log(message);terminalで実行
% node main.js > Hi, This is Node.js.こんな感じです。
- 投稿日:2020-05-30T19:03:53+09:00
(備忘録)Node.jsでネットワークの疎通チェック処理作ろうとした時の挫折メモ。。。
はじめに
自分の備忘録用です
SPA(Single Page Application)である処理を作ろうとした時の話です。。。
諸々の事情により外部IPアドレスにアクセスし、アクセス可否により表示を変更するという仕様でした。
しかし結局の所、アクセス不可の場合、レスポンスが悪すぎてボツとなりましたが。。。
しょぼいですが頑張って色々調べて作ったので後学のためにも残します。
自分用に作った記事なので、分かりにくい点や情報、技術が古いかもしれませんがご了承ください参考資料
参考にさせて頂きました。大変ためになりました
- SuperAgent
- SuperAgent(HTTPリクエスト用のNode.jsモジュール)の使い方メモ
- 今からはじめるReact.js〜サーバーとの通信〜
- シェルコマンドを実行する方法(child_process)
- 勉強メモ/npmの使い方
- SPA(Single Page Application)ってなに?
環境 ※以下のVerでなくても動くと思いますが、古いのでご注意下さい
OS: Ubuntu 18.04.4 LTS Node.js: v10.16.0ネットワークの疎通チェック
結局はアクセス不可時の待機時間が長すぎてボツとなりましたが、2通り試しました。
前提としては、クロスドメイン制約1のため、クライアント(React)からの通信チェックではなく、
サーバ側(Node.js)でのチェックを想定してました。。。SuperAgentを使って疎通チェック
一つ目は非同期処理のライブラリでSuperAgentを使い、Ajax通信による疎通チェックを試しました。
SuperAgentのインストール方法は以下です。npm install superagent --saveipAddressSuperAgent.jsconst request = require('superagent'); const ipaddress = 'AAA.BBB.CCC.DDD' // ここを疎通確認したいIPアドレスに変えます console.log('疎通確認 START ' + ipaddress); request .get('http://' + ipaddress) .timeout({ response: 5000, // Wait 5 seconds for the server to start sending, deadline: 10000, // but allow 10 seconds for the file to finish loading. }) .then(res => { if (res.ok) { console.log('疎通 OK'); } else { console.log('疎通 NG'); } }) .catch(err => { console.log('疎通 NG'); });$ node ipAddressSuperAgent.js 疎通確認 START AAA.BBB.CCC.DDD 疎通 NG
ChildProcessを使って疎通チェック
二つ目は標準モジュールのchild_process使い、シェルコマンドpingによる疎通チェックを試しました。
pingのオプションとして-c(回数指定)をすれば、レスポンスよくなるのではないかと勝手に思ってましたが、
見当違いでした当然ながらしっかり1回毎に時間かけてpingしてくれてました
他のオプションありそうな気もしますが。。挫折しました。。。ipAddressChildProcess.jsconst exec = require('child_process').exec; const ipaddress = 'AAA.BBB.CCC.DDD' // ここを疎通確認したいIPアドレスに変えます exec("echo 'ping START' " + ipaddress, (error, stdout, stderr) => { if (error) { console.log(error); console.log(stderr); } console.log(stdout); }); exec('ping -c 3 ' + ipaddress , (error) => { if (error) { console.log("疎通 NG: " + error); } else { console.log("疎通 OK"); } });$ node ipAddressChildProcess.js ping START AAA.BBB.CCC.DDD 疎通 NG: Error: Command failed: ping -c 3 AAA.BBB.CCC.DDD ping: AAA.BBB.CCC.DDD: 名前またはサービスが不明です今回の教訓
内々では懸念を確認していましたが、やはり全てを把握できている訳はないですね。
可能な限り使う人に近い立場の方に遠慮しないで確認すべきでした
単体テストもやり直しだし、ドキュメントまで遡って直す工数が発生してしまいました。
結局はメンタルトレーニングが必要でしたわ。気合い入れて頑張ります
- 投稿日:2020-05-30T17:56:30+09:00
【初心者】Puppeteerでよく使うコードベスト3
はじめに
毎月末になると、ルーティンで作業工数の入力をしています。
JavaScriptで半自動で入力していて、5分ほどで入力出来ています。
が、憂鬱すぎるので全自動化することにしました。
ググって最初に目に付いたPuppeteerを利用します。
実際に利用してみてベースとなるコード、更によく使うコードがわかったのでQiitaに残します。コード
基本のコード
kihon.jsconst puppeteer = require('puppeteer'); // ID・アカウント認証する時はここでID・アカウント情報を読み込む↓ // 後述 // ID・アカウント認証する時はここでID・アカウント情報を読み込む↑ (async () => { const browser = await puppeteer.launch({ headless: false, // ブラウザの動きを表示 slowMo: 50 // puppeteerの操作を遅らせる }) const page = await browser.newPage() // ページを開く await page.goto('https://www.google.com/') // 必要な処理を書く↓ // 後述 // 必要な処理を書く↑ // ブラウザを閉じる await browser.close() })()ID・アカウント認証する時はここでID・アカウント情報を読み込む
const {USER, PWD} = require('./config.json'); // 認証が必要であれば別ファイルのconfigファイルから読み込むconfig.jsonの中身は以下
config.json{ "USER":"xxx", "PWD":"xxx" }必要な処理(よく使うコード)
今回だと使うコードは決まっていて、必要な値を入力して、ボタンをクリックして登録するだけでしたので以下の操作で事足りました。
// 指定した時間待つ await page.waitFor(10000); // ミリ秒 // 入力 await page.type("#IdUser", 'userName'); // セレクタ,入力文字。 await page.type("#IdUser", USER); // 変数の場合 // クリック await page.click("#loginButton"); // セレクタ // テキスト取得 const text = await page.$eval('td.timeHour', text => text.textContent) // セレクタ最終的なコード
kihonプラスα.jsconst puppeteer = require('puppeteer'); // ID・アカウント認証する時はここでID・アカウント情報を読み込む const {USER, PWD} = require('./config.json'); // 認証が必要であれば別ファイルのconfigファイルから読み込む (async () => { const browser = await puppeteer.launch({ headless: false, // ブラウザの動きを表示 slowMo: 50 // puppeteerの操作を遅らせる }) const page = await browser.newPage() // ページを開く await page.goto('https://www.google.com/') // 必要な処理を書く // 指定した時間待つ await page.waitFor(10000); // ミリ秒 // 入力 await page.type("#IdUser", USER); // 変数の場合 // クリック await page.click("#loginButton"); // セレクタ // テキスト取得 const text = await page.$eval('td.timeHour', text => text.textContent) // セレクタ // ブラウザを閉じる await browser.close() })()参考
- 公式
- 環境構築
- サンプルコード
- よく使う操作解説