Azure AD で OIDC 認証するアプリを作ってみる

## はじめに

Azure AD と自分のアプリの連携を考えたとき、以下のようなことが可能です。

* Azure AD を IdP とした OpenID Connect による SSO
* Azure AD 側のユーザーおよびグループを SCIM によってアプリ側に自動プロビジョニング

OpenID Connnect (OIDC) の情報は割と充実していますが、SCIM の情報は少ないと感じたので Azure AD での設定とアプリの実装例があればと思い記事を書き始めました。しかし、今回は 1 点目の OIDC で SSO できるようになるところまでを扱います。アプリは Django で実装し、認証フローは認証コード フローを使います。一応、自動プロビジョニングも見据えて設定していこうと思います。

では、やっていきましょう！ (今回のソースコードは [github](https://github.com/tomatsue/django-azuread-oidc) にあります。)

## 1. Django について

Django は Python の Web アプリケーション フレームワークです。とりあえず動かしてみるのが簡単なので使っています。以降の説明は Django の経験がなくても雰囲気を掴んでもらえることを期待していますが、他の MVC フレームワークの経験がある場合、本記事で登場する Django の用語に混乱するかもれしれないので下記についてご注意ください。

> Djangoでは一般的なWebフレームワークと同じくMVCパターンを採用しています。
> 参考：[MVCモデルについて](https://qiita.com/s_emoto/items/975cc38a3e0de462966a)
> ただし各要素の呼び方が異なっており、MTV(Model、View、Template)と呼ばれています。
> まぎらわしいですが、**DjangoのViewとは一般的なMVCのControllerにあたる** ということに注意しましょう。

引用元：[[Python] Djangoチュートリアル - 汎用業務Webアプリを最速で作る](https://qiita.com/okoppe8/items/54eb105c9c94c0960f14#djnago-%E3%81%AE-mvc%E3%83%91%E3%82%BF%E3%83%BC%E3%83%B3)


## 2. 認証コード フロー/Aurhorization Code Flow について

Web アプリで認証および認可を行なう一般的な方法の一つです。詳細については、以下の公開情報にまとめられています。今回も以下の記事に従って Microsoft 認証ライブラリ (MSAL) を使ってトークンを取得します。

ご参考：[Microsoft ID プラットフォームと OAuth 2.0 認証コード フロー](https://docs.microsoft.com/ja-jp/azure/active-directory/develop/v2-oauth2-auth-code-flow)

## 3. Azure AD にアプリを登録

Azure AD にアプリを登録する画面は「アプリの登録」と「エンタープライズ アプリケーション」の２つがありますが、自動プロビジョニングのためには「エンタープライズ アプリケーション」から登録する必要があります。Azure AD Premium P1 以上のライセンスが必要なので、なければ無料試用版を使いましょう。

3.1. **Azure ポータル > Azure Active Directory > エンタープライズ アプリケーション > 新しいアプリケーション >ギャラリー以外のアプリケーション**で、アプリを登録します。

3.2. **Azure ポータル > Azure Active Directory > アプリの登録 > すべてのアプリケーション**にて、上記で作成したアプリを見つけて開きます。

3.3. **概要** ページにある「アプリケーション ID」と「ディレクトリ ID」をメモっておきます。

3.4. **認証** ページからリダイレクト URI を追加します。

* 種類：WEB
* リダイレクト URI：http://localhost:8000/accounts/oidc/callback/

`accounts` は今回作るアプリの名前です。

3.5. **証明書とシークレット > 新しいクライアント シークレット**でクライアント シークレットを作成し、メモっておきます。

今回は簡単のためにクライアント シークレットを使っています。よりセキュアな方法として、証明書を利用することも可能です。

## 4. Django プロジェクト作成

次のコマンドで `mysite` プロジェクトを作成します。

```bash
django-admin startproject mysite
```

Template を置くフォルダを追加します。

```python:mysite/settings.py
TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR, 'templates')],  # 追加
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]
```

## 5. アプリケーション `accounts` を作成

次のコマンドで `mysite` プロジェクトの中に `accounts` というアプリを作成します。

```bash
python manage.py startapp accounts
```

### `urls.py` を作成する

```python:accounts/urls.py
from django.urls import include, path

from . import views

urlpatterns = [
    path('', views.index),
    path('login/', views.login_view, name='login'),
    path('logout/', views.logout_view, name='logout'),
    path('oidc/callback/', views.callback_view) 
]
```

`/accounts/oidc/callback` が手順 3.4. で設定したリダイレクト URI です。Azure AD で認証後、ユーザーはこの URI にリダイレクトされます。その際に渡される認証コードを `views.callback_view` で処理できるように実装する必要があります。

### カスタム ユーザー モデルを作成する

Azure AD で認証したユーザーが、このアプリにサインインできるようにします。そのためには、Azure AD のユーザーとアプリのユーザーを紐付ける必要があります。今回は、Azure AD 側の ObjectId とアプリの external_id が同一であるユーザーを紐付けることにします。

カスタム ユーザー モデルを作成し、external_id 属性を追加します。

```python:accounts/models.py
from django.db import models
from django.contrib.auth.models import AbstractUser

class User(AbstractUser):
    external_id = models.CharField(max_length=50, blank=True)
```

管理者用の画面でユーザーの external_id が表示されるように修正します。

```python:accounts/admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin

from .models import User


class UserAdmin(BaseUserAdmin):
    list_display = ['username', 'is_staff', 'is_active', 'external_id']


admin.site.register(User, UserAdmin)
```

カスタム ユーザー モデルを認証のユーザー モデルとします。

```python:mysite/settings.py
AUTH_USER_MODEL = 'accounts.User'  # 追加

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'accounts.apps.AccountsConfig',  # 追加
]
```

プロジェクトの URL に `accounts` を追加します。

```python:mysite/urls.py
from django.contrib import admin
from django.shortcuts import redirect  # 追加
from django.urls import include, path  # include 追加

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', lambda req: redirect('accounts/', permanent=False)),  # 追加
    path('accounts/', include('accounts.urls')),  # 追加
]
```

## 6. 認証コードフローを実装 

基本的には、Azure AD の認証エンドポイントへの URL を作成し、そこにユーザーをリダイレクトさせれば Azure AD での認証が実現できます。よく使う機能については、[MSAL](https://github.com/AzureAD/microsoft-authentication-library-for-python) というライブラリからも利用できます。
以下のコードは、[Flask + MSAL のサンプル](https://github.com/Azure-Samples/ms-identity-python-webapp/blob/master/app.py) をベースに実装しています。

6.1. 画面を追加します。

* [templates/base.html](https://github.com/tomatsue/django-azuread-oidc/blob/master/templates/base.html) 追加
* [templates/accounts/index.html](https://github.com/tomatsue/django-azuread-oidc/blob/master/templates/accounts/index.html) 追加
* [templates/accounts/login.html](https://github.com/tomatsue/django-azuread-oidc/blob/master/templates/accounts/login.html) 追加
* [templates/accounts/auth_error.html](https://github.com/tomatsue/django-azuread-oidc/blob/master/templates/accounts/auth_error.html) 追加

6.2. 認証および認可のための設定を追加します。

```python:mysite/settings.py
# Azure AD

SCOPES = ['User.Read']
TENANT_ID = '{3.3. のディレクトリ ID}'
AUTHORITY = f'https://login.microsoftonline.com/{TENANT_ID}/'
CLIENT_ID = '{3.3. のアプリケーション ID}'
REDIRECT_PATH = '{3.4. のリダイレクト URI}'
CLIENT_SECRET = '{3.5. のクライアント シークレット}'
ENDPOINT = 'https://graph.microsoft.com/beta/me'
```

ENDPOINT はサインインしたユーザーの権限で参照する API です。今回は[ユーザー情報の取得 API ](https://docs.microsoft.com/ja-jp/graph/api/user-get?view=graph-rest-beta&tabs=http)を実行するため、`SCOPES` に `User.Read` を追加しています。

6.3. View を実装します。

```python:accounts/views.py
import uuid
import msal
from django.shortcuts import redirect
from django.contrib.auth import login, logout
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from django.contrib.auth import get_user_model

from mysite2 import settings

User = get_user_model()


@login_required
def index(request):
    context = {'user': request.user}
    return render(request, 'accounts/index.html', context)


def logout_view(request):
    logout(request)
    return redirect('/')


def login_view(request):
    request.session['state'] = str(uuid.uuid4())
    auth_url = _build_auth_url(
        scopes=settings.SCOPES, state=request.session['state'])
    context = {'auth_url': auth_url}
    return render(request, 'accounts/login.html', context)


def callback_view(request):
    if request.GET.get('state') != request.session.get('state'):
        # 'state' がリクエスト時と一致しない
        return redirect('/')
    if 'error' in request.GET:
        # Azure AD が認証/認可エラーを返した
        return render(request, 'accounts/auth_error.html', request.GET)
    if 'code' in request.GET:
        cache = _load_cache(request)
        result = _build_msal_app(cache=cache).acquire_token_by_authorization_code(
            request.GET['code'],
            scopes=settings.SCOPES,  # Misspelled scope would cause an HTTP 400 error here
            redirect_uri=settings.REDIRECT_PATH)
        if 'error' in result:
            return render(request, 'accounts/auth_error.html', result)
        request.session['user'] = result.get('id_token_claims')

    try:
        # Azure AD 側の oid とアプリ側の external_id が一致するユーザーをいるか確認する
        oid = request.session['user']['oid']
        user = User.objects.get(external_id=oid)
        login(request, user)
    except User.DoesNotExist as e:
        context = {'error': 'User.DoesNotExist', 'error_description': str(e)}
        return render(request, 'accounts/auth_error.html', context)

    return redirect('/')


def _build_auth_url(authority=None, scopes=None, state=None):
    return _build_msal_app(authority=authority).get_authorization_request_url(
        scopes or [],
        state=state or str(uuid.uuid4()),
        redirect_uri=settings.REDIRECT_PATH)


def _build_msal_app(cache=None, authority=None):
    return msal.ConfidentialClientApplication(
        settings.CLIENT_ID, authority=settings.AUTHORITY,
        client_credential=settings.CLIENT_SECRET, token_cache=cache)


def _load_cache(request):
    cache = msal.SerializableTokenCache()
    if request.session.get('token_cache'):
        cache.deserialize(request.session['token_cache'])
    return cache


def _save_cache(request, cache):
    if cache.has_state_changed:
        request.session['token_cache'] = cache.serialize()
```

以下がポイントです。

* get_authorization_request_url()：Azure AD への認証リクエストを生成する。
* acquire_token_by_authorization_code()：認証コードを元にトークンを取得する。
* user = User.objects.get(external_id=oid)： id_token に含まれる oid (Azure AD 側の ObjectId) とアプリ側の external_id が一致するユーザーを見つける。
* _save_cache(), _load_cache() でトークンをキャッシュしており、余計なトークンの取得をしない。


## 7. アプリを動かす

```bash
# DB を作成する
python manage.py makemigrations
python manage.py migrate

# 管理者ユーザーを作成する
python manage.py createsuperuser

# サーバーを起動する
python manage.py runserver
```

ブラウザで `http://localhost:8000/` を開くとアプリにアクセスできます。`/admin` が管理コンソールです。

## 8. Azure AD で認証してアプリにサインインする

それでは Azure AD のユーザーでアプリにサインインしてみましょう。

まず、**Azure ポータル > Azure Active Directory > エンタープライズ アプリケーション > 該当アプリ > ユーザーとグループ** ページにてサインインするユーザーをアプリに割り当てておきます。

次に、今回は自動プロビジョニングを構成していないので、`python manage.py shell` で管理シェルを起動し、以下のように手動でアプリ側のユーザーを作成します。

```bash
from django.contrib.auth import get_user_model
User = get_user_model()
user = User.objects.create(username='{Azure AD のユーザーのユーザー名}', external_id='{Azure AD のユーザーの ObjectId}') 
user.save()
```

さて、上記ユーザーでアプリにアクセスしてログイン ボタンを押すと、Azure AD の認証画面が求められます。そこで資格情報を入力すると、以下のようにアプリのアクセス許可について同意が求められます。
この同意画面は初回アクセスのみ求められます。また、管理者がテナント単位で同意を与えることもでき、その場合は一般ユーザーには同意画面は表示されません。

![2019-12-24_07h04_10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/535167/b296331d-2972-40cb-9dc1-f87cd4829ea4.png)

ここで承諾すると、無事にサインインすることができました！

![2019-12-24_07h21_12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/535167/6ec779b3-36f1-4980-feb5-3a8af8583014.png)

また、アプリはサインインしたユーザーの権限で保護された API にアクセスすることができます。

![2019-12-24_07h21_58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/535167/d41d3bfa-a86a-a483-7dcb-342eee80f54c.png)

## おわりに
Azure AD のユーザーでアプリにサインインすることができました。

今回作ったカスタム ユーザーの externalId を持たせたのは認証のためと、[SCIM API](Azure Active Directory (Azure AD) での SCIM のユーザー プロビジョニング) を実装するためです。ただし、一つ一つ実装するのは結構大変なので SCIM API 用のライブラリがあるならそれを使ったほうが良いと思います。
今回の Django なら [django-scim2](https://pypi.org/project/django-scim2/)というライブラリがあるようですが、これはカスタム ユーザー モデルもライブラリ用に作り直す必要があるようです。SCIM API を使う場合は設計の段階から考える必要があるかもしれないですね。
自動プロビジョニングに対応したアプリについても今度作って見ようと思います！