はじめに
Auth0のUniversal Loginにはパスワードリセットの機能はありますが、ログインID(メールアドレス)を変更する機能がなさそうなので、Auth0を利用するアプリからメールアドレスを変更する方法を考えてみました。
メールアドレスの変更手順
単純にユーザーに新しいメールアドレスを入力してもらって、Management APIでメールアドレスを更新!
とやってしまうと、誤ったメールアドレスが入力された場合その内容で更新されてしまうのでログインが出来なくなってしまう恐れがあります。
新しいメールアドレスの妥当性を確認しつつ更新するには以下のような手順を踏む必要があると考えました。
- ユーザーがアプリにログインする
- ユーザーに新メールアドレスを入力してもらう
- 新メールアドレスにメール送信
- ユーザーは新メールアドレスに届いたメールを確認
- 新メールアドレスでユーザー情報(メールアドレス)を更新
この中の4のメールの受信確認ですが、よくある手法として
- slackのようにマジックリンクをメールに記載し、ユーザーにリンクを踏んでもらう
- メールにワンタイムパスワード(OTP)を記載しておき、アプリの画面からユーザーにOTPを入力してもらう。
の2つがあると思います。
しかし、いざ実装しようとするとマジックリンクやOTPの生成方法や有効期限を考える必要があるので、自前で作るのは結構大変そうです。
できるだけ自前での実装はやりたくなかったので、Auth0のパスワードレス認証を利用してこの手順を実現してみました。
シーケンス図
大まかな流れをシーケンス図にするとこうなります。
- ユーザーはログイン済みの状態から新しいメールアドレスを使ってパスワードレス認証(OTP)を行う。
- マジックリンクでのパスワードレス認証はいろいろな都合でうまくいかなかったのでOTPを利用
- パスワードレス認証の流れで新しいメールアドレスの有効性を確認。
- Auth0を利用するクライアントアプリはパスワードレス認証の結果としてトークンを取得。
- このとき、クライアントアプリは変更前のメールアドレスでログインしたときのトークンと、パスワードレス認証で取得したトークンの2つを保持する。
- パスワードレス認証で取得したIDトークンを検証し、クレームから新しいメールアドレスを取得。
- アクセストークンを使って
GET /userinfo
からメールアドレスを取得してもOK
- アクセストークンを使って
- Management APIを叩くためのアクセストークンを取得
-
update:users
とdelete:users
のscopeが必要
-
- パスワードレス認証で作成されたユーザーを削除し、現在ログインしているユーザーのメールアドレスを更新する。
- メールアドレスの更新が成功したら、新旧両方のメールアドレスに通知を飛ばす。
- 新しいメールアドレスで既にユーザー登録されていて更新がエラーとなった場合はその旨を通知。
こんな感じでやってみました。
一応、新しいメールアドレスが既に登録済みであることを示すメッセージは、メールアドレスの有効性確認後にしか表示しないようにして、登録済みかどうかはそのメールアドレスの所有者にしかわからないようにしています。
これでスクリーニング対策になってる…かな?
Auth0の設定
クライアントアプリの設定
DashBoardのApplicationsからクライアントアプリをRegular Web Applicationとして登録。
適当にCalback URLsなんかを設定して、普通にUniversal Loginが使えるようにします。
あと、忘れずにAdvanced SettingsのGrant TypeにあるPasswordless OTP
にチェックを入れておきます。
M2Mアプリの設定
DashBoardのAPIsを開き、Auth0 Management API
を選択します。
Machine to Machine Applicationsのメニューを開き、先に設定したクライアントアプリのトグルボタンをONにし、APIのpermissions設定でupdate:users
とdelete:users
のscopeにチェックを入れておきます。
これでクライアントアプリからManagement APIを叩けるようになります。
パスワードレス認証設定
DashBoard > Connections > Passwordless
を押して、Emailのパスワードレス認証を有効化し、SettingsのAuthentication Parametersを以下のように編集します。
{
"scope": "openid email"
}
あとはApplicationsからクライアントアプリのパスワードレス認証を有効化すればOKです。
クライアントアプリの実装
クライアントアプリはNode.js(Express.js)で実装してみました。
以降のコードは主要部分のみの抜粋です。
パスワードレス認証の開始
シーケンス図の2と3に該当します。
ユーザーに新しいメールアドレスを入力してもらい、パスワードレス認証を開始します。
const axios = require('axios');
router.post('/change-email', async (req, res) => {
// パスワードレス認証の開始
await axios.post(
'https://{Auth0のドメイン}/passwordless/start',
{
client_id: '{クライアントID}',
client_secret: '{クライアントシークレット}',
connection: 'email',
email: '{新しいメールアドレス}',
send: 'code',
},
);
res.redirect('/otp'); // OTP入力画面にリダイレクト
});
OTPとトークンを交換
シーケンス図の6に該当します。
OTPを入力する画面ではCSRF対策をしたほうが良いと思います。
const axios = require('axios');
const querystring = require('querystring');
const response = await axios.post(
'https://{Auth0のドメイン}/oauth/token',
querystring.stringify({
grant_type: 'http://auth0.com/oauth/grant-type/passwordless/otp',
client_id: '{クライアントID}',
client_secret: '{クライアントシークレット}',
username: '{新しいメールアドレス}',
realm: 'email',
otp: '{OTP}',
}),
);
IDトークンの検証
シーケンス図の9に該当します。
IDトークンの検証と同時にクレームを取得しています。
const jwksClient = require('jwks-rsa');
const jwt = require('jsonwebtoken');
const userInfo = async (idToken) => new Promise((resolve, reject) => {
const client = jwksClient({
jwksUri: 'https://{Auth0のドメイン}/.well-known/jwks.json',
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
});
const getKey = (header, callback) => {
client.getSigningKey(header.kid, (err, key) => {
const signingKey = key.publicKey || key.rsaPublicKey;
callback(null, signingKey);
});
};
const options = {
algorithms: ['RS256'],
audience: '{クライアントID}',
issuer: 'https://{Auth0のドメイン}/',
};
jwt.verify(idToken, getKey, options, (error, decoded) => {
if (error) {
reject(error);
} else {
resolve(decoded);
}
});
});
Management API用のアクセストークンを取得
シーケンス図の10と11に該当します。
Management APIのアクセストークンはキャッシュしておいて、有効期限が切れたら再取得するようにしておくと良いです。
const axios = require('axios');
const params = new URLSearchParams();
params.append('audience', 'https://{Auth0のドメイン}/api/v2/');
params.append('scope', 'update:users delete:users');
params.append('grant_type', 'client_credentials');
params.append('client_id', '{クライアントID}');
params.append('client_secret', '{クライアントシークレット}');
const responseToken = await axios.post(
'https://{Auth0のドメイン}/oauth/token', params
);
パスワードレス認証で作成されたユーザーを削除
シーケンス図の12に該当します。
先にパスワードレス認証で取得したIDトークンのsubクレームを指定してユーザーを削除
const axios = require('axios');
await axios.delete('https://{Auth0のドメイン}/api/v2/users/{IDトークンのsubクレーム}', {
headers: { Authorization: 'Bearer {Management APIのアクセストークン}' },
data: {},
});
メールアドレスの更新
シーケンス図の13に該当します。
更新対象のユーザーは元々ログインしているほうのユーザーなので間違えないようにしましょう。
await axios.patch(
'https://{Auth0のドメイン}/api/v2/users/{現在ログインしているユーザーのsub}',
querystring.stringify(
{
email: '{IDトークンのemailクレーム}',
email_verified: true,
},
),
{ headers: { Authorization: 'Bearer {Management APIのアクセストークン}' } },
)
.catch(
(error) => // エラーハンドリング
);
変更後のメールアドレスが既に存在している場合、このAPIリクエストはエラーとなって以下のレスポンスが帰ってきます。
{ statusCode: 400,
error: 'Bad Request',
message: 'The specified new email already exists',
errorCode: 'auth0_idp_error' }
このエラーが帰ってきたらユーザーに「このメールアドレスは既に登録されています。」などの通知を出すと良いと思います。
このメールアドレス変更手順の注意点
ここまでAuth0のパスワードレス認証の仕組みを利用したメールアドレス変更手順を紹介してきましたが、いくつか注意点があります。
パスワードレス認証の使い方
そもそもパスワードレス認証はこんな使われた方を想定した機能ではないはずなので、何か不都合があるかもしれませんし、今後パスワードレス認証の仕様が変わったときに想定外の挙動をする可能性もあります。
もしもこの内容を試されるときは、そういった点を承知の上で実施してください。
RulesでLink Accounts with Same Email Addressを設定している場合
RulesのLink Accounts with Same Email Address
を設定して同一メールアドレスのアカウントリンクを実施していると
新しく入力されたメールアドレスが既に登録済みの場合に既存のアカウントと新しいメールアドレスで作成されたユーザーが統合され、既存アカウントごとユーザーが削除されてしまいます。
RulesでLink Accounts with Same Email Address
を設定している場合は、パスワードレス認証で作成されたユーザーはアカウントリンクの対象外にする必要があります。
function (user, context, callback) {
const request = require('request');
// Emailによるパスワードレス認証の場合はアカウントリンクの対象外となるように条件追加
if (!user.email || !user.email_verified || context.connection === "email") {
return callback(null, user, context);
}
クライアントアプリのタイプ
このメールアドレス変更フローはクライアントアプリがConfidential Clientでないと成功しません。
SPAやNative AppのようなPublic Clientだと、Management APIのscope(update:users
とdelete:users
)が取れないので失敗します。
おわりに
Universal Loginの標準機能でログインIDを変更する機能が追加されると嬉しいんですが
現状そんな機能はないので、無理やりAuth0の機能を使って実装してみました。
もっと他にスマートなやり方があれば誰か教えて欲しいです。