🔖

【Webサービス開発】ユーザ承認

2023/03/18に公開

概要

Webサービス開発において、重要な概念である「ユーザ承認」を解説する。ユーザ認証とは、ユーザが特定のアクションを実行する権利を保持するか、確認するプロセスである。例えば、あなたがバーやクラブへ行った際、身分証の提示を求められるだろう。身分証を提示することで、あなたが20歳以上でバーやクラブへ入る権利があることをスタッフは承認できる。同様に、Webサービスにおいて、あなたが管理者権限を持つユーザなのか、もしくは一般ユーザの権限しか持たないのか、アクセスするユーザに応じて適切な権限を付与する必要がある。

ユーザ毎の実行権限

下記図の通り、オンラインショッピングサイトを想定する。システムオーナーは、商品の投稿、削除、購入と全てのアクションが行える。マネジャーは投稿と購入のみ。顧客は購入のみ、といったように、ユーザの種類に応じて、実行できるアクションは異なる。

権限については、ユーザに応じて付与する場合もあれば、そのユーザに対し特定権限を付与する場合もある。

各ユーザがどのような権限を保持するか、ユーザテーブルで整理される。

ユーザ承認の流れ

認証の流れについて、ユーザがシステムログインした際に「実行権限」に関する情報を送る。この実行権限について、API ServerとDatabase間でユーザ認証がされた際に、実際にそのアクション権限を保持するか確認することで、ユーザ承認がされる。(下記図の場合、getとpostはできるがdelteはできないので、deleteに関するAPIリクエストが送られると、403エラーが返る。)

FEでのユーザ承認によるUX向上

BEによる最終的なユーザ承認は、間違いなく必要である。ただ、ユーザによるリクエスト過多を解消したい場合、FEによるユーザ承認ができる。FEでユーザ承認をすることでサーバやDBへの負荷を軽減することできて、またフロントエンドですぐにレスポンスが得られるので、UXを向上させられる。

実際に、どのようにFEのみでユーザ承認するか、簡単にハンズオンを行う。まずGoogle ChromeのDeveloperツールのconsoleを開いて、下記コードを貼る。こちらは、ユーザからリクエストされたトークンについて、どのような権限が付与されているか確認するためのコードである。

function parseJwt (token) {
    // https://stackoverflow.com/questions/38552003/how-to-decode-jwt-token-in-javascript
   var base64Url = token.split('.')[1];
   var base64 = decodeURIComponent(atob(base64Url).split('').map((c)=>{
       return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
   }).join(''));

   return JSON.parse(base64);
};

次に、実際のjwtトークンを貼る。

jwt = parseJwt('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Imp1aWNlcHJvIiwicGVybWlzc2lvbnMiOlsicG9zdDpqdWljZSJdfQ.7m6ukD61G--xjWGIJJNBRwVJkSrnKwfHOU5KrYEvLW8')

最後にjwtと打ち込むと、そのtokenの権限が表示される。jwtとしてtokenを保存することで、ユーザはFEであればいつでもそのtokenを元に、リクエストされたアクションに対して、適切な権限が付与されているか確認できる。下記に、一連の流れをスクショで添付する。

Auth0によるハンズオン

Auth0側の設定

実際に、権限つきのAPIをAuth0で作成する。まずはAPI画面で、APIを作成する。(*この場合はImage)

次にPermissionsタブで、get:imagesとpost:imagesという二つのAPIを作成する。

次にUsers & RolesのRolesタブで、新しいRoleを作成する。

ユーザの役割として、今回はPhotographerという役割を作成して、作成したAPIにgetとpostというpermissionsを割り当てる。



同様に、Clientという役割を作成して、作成したAPIにgetというpermissionsを割り当てる。

最後に、APIの設定でEnable RBACとAdd Permissions in the Access TokenをONにする。

上記設定後、アクセス時に取得したトークンをjwt.ioのdebuggerツールで検証する。そうすると、適切にpermissionsが付与されていることが確認できる。

コード作成

一部割愛するが、コードは以下の通り。
最初に、上記で作成した/imageというエンドポイントを定義している。このエンドポイントは、requires_auth(ユーザ承認するための関数)により、エンドポイントにアクセスする前にget:imagesという権限が必要であるので、その権限チェックをしている。権限が確認されれば、エンドポイントにアクセスできて、Access Grantedというメッセージが出力される。

@app.route('/image')
@requires_auth('get:images')
def image(jwt):
    print(jwt)
    return 'Access Granted'

次にrequires_authという関数にを説明する。ここでいうpermissionには、get:imagesが代入される。ユーザから送られたtokenはget_token_auth_headerにより取得される。またverify_decode_jwt関数により、取得したトークンを検証しペイロードを取得する。最後に、check_permissionsのpermissionの値に基づいて、ペイロードに含まれるユーザの認可レベルをチェックする。(*get:imagesに対する権限があるか確認)

def requires_auth(permission=''):
    print(permission)
    def requires_auth_decorator(f):
        @wraps(f)
        def wrapper(*args, **kwargs):
            token = get_token_auth_header()
            try:
                payload = verify_decode_jwt(token)
            except:
                abort(401)
                print("permission")
            check_permissions(permission, payload)
            return f(payload, *args, **kwargs)
        return wrapper
    return requires_auth_decorator

最後にcheck_permissionsを説明する。引数permissionとpayloadにより、認可レベルをチェックする。permissionsキーがpayloadに含まれていない場合、もしくはpayloadで許可しているpermission権限に、get:imagesが含まれていない場合はAuthErrorを発生させる。上記チェックをパスした場合は、Trueを返す。

def check_permissions(permission, payload):
    if 'permissions' not in payload:
                        raise AuthError({
                            'code': 'invalid_claims',
                            'description': 'Permissions not included in JWT.'
                        }, 400)

    if permission not in payload['permissions']:
        raise AuthError({
            'code': 'unauthorized',
            'description': 'Permission not found.'
        }, 403)
    return True

豆知識

下記に401 / 403エラーの違いを記載する。

  • 401 Unauthorized: アクセストークンが無効なとき、認証がされていないときに利用される。
  • 403 Forbidden: リクエストによる操作権限が無いときに利用される。(*一般ユーザが、管理者権限のアクションを実行する場合など)

全体コード

def check_permissions(permission, payload):
    if 'permissions' not in payload:
                        raise AuthError({
                            'code': 'invalid_claims',
                            'description': 'Permissions not included in JWT.'
                        }, 400)

    if permission not in payload['permissions']:
        raise AuthError({
            'code': 'unauthorized',
            'description': 'Permission not found.'
        }, 403)
    return True

def requires_auth(permission=''):
    print(permission)
    def requires_auth_decorator(f):
        @wraps(f)
        def wrapper(*args, **kwargs):
            token = get_token_auth_header()
            try:
                payload = verify_decode_jwt(token)
            except:
                abort(401)
                print("permission")
            check_permissions(permission, payload)
            return f(payload, *args, **kwargs)
        return wrapper
    return requires_auth_decorator


@app.route('/image')
@requires_auth('get:images')
def image(jwt):
    print(jwt)
    return 'Access Granted'

参考文献

https://learn.udacity.com/nanodegrees/nd0044

Discussion