maruyama.t c4aaee07b1 Merged PR 137: アクセストークン内に階層情報を含める
## 概要
[Task1925: アクセストークン内に階層情報を含める](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/1925)

- アクセストークンとリフレッシュトークンにtierを追加

## レビューポイント
- tierのチェックは必要か
- DBの値をそのまま入れているが問題ないか

**以下は別タスクとして切り出す**
- RoleGuardsを拡張してtierもチェックできるようにする処理を追加し、階層ごとに許可される操作をI/Fの属性として宣言的にチェックできるように修正した箇所について、使いやすそうか。
例)
@UseGuards(RoleGuard.requireds({ roles: ['admin', 'author'] }))
の場合(階層の宣言はしていない場合)許可@UseGuards(RoleGuard.requireds({ roles: ['admin', 'author'], tier [2] }))の場合(階層の宣言をしている場合)ユーザのアカウントの階層を見て、2以上なら許可、2未満なら拒否
 

## UIの変更
- なし

## 動作確認状況
- ローカルで確認

## 補足
- https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/OMDSDictation/_wiki/wikis/OMDSDictation_wiki/202/%E3%82%A2%E3%82%AF%E3%82%BB%E3%82%B9%E3%83%88%E3%83%BC%E3%82%AF%E3%83%B3
wikiにアクセストークンとリフレッシュトークンについてのページを記載しました。
2023-06-08 08:28:10 +00:00

275 lines
8.7 KiB
TypeScript

import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import jwt from 'jsonwebtoken';
import { UsersRepositoryService } from '../../repositories/users/users.repository.service';
import { makeErrorResponse } from '../../common/error/makeErrorResponse';
import { isVerifyError, sign, verify } from '../../common/jwt';
import {
AccessToken,
IDToken,
isIDToken,
RefreshToken,
} from '../../common/token';
import { AdB2cService } from '../../gateways/adb2c/adb2c.service';
import { CryptoService } from '../../gateways/crypto/crypto.service';
import { User } from '../../repositories/users/entity/user.entity';
import { ADMIN_ROLES, USER_ROLES } from '../../constants';
@Injectable()
export class AuthService {
constructor(
private readonly cryptoService: CryptoService,
private readonly adB2cService: AdB2cService,
private readonly configService: ConfigService,
private readonly usersRepository: UsersRepositoryService,
) {}
private readonly logger = new Logger(AuthService.name);
/**
* Determines whether verified user is
* @param idToken AzureAD B2Cにより発行されたIDトークン
* @returns verified user
*/
async isVerifiedUser(idToken: IDToken): Promise<boolean> {
this.logger.log(`[IN] ${this.isVerifiedUser.name}`);
try {
// IDトークンのユーザーがDBに登録されていてメール認証が完了しているユーザーか検証
const user = await this.usersRepository.findVerifiedUser(idToken.sub);
return user !== undefined;
} catch (e) {
this.logger.error(`error=${e}`);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
} finally {
this.logger.log(`[OUT] ${this.isVerifiedUser.name}`);
}
}
/**
* Generates refresh token
* @param idToken AzureAD B2Cにより発行されたIDトークン
* @param type 環境を表す文字列(web/desktop/mobile)
* @returns refresh token
*/
async generateRefreshToken(idToken: IDToken, type: string): Promise<string> {
const lifetimeWeb = this.configService.get('REFRESH_TOKEN_LIFETIME_WEB');
const lifetimeDefault = this.configService.get(
'REFRESH_TOKEN_LIFETIME_DEFAULT',
);
let user: User;
// ユーザー情報とユーザーが属しているアカウント情報を取得
try {
user = await this.usersRepository.findUserByExternalId(idToken.sub);
if (!user.account) {
throw new Error('Account information not found');
}
} catch (e) {
this.logger.error(`error=${e}`);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
// Tierのチェック
const minTier = 1;
const maxTier = 5;
const userTier = user.account.tier;
if (userTier < minTier || userTier > maxTier) {
this.logger.error(
`Tier from DB is unexpected value. tier=${user.account.tier}`,
);
throw new HttpException(
makeErrorResponse('E010206'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
// 要求された環境用トークンの寿命を決定
const refreshTokenLifetime = type === 'web' ? lifetimeWeb : lifetimeDefault;
const privateKey = await this.cryptoService.getPrivateKey();
// ユーザーのロールを設定
// 万一不正なRoleが登録されていた場合、そのままDBの値を使用すると不正なロールのリフレッシュトークンが発行されるため、
// ロールの設定値はDBに保存したRoleの値を直接トークンに入れないように定数で設定する
// ※none/author/typist以外はロールに設定されない
let role = '';
if (user.role === USER_ROLES.NONE) {
role = USER_ROLES.NONE;
} else if (user.role === USER_ROLES.AUTHOR) {
role = USER_ROLES.AUTHOR;
} else if (user.role === USER_ROLES.TYPIST) {
role = USER_ROLES.TYPIST;
} else {
this.logger.error(`Role from DB is unexpected value. role=${user.role}`);
throw new HttpException(
makeErrorResponse('E010205'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const token = sign<RefreshToken>(
{
//ユーザーの属しているアカウントの管理者にユーザーが設定されていればadminをセットする
role: `${role} ${
user.account.primary_admin_user_id === user.id ||
user.account.secondary_admin_user_id === user.id
? ADMIN_ROLES.ADMIN
: ADMIN_ROLES.STANDARD
}`,
tier: user.account.tier,
userId: idToken.sub,
},
refreshTokenLifetime,
privateKey,
);
return token;
}
/**
* Generates access token
* @param refreshToken リフレッシュトークン(jwt)
* @returns access token(jwt)
*/
async generateAccessToken(refreshToken: string): Promise<string> {
const lifetime = this.configService.get('ACCESS_TOKEN_LIFETIME_WEB');
const privateKey = await this.cryptoService.getPrivateKey();
const pubkey = await this.cryptoService.getPublicKey();
const token = verify<RefreshToken>(refreshToken, pubkey);
if (isVerifyError(token)) {
throw new Error(`${token.reason} | ${token.message}`);
}
const accessToken = sign<AccessToken>(
{
role: token.role,
tier: token.tier,
userId: token.userId,
},
lifetime,
privateKey,
);
return accessToken;
}
/**
* Gets id token
* @param token
* @returns id token
*/
async getVerifiedIdToken(token: string): Promise<IDToken> {
this.logger.log(`[IN] ${this.getVerifiedIdToken.name}`);
let kid = '';
try {
// JWTトークンのヘッダを見るため一度デコードする
const decodedToken = jwt.decode(token, { complete: true });
kid = decodedToken.header.kid;
} catch (e) {
this.logger.error(`error=${e}`);
throw new HttpException(
makeErrorResponse('E000101'),
HttpStatus.UNAUTHORIZED,
);
}
let issuer = '';
try {
const metadata = await this.adB2cService.getMetaData();
const keySets = await this.adB2cService.getSignKeySets();
issuer = metadata.issuer;
const jwkKey = keySets.find((x) => x.kid === kid);
if (!jwkKey) {
throw new Error('Public Key Not Found.');
}
const publicKey = await this.cryptoService.getPublicKeyFromJwk(jwkKey);
const verifiedToken = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: [issuer],
});
if (!isIDToken(verifiedToken)) {
throw new Error('invalid format ID token');
}
return verifiedToken;
} catch (e) {
if (e instanceof Error) {
const { name, message } = e;
this.logger.error(`error=${name}: ${message}`);
switch (e.constructor) {
case jwt.TokenExpiredError:
throw new HttpException(
makeErrorResponse('E000102'),
HttpStatus.UNAUTHORIZED,
);
case jwt.NotBeforeError:
throw new HttpException(
makeErrorResponse('E000103'),
HttpStatus.UNAUTHORIZED,
);
case jwt.JsonWebTokenError:
// メッセージごとにエラーを判定しHTTPエラーを生成
throw this.makeHttpErrorFromJsonWebTokenErrorMessage(
message,
issuer,
);
default:
break;
}
} else {
this.logger.error(`error=${e}`);
}
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
} finally {
this.logger.log(`[OUT] ${this.getVerifiedIdToken.name}`);
}
}
/**
* JWT検証時のError、JsonWebTokenErrorをメッセージごとに仕分けてHTTPエラーを生成
*/
makeHttpErrorFromJsonWebTokenErrorMessage = (
message: string,
issuer: string,
): Error => {
// 署名が不正
if (message === 'invalid signature') {
return new HttpException(
makeErrorResponse('E000104'),
HttpStatus.UNAUTHORIZED,
);
}
// 想定発行元と異なる
if (message === `jwt issuer invalid. expected: ${issuer}`) {
return new HttpException(
makeErrorResponse('E000105'),
HttpStatus.UNAUTHORIZED,
);
}
// アルゴリズムが想定と異なる
if (message === 'invalid algorithm') {
return new HttpException(
makeErrorResponse('E000106'),
HttpStatus.UNAUTHORIZED,
);
}
// トークンの形式が不正
return new HttpException(
makeErrorResponse('E000101'),
HttpStatus.UNAUTHORIZED,
);
};
}