【Webサービス開発】ユーザ承認
概要
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'
参考文献
Discussion