🔐

Passkeyを個人開発サービスに導入したお話

2023/10/18に公開

初めまして。都内でソフトウェアエンジニアをやってご飯を食べているWintuというものです。今回は私が開発してるライブ配信プラットフォーム「CASPUR」にPasskeyを導入した話をしていけたらと思っております。
https://caspur.wintu.dev/front/

今回はPasskeyのある程度の仕組みを理解して、実際に実装したいと思ってるエンジニアさんに少しでも参考になればと思い書いてます。なのでPasskeyなどの基礎知識とかの解説は省かせてもらいます

完成した認証フロー

なるべくPasskeyを登録して欲しかったので、登録導線に組み込んで後のログインを楽にするよう設計しました。いろいろデザインが投げやりなところは個人開発なので許してください...😛

CASPURの現状

CASPURでは認証周りにFirebase Authenticationを利用し、バックエンドはMySQL + Express.js。フロントエンドにはVue.jsを利用して開発しております。
現段階では、Vue.js v3への移行をちまちまやっているような段階です。

導入した動機

私自身、個人開発サービスにユーザー登録など億劫だと自分が思っているタイプなのでそのハードルを少しでも下げれるかと思い導入を決意しました。

また、当時Google主催のオフラインイベントでPasskeyの解説がされており、将来性を感じたためいち早く動きを知りたいと思ったのもきっかけになっております。

使用ライブラリ

今回は単純に導入できるものを選びました。
https://github.com/MasterKale/SimpleWebAuthn

登録機構

実際にバックエンド、フロントエンドの順に実装をしていこうと思います。ごちゃごちゃ説明するよりコードを提示した方がいいと思うのでそうします😊

ライブラリの使用方法は公式ドキュメントに纏まってるため、こちらをご覧になりながら見ていただけると理解が早いと思います!
https://simplewebauthn.dev/docs/packages/server#1-generate-registration-options

まずはバックエンド側 (解説用に実装したため、JSで申し訳ないです...)

const express = require('express')
const router = express.Router()
const {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} = require('@simplewebauthn/server')

const PASSKEY_CONFIG = {
  rpID: 'example.com', // Relying Party Identifier(RPID)というサービスごとの識別子。私はドメインを指定してます。
  rpName: 'Passkey Tester', // サービス名
  attestationType: 'none',
  authenticatorSelection: {
    userVerification: 'required',
    residentKey: 'required' // これがないとPasskey認証になりません。
  }
}

// Passkey用のチャレンジトークンと登録オプションを返す
router.get('/register', async (req, res, next) => {
  // ユーザー情報を取得
  const user = {
    id: req.query.id,
    name: req.query.name
  }

  // 対象のユーザーのPasskeyを取得(実際はDBから取得する)
  const passkeyCredentials = [{
    userId: user.id, // ユーザーID
    credentialId: 'hogehogefugafuga' // Passkey発行時の識別ID
  }]

  const options = generateRegistrationOptions({
    ...PASSKEY_CONFIG,
    userID: user.id,
    userName: user.name,
    // 登録済みのPasskeyをブロックする
    excludeCredentials: passkeyCredentials
      .filter(passkey => passkey.userId === user.id)
      .map(passkey => ({
        id: passkey.credentialId,
        type: 'public-key'
      }))
  })

  // セッションにチャレンジトークンを保存
  req.session.passkeyChallenge = options.challenge
  res.json({ok: 1, options})
})

// Passkeyを登録する
router.post('/register', async (req, res, next) => {
  // ユーザー情報を取得
  const user = {
    id: req.body.user.id,
    name: req.body.user.name
  }
  const challenge = req.session.passkeyChallenge

  if (challenge) {
    req.session.passkeyChallenge = undefined
  } else {
    next(new Error('パスキーの登録に失敗しました'))
    return
  }

  // validation & decode
  const {verified, registrationInfo} = await verifyRegistrationResponse({
    credential: req.body.credential,
    expectedChallenge: challenge,
    expectedRPID: PASSKEY_CONFIG.rpID,
    expectedOrigin: 'http://localhost:3000' // Originを指定
  })

  if (!verified || !registrationInfo) {
    next(new Error('パスキーの登録に失敗しました'))
    return
  }

  // DBに保存する情報
  const passkeyCredential = {
    credentialId: registrationInfo.credentialID.toString('base64url'), // 識別ID
    credentialPublicKey: registrationInfo.credentialPublicKey, // 公開鍵
    counter: registrationInfo.counter, // 認証回数
    credentialDeviceType: registrationInfo.credentialDeviceType,
    credentialBackedUp: registrationInfo.credentialBackedUp,
    userId: user.id
  }

  // TODO: save passkeyCredential to DB

  res.json({ok: 1})
})

こちらフロントエンド側

<template>
  <div class="passkey-register">
    <button type="button" @click="register">register</button>
  </div>
</template>

<script setup>
import {startRegistration, browserSupportsWebAuthn} from '@simplewebauthn/browser'

const USER_INFO = {
  id: 'hogehoge',
  name: 'test user'
}

const register = async () => {
  if (!browserSupportsWebAuthn()) {
    alert('WebAuthn is not supported on this browser')
    return
  }

  // チャレンジトークンとオプションを取得
  const optionRes = await fetch(`/register?${new URLSearchParams(USER_INFO)}`).then((res) => res.json())

  try {
    const result = await startRegistration(optionRes.options)
    await fetch('/register', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({user: USER_INFO, credential: result})
    }).then((res) => res.json())

    alert('register success')
  } catch (error) {
    // 認証失敗したら、startRegistrationがコケます
    alert('register failed')
    console.error(error)
  }
}
</script>

これで登録機構は完成です。諸所省略した部分は読者のシステムの仕様に合わせて実装してください!

ログイン機構

同様に以下の公式ドキュメントを元にバックエンド、フロントエンド実装していきます!
https://simplewebauthn.dev/docs/packages/server#1-generate-authentication-options

const express = require('express')
const router = express.Router()
const {
  generateAuthenticationOptions,
  verifyAuthenticationResponse
} = require('@simplewebauthn/server')

const PASSKEY_CONFIG = {
  rpID: 'example.com', // Relying Party Identifier(RPID)というサービスごとの識別子。私はドメインを指定してます。
  rpName: 'Passkey Tester', // サービス名
  attestationType: 'none',
  authenticatorSelection: {
    userVerification: 'required',
    residentKey: 'required' // これがないとPasskey認証になりません。
  }
}

// Passkeyのチャレンジトークン発行とログインオプションを返す
router.get('/login', async (req, res, next) => {
  const options = generateAuthenticationOptions({userVerification: 'preferred'})
  req.session.passkeyChallenge = options.challenge
  res.json({ok: 1, options})
})

// パスキーを使ったログイン処理
router.post('/login', async (req, res, next) => {
  const challenge = req.session.passkeyChallenge
  const credential = req.body.credential

  if (challenge) {
    req.session.passkeyChallenge = undefined
  } else {
    next(new Error('パスキーの登録に失敗しました'))
    return
  }

  // 登録時に保存した認証データを取得(実際はDBから取得する。 credential.idを参照する)
  const passkeyCredential = {}

  // validation
  const {verified, authenticationInfo} = await verifyAuthenticationResponse({
    credential,
    expectedChallenge: challenge,
    expectedRPID: PASSKEY_CONFIG.rpID,
    expectedOrigin: 'http://localhost:3000', // Originを指定
    authenticator: {
      credentialID: passkeyCredential.credentialId,
      credentialPublicKey: passkeyCredential.credentialPublicKey,
      counter: passkeyCredential.counter,
      credentialDeviceType: passkeyCredential.credentialDeviceType,
      credentialBackedUp: passkeyCredential.credentialBackedUp
    }
  })

  // verifiedがtrueだった場合は認証成功
  if (!verified || !authenticationInfo || req.body.user.id !== passkeyCredential.userId) {
    next(new Error('パスキーの認証に失敗しました'))
    return
  }

  passkeyCredential.counter = authenticationInfo.newCounter

  // TODO: update passkeyCredential to db

  res.json({ok: 1, user})
})
module.exports = router

こちらフロント側

<template>
  <div class="passkey-login">
    <button type="button" @click="login">login</button>
  </div>
</template>

<script setup>
import {startAuthentication, browserSupportsWebAuthn} from '@simplewebauthn/browser'

const USER_INFO = {
  id: 'hogehoge',
  name: 'test user'
}

const login = async () => {
  if (!browserSupportsWebAuthn()) {
    alert('WebAuthn is not supported on this browser')
    return
  }

  // チャレンジトークンとオプションを取得
  const optionRes = await fetch('/login').then((res) => res.json())

  try {
    const result = await startAuthentication(optionRes.options)
    await fetch('/login', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({user: USER_INFO, credential: result})
    }).then((res) => res.json())

    alert('login success')
  } catch (error) {
    // 認証失敗したら、startAuthenticationがコケます
    alert('login failed')
    console.error(error)
  }
}
</script>

便利なライブラリを使えば実はそんなに難しくなく実装できてします。読者の方々も是非チャレンジしてみてください。劇的にログインが楽になるので多分私は今後Passkeyを自分のサービスに組み込みまくる気がしてます😎

もしPasskeyの挙動知りたい方は、私の個人開発サービス「CASPUR」で試せるので是非アカウント登録してみてくださいw

今後はFlutterやAndroid、iOSなどのネイティブ周りでのPasskey実装にもチャレンジしてみたいですね... それはまた僕のやる気が出た時に...

参考にさせてもらったサイト
https://techblog.yahoo.co.jp/entry/2023080730431354/

Discussion