Auth0で初回ログイン時に強制的にパスワードを再設定させる

2022.09.06

こんにちは。CX事業本部Delivery部のakkyです。

今回は、Auth0で初回ログイン時、ユーザーに強制的に新しいパスワードを設定させる方法を調べましたので、ご紹介します。

Auth0の設定だけでは不可能なため、Auth0 Rulesと外部に用意したWebページを併用する方法になります。

この方法についてはAuth0のドキュメントに記載されていますが、細かい点が不明であったため、動作確認を兼ねて実装しました。

背景

Auth0でシステムにユーザーを追加する場合、各自に新規登録してもらう場合と、管理者がマネジメントコンソールから追加する場合があります。 限られたユーザーのみを登録したい場合は後者の方法をとることになりますが、このユーザーにパスワードの変更を強制したい場合は、ランダムなパスワードを設定しておき、パスワードリセットメールから変更してもらう方法が手軽です。通常はこの方法を使いましょう。

しかし、要件によってはユーザーがメールを受信しなくてもパスワードを変更したい場合があるかもしれません。システムによっては、初回ログイン時にパスワード設定画面に遷移し、新たなパスワードを設定させることが可能ですが、現状Auth0ではそのような設定はありません。

そこで今回はこのような強制パスワード設定を実現するために、Auth0 Rulesを使用し、自前で用意したパスワード設定画面に遷移させることでこの要件を実現してみます。

注意点

今回ご紹介する方法では、パスワード再設定の画面はAuth0のものではなく、自前で用意したWebサイトになります。 ドメインもデザインもAuth0とは異なりますので、フィッシングやバグと誤解されないようにユーザーに事前に説明しておく必要があります。

動作シーケンス

サイトからAuth0でログインするまでは通常と同じです。ログインが完了した後、Rulesでユーザーに紐づいたフラグをチェックし、パスワード未変更の場合、パスワード変更システムへ遷移します。その後、パスワードを入力すると、API経由でパスワードを変更し、システムへ戻します。 Rulesからパスワード変更システムへは、ユーザー名や連携に必要な情報をJWTに入れて渡しています。なお、ここで使用するJWTのシークレットキーは、このシステム独自に用意するものであり、Auth0のAPIのキーなどとは関係ありません。

Auth0 APIの準備

Auth0のコンソールを開き、Applicationsメニュー→ApplicationsからCreate Applicationをクリックします。 名前を入力してMachine to Machine Applicationsを選択し、Createをクリックします。

Domain, Client ID, Client Secretをメモしておきます。

Webサイトの用意

AWS Secrets Manager

AWS Secrets Managerへ以下の項目を保存しておきます。auth0/passwordchangeという名前にしました。

  • jwt_secret: Auth0 Rulesからこのシステムへ送信されるJWTを暗号化するキー(32文字以上の乱数)
  • auth0_client_id: 上記Auth0 APIのClient ID
  • auth0_client_secret: 上記Auth0 APIのClient Secret

Zappa

システムはLambda+API Gatewayを使用し、Python+Zappa+Flaskで作っていきます。

requirements.txt

boto3==1.24.66
Flask==2.2.2
PyJWT==2.4.0
requests==2.28.1
zappa==0.55.0

extra_permissions.ResourceにSecrets ManagerのARNを設定します。

zappa_settings.json

{
    "dev": {
        "app_function": "myapp.app",
        "aws_region": "ap-northeast-1",
        "profile_name": "default",
        "project_name": "authzerorulechgpw",
        "runtime": "python3.8",
        "s3_bucket": "zappa-XXXXXXX",
        "extra_permissions": [{
            "Effect": "Allow",
            "Action": ["secretsmanager:GetSecretValue"],
            "Resource": "arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:auth0/passwordchange"
        }]
    }
}

アプリケーション本体のコードです。AUTH0_URLを実際のテナントの名前に変更してください。 パスワードが入力されると、Auth0 APIへ新しいパスワードと共にユーザーのメタデータにset_own_password = trueをセットして送信します。

なお、後述のRulesでJWTの有効期限は10分間としており、これがパスワード変更ができる期間になっていますが、JWTに含まれるjti(uuid)をDynamoDB等に登録し、登録されていた場合は拒否する処理を追加するとリプレイ攻撃を防止することができます。

app.py

from flask import Flask, request, render_template, redirect, url_for
import jwt
import requests
import boto3
import json

app = Flask(__name__)

AUTH0_URL = "https://XXXXXXXX.us.auth0.com"
secret_name = "auth0/passwordchange"
region_name = "ap-northeast-1"

def get_secrets():
    global jwt_secret, client_id, client_secret
    session = boto3.session.Session()
    client = session.client(service_name='secretsmanager', region_name=region_name)
    get_secret_value_response = client.get_secret_value(SecretId=secret_name)
    secret = get_secret_value_response['SecretString']
    s = json.loads(secret)
    jwt_secret = s["jwt_secret"]
    client_id = s["auth0_client_id"]
    client_secret = s["auth0_client_secret"]

def get_auth0_mgmt_api_token():
    j = {"client_id":client_id,"client_secret":client_secret,"audience":f"{AUTH0_URL}/api/v2/", "grant_type":"client_credentials"}
    r = requests.post(f"{AUTH0_URL}/oauth/token", json=j)
    rj = r.json()
    return rj["access_token"]

def update_auth0_user(auth0_mgmt_api_token, user_id, json):
    headers = {'authorization': f"Bearer {auth0_mgmt_api_token}"}
    r = requests.patch(f"{AUTH0_URL}/api/v2/users/{user_id}", json=json, headers=headers)
    return r.status_code, r.json()

@app.route("/")
def index():
    return "OK"

@app.get("/form/<jwt_token>")
def form(jwt_token):
    state = request.args["state"]
    mes = request.args.get("mes", "")
    get_secrets()
    j = jwt.decode(jwt_token, jwt_secret, algorithms="HS256", options={"require": ["exp"]})
    email = j["email"]
    return render_template("form.html", state=state, jwt_token=jwt_token, email=email, mes=mes)

@app.post("/chgpwd")
def chgpwd():
    state = request.form["state"]
    password = request.form["password1"]
    jwt_token = request.form["jwt_token"]

    if password != request.form["password2"]:
        mes = "パスワードが一致しません"
        return redirect(url_for("form", jwt_token=jwt_token, state=state, mes=mes))
    
    get_secrets()
    try:
        j = jwt.decode(jwt_token, jwt_secret, algorithms="HS256", options={"require": ["exp"]})
    except jwt.exceptions.ExpiredSignatureError:
        mes = "トークンの有効期限が切れています。ログインしなおしてください。"
        return redirect(url_for("form", jwt_token=jwt_token, state=state, mes=mes))
    except Exception:
        mes = "トークンエラー"
        return redirect(url_for("form", jwt_token=jwt_token, state=state, mes=mes))
    
    user_id = j["sub"]
    user_json = {"password": password, "user_metadata": {"set_own_password": True}}
    auth0_mgmt_api_token = get_auth0_mgmt_api_token()
    status_code, r = update_auth0_user(auth0_mgmt_api_token, user_id, user_json)
    if status_code != 200:
        mes = r["message"]
        return redirect(url_for("form", jwt_token=jwt_token, state=state, mes=mes))
    return redirect(f"{AUTH0_URL}/continue?state={state}")

パスワード入力フォームのテンプレートです。

templates/form.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="https://unpkg.com/mvp.css">
    <title>パスワード変更フォーム</title>
</head>
<body>
<main>
<h1>新しいパスワードを入力してください</h1>
<p>{{ mes }}</p>
<form class="pure-form" action="{{ url_for('chgpwd') }}" method="post">
    <p>{{ email }} さんの新しいパスワード</p>
    <input type="password" name="password1" placeholder="パスワード">
    <input type="password" name="password2" placeholder="パスワード確認">
    <input type="hidden" name="jwt_token" value="{{ jwt_token }}">
    <input type="hidden" name="state" value="{{ state }}">
    <button type="submit">変更</button>
</form>
</main>
</body>
</html>

Auth0 Rules

ARulesを使うと、ログイン成功後に指定したWebサイトへリダイレクトすることが可能で、ユーザー情報等のトークンも付けることができます。 Auth0のコンソールからAuth Pipelineを選択し、RulesからCreateを選択し、下記コードを入力します。 入力が終わったら、Rulesの画面にあるSettingsからjwt_secretという名前でSecrets Managerに設定したjwt_secretと同じ名前を設定してください。 また、context.redirectに与えるurlにAPI Gatewayのアドレスに置き換えてください。

Auth0 Rules

function(user, context, callback) {
  const req = context.request;
  
  if (context.protocol && context.protocol === "redirect-callback") {
    return callback(null, user, context);
  }
  
  if("user_metadata" in user &&
     "set_own_password" in user.user_metadata && 
     user.user_metadata.set_own_password)
  {
    return callback(null, user, context);
  }
  
  const payload = {
    email: user.email
  };
  const options = {
    algorithm: "HS256",
    expiresIn: "10m",
    subject: user.user_id,
    issuer: "auth0",
    jwtid: uuid.v4()
  };
  const token = jwt.sign(payload, configuration.jwt_secret, options);
	context.redirect = {
      url: `https://XXXXXXXXXX.execute-api.ap-northeast-1.amazonaws.com/dev/form/${token}`
  };
  return callback(null, user, context);
}

おわりに

Auth0で初回ログイン時にパスワードを強制的に設定させる方法をご紹介しました。Rulesを使用すると、設定だけでは実現できない機能を追加することができるようになります。今後も便利な使い方があればご紹介したいと思います。