はじめに
ワンタイムパスワードの入力補助API、 WEB OTP APIをご存じでしょうか?
プロジェクトで触ってみる機会があり、こんなことができるのか!と感動したので、その感動を共有してみたいと思います。
どんなことができるの?
スマートフォンでSMSメッセージを受信後に、「送信」ボタンをクリックすると、PCのブラウザにワンタイムパスワードが送信されます。
そもそもOTPとは?
OTPは「one-time-password」の略で、訳すと一度きりのパスワードです。
2段階認証などで、ユーザーのログインの際に追加でSMSなどに送られてくるコードのことを指します。
OTPが抱える課題
OTPはSMSであれば電話番号の所有確認に加えて、ユーザーの持っている電話番号であるかを確認でき、セキュリティ向上が期待できます。
一方でユーザー操作が増えるため、特にデバイスの横断(e.g. PCでログインしたいが、スマートフォンにメッセージが来る時など)が発生した場合は、ユーザービリティを低下させてしまう可能性を孕んでいます。
スマホで 2段階認証の画面を開いている場合などは1端末の操作ですみますが、PCでログインをしたい時などは操作が面倒になってしまいます。
PCでまずはID/Passを入力して送信、2段階認証画面が表示されるので、スマートフォンに送信されて来たSMSメッセージを見て、PCに向かってコピペはできないから手入力して、、、、と、実際にやったことのある人にはわかる、端末を行ったり来たりしないとならないという非常に面倒な操作が求められます。
そんなときにユーザーの利便性を向上するのが今回紹介するWEB OTP APIになります。
WEB OTP APIとは
WEB OTP APIはワンタイムパスワードの入力補助をしてくれるAPIになります。
SMSが配信された時にスマートフォンなどで受信したコードがキーパッド上部にサジェストされるのはよくみたことがあるかと思いますが、サジェストのその先、たとえば自動入力などをさせることができるようになるのがこのAPIです。
同一のアカウントで Chrome にログインする必要があります
使用可能なブラウザ
WEB OTP APIはGoogleが提案しているAPIになります。
そのため、使用可能なブラウザは主にChormiumベースのブラウザが対象になっています。
- Chrome
- Edge
- Opera
- Chrome for android
- Samsung Internet
- Opera Mobile
Chromeがサポートしているので、ユーザーの多さを考えると、サポートしておいて損はなさそうです。
Safariの場合はinputのフォームにauto-complete="one-time-code"
の属性を設定するだけで同様の処理を実現が可能です。
実際に実装してみる
さて、ここからは実装に入っていきたいと思います。
WEB OTP APIを使うために必要なことは下記2点です。
- W3Cが定めた形式でSMSメッセージを送信する
- フロントエンドでWEB OTP APIを使い、コードを受け取る
1. SMSメッセージを送信する
SMS の配信形式
SMSの配信形式ですが、こちらをまず、W3Cが定めた Origin-bound one-time codes delivered via SMSの形式に合わせる必要があります。
上の英語は要約すると、「originを証明したSMSで配信されるOTPの形式」、ということです。
この仕様はSMS OTPとwebサイトを紐付ける仕様で、W3Cのコミュニティ内部で提案されている仕様になります。
元々はSPなどのデバイスで別のサイトから送信されたOTPが自動入力がされてしまうことがあり、フィッシングサイトなどで自動入力を防ぐために提案された仕様です。
この仕様をもとに、ドメインが一致するWebサイトへの自動入力を促すべくGoogleが提案したJavascript APIが今回使うWEB OTP APIになります。
そのため、まずはSMSの形式をフォーマット通りにしましょう。
Your OTP is 123456
@test.com #123456
上記が提唱されている形式となります。
こちらで大事なところは、一番最後の行だけです。この一文を正しく記載する必要があります。
詳しい形式は下記になります。
-
@
移行に OTP を入力するサイトのドメインを記載する - 1で記載したドメインの後に
#
をつけて、ワンタイムパスワードを記載する
こちらの形式を守ることで、今回のWEB OTP APIを使えるほか、異なるドメインであれば、OTPのコードの入力補助をさせないように防ぐことができます。
SMSメッセージを送信できるようにする
今回はSupabaseのedge functionsを使ってSMSメッセージを送信していきます。
edge functionsは ユーザーの近くのエッジ環境で実行することができるのでレイテンシーを低く抑えることができます。
1. supabaseプロジェクトを作成する
Supabase「New Project」からプロジェクトを作成します。
- Region ⇒ Tokyo Regionを選ぶ
2. Supabase CLIをインストール
# CLIをインストールする
$ brew install supabase/tap/supabase
3. Supabase Edge functionsの環境作成
# Supabaseアカウント(app.supabase.com)を使ってログインする
supabase login
# Supabaseを初期化
supabase init
ローカルでプロジェクトを開発するためのすべての設定を保持するsupabase
フォルダーが作成されます。
.
└── supabase/
├── .gitignore
└── config.toml
# プロジェクトとリモートのプロジェクトを関連付ける
supabase link --project-ref {Reference ID}
# 関数作成用のボイラープレート作成
supabase functions new sms-message
これで関数作成用の雛形(/sms-message/index.ts
)ができました。
最終的に下記のようなディレクト構成になります。
.
├── supabase/
│ ├── .temp
│ ├── functions/(*)
│ │ ├── _shared
│ │ └── sms-message/
│ │ ├── helper/
│ │ │ ├── twillioSms.ts
│ │ │ └── messageTemplate.ts
│ │ ├── types/
│ │ │ └── sms.ts
│ │ ├── deps.ts
│ │ └── index.ts(*)
│ ├── .env
│ ├── .gitignore
│ └── config.toml
└── deno.jsonc
4. 環境変数を登録する
トライアルアカウントでは、管理画面でverify
された電話番号へのみSMSが送信が可能です。
Twillioの管理画面Account Infoから下記3点を環境変数に登録する
TWILIO_ACCOUNT_SID={Account SID}
TWILIO_AUTH_TOKEN={Auth Token}
TWILIO_PHONE_NUMBER={My Twilio phone number}
# .envファイルからSupabaseへ環境変数を登録。
supabase secrets set --env-file ./supabase/.env
# 登録した環境変数一覧表示
supabase secrets list
# 環境変数削除
supabase secrets unset
5. 関数を作成する
TwilioSms
ヘルパークラスを作成する
import {base64} from '../deps.ts';
import {SMSRequest} from '../types/sms.ts';
/**
* Twilio SMS Client Class
*/
export class TwilioSms {
private readonly authorizationHeader: string;
/**
* Create Authorization Header
* @param accountSID
* @param authToken
*/
constructor(private accountSID: string, authToken: string) {
this.authorizationHeader = `Basic ${
base64.fromUint8Array(
new TextEncoder().encode(`${accountSID}:${authToken}`),
)
}`;
}
/**
* SMS送信
* @param payload SMSRequest
*/
async sendSms(payload: SMSRequest): Promise<any> {
const res = await fetch(
`https://api.twilio.com/2010-04-01/Accounts/${this.accountSID}/Messages.json`,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
Authorization: this.authorizationHeader,
},
body: new URLSearchParams(payload).toString(),
},
);
return await res.json();
}
}
ここでは、登録した環境変数を取得して、TwilioSmsのインスタンス化して、sendSmsでメッセージを送信します。
import {serve} from './deps.ts';
import {corsHeaders} from '../_shared/mod.ts';
import {TwilioSms} from './helper/twilio-sms.ts';
import {MessageTemplates} from "./helper/messageTemplate.ts";
export const accountSid = Deno.env.get('TWILIO_ACCOUNT_SID') || '';
export const authToken = Deno.env.get('TWILIO_AUTH_TOKEN') || '';
export const fromMobile = Deno.env.get('TWILIO_PHONE_NUMBER') || '';
// SMSメッセージを送る関数
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response('ok', {headers: corsHeaders});
}
const {toMobile} = await req.json() || '';
const twilioClient = new TwilioSms(accountSid, authToken);
const {status = ''} = await twilioClient.sendSms({
Body: MessageTemplates.otpMessage('123456'),
From: fromMobile,
To: toMobile,
});
const data = {
isSuccess: false,
};
if (status === 'queued') {
data.isSuccess = true;
}
return new Response(JSON.stringify(data), {
headers: {
...corsHeaders,
'Content-Type': 'application/json',
},
});
});
6. 作成した関数をDeployする
# Deployする
supabase functions deploy send-message
これでSMSを送信する処理をデプロイすることができました。
// クライアント側では、下記で関数を呼びだすことができます。
const { data, error } = await supabase.functions.invoke('send-message', {
body: JSON.stringify({ toMobile: phone })
});
細かい部分は省略させていただきましたが、詳細は下記レポジトリをご確認ください。
SMS送信関数のレポジトリ:
2. フロントエンドでWEB OTP APIを使い、コードを受け取る
さて、いよいよ本題のフロントエンドの実装に入りましょう。
今回使用した主要な技術は下記になります。
主要な使用技術
- Nextjs
- react hook form
今回はWEB OTP APIの実際に処理をおこなっている箇所を抜粋して説明をしていきます。
(より詳しくみたい方は、サンプルリポジトリのsrc/pages/otp/index.tsx
をご確認ください。)
/** OTP Credential API処理 */
useEffect(() => {
// OTP Credential APIが使用可能かどうか
if (!("OTPCredential" in window)) {
return;
}
// one-time-codeのを入力するinputエリアを取得
const input = document.querySelector('input[autocomplete="one-time-code"]');
if (input == null) {
return;
}
// フォームを取得
const form = input.closest("form");
const ac = new AbortController();
const abort = (): void => ac.abort();
if (form != null) {
// ユーザー操作で送信したらabort
form.addEventListener("submit", abort);
}
// OTP処理
navigator.credentials
.get({
// @ts-expect-error
otp: { transport: ["sms"] },
signal: ac.signal,
})
.then(async (otp) => {
// @ts-expect-error
setValue("otp", otp?.code ?? "");
await trigger("otp");
if (isValid) {
// 送信処理
submitOTP({ otp: getValues("otp") });
}
})
.catch((error) => {
console.log(error);
});
// listenerをremoveする
return () => {
if (form != null) {
form?.removeEventListener("submit", abort);
}
};
}, [getValues, isValid, setValue, submitOTP, trigger]);
なんだか色々とやっていますが、実は本当の肝の部分は下に記載するたった10数行の部分だけになります。
// ①
navigator.credentials
.get({
otp: { transport: ["sms"] },
signal: ac.signal,
})
.then(async (otp) => {
// ②
setValue("otp", otp?.code ?? "");
// 省略
})
.catch((error) => {
console.log(error);
});
この部分です!
していることはとても単純です。
-
navigator.credentials.get
でotp: { transport: ["sms"] }
を指定、OTPがSMSから送信されるのを待機する -
then
の節の内部で、code
を受け取り、行いたい処理をする。
WEB OTP APIのPromiseが解決するまで待ち、Promiseが解決したらオブジェクトからコードを受け取る、至ってシンプルな実装です。
then節で受け取ったコードを使いフォームの自動入力などを行わせることができます。
より詳しい実装は公開しているリポジトリをご覧ください。
終わりに
WEB OTP APIの良さは実装者それほど大きなコストをかけずとも対応することができるが、ユーザーの利便性を上げる所にあると思います。
ユーザーから見れば、PCのフォームがSPを少し操作するだけで入力され、ほとんど煩わしさもなく認証を済ませられます。
端末を横断するので、面倒そうな実装なのかなと初見だと感じそうですが、実際はたった20行にも満たないというのが実装者としては魅力的かなと思っています。
また、UXを高めるだけでなく、SMSをW3Cの指定する形式にするだけでもセキュリティの向上が見込まれるので、もし開発要件に2段階認証があった、そんな時には提案するのも良いかなと思いました。