import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import jwt from 'jsonwebtoken'; import jwkToPem from 'jwk-to-pem'; import { makeErrorResponse } from '../../common/error/makeErrorResponse'; import { sign } from '../../common/jwt'; import { getPrivateKey, getPublicKey, isVerifyError, verify, } from '../../common/jwt/jwt'; import { AccessToken, IDToken, JwkSignKey, RefreshToken, isIDToken, } from '../../common/token'; import { ADMIN_ROLES, TIERS, USER_ROLES } from '../../constants'; import { AdB2cService } from '../../gateways/adb2c/adb2c.service'; import { User } from '../../repositories/users/entity/user.entity'; import { UsersRepositoryService } from '../../repositories/users/users.repository.service'; import { Context } from '../../common/log'; @Injectable() export class AuthService { private readonly refreshTokenLifetimeWeb = this.configService.getOrThrow('REFRESH_TOKEN_LIFETIME_WEB'); private readonly refreshTokenLifetimeDefault = this.configService.getOrThrow('REFRESH_TOKEN_LIFETIME_DEFAULT'); private readonly accessTokenlifetime = this.configService.getOrThrow( 'ACCESS_TOKEN_LIFETIME_WEB', ); constructor( 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 { 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( context: Context, idToken: IDToken, type: string, ): Promise { this.logger.log( `[IN] [${context.trackingId}] ${this.generateRefreshToken.name}`, ); 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}`); this.logger.log( `[OUT] [${context.trackingId}] ${this.generateRefreshToken.name}`, ); 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}`, ); this.logger.log( `[OUT] [${context.trackingId}] ${this.generateRefreshToken.name}`, ); throw new HttpException( makeErrorResponse('E010206'), HttpStatus.INTERNAL_SERVER_ERROR, ); } // 要求された環境用トークンの寿命を決定 const refreshTokenLifetime = type === 'web' ? this.refreshTokenLifetimeWeb : this.refreshTokenLifetimeDefault; const privateKey = getPrivateKey(this.configService); // ユーザーのロールを設定 // 万一不正な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}`); this.logger.log( `[OUT] [${context.trackingId}] ${this.generateRefreshToken.name}`, ); throw new HttpException( makeErrorResponse('E010205'), HttpStatus.INTERNAL_SERVER_ERROR, ); } const token = sign( { //ユーザーの属しているアカウントの管理者にユーザーが設定されていれば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, ); this.logger.log( `[OUT] [${context.trackingId}] ${this.generateRefreshToken.name}`, ); return token; } /** * Generates access token * @param refreshToken リフレッシュトークン(jwt) * @returns access token(jwt) */ async generateAccessToken( context: Context, refreshToken: string, ): Promise { this.logger.log( `[IN] [${context.trackingId}] ${this.generateAccessToken.name}`, ); const privateKey = getPrivateKey(this.configService); const pubkey = getPublicKey(this.configService); const token = verify(refreshToken, pubkey); if (isVerifyError(token)) { this.logger.error(`${token.reason} | ${token.message}`); this.logger.log( `[OUT] [${context.trackingId}] ${this.generateAccessToken.name}`, ); throw new HttpException( makeErrorResponse('E000101'), HttpStatus.UNAUTHORIZED, ); } const accessToken = sign( { role: token.role, tier: token.tier, userId: token.userId, }, this.accessTokenlifetime, privateKey, ); this.logger.log( `[OUT] [${context.trackingId}] ${this.generateAccessToken.name}`, ); return accessToken; } /** * Gets id token * @param token * @returns id token */ async getVerifiedIdToken(token: string): Promise { this.logger.log(`[IN] ${this.getVerifiedIdToken.name}`); let kid: string | undefined = ''; try { // JWTトークンのヘッダを見るため一度デコードする const decodedToken = jwt.decode(token, { complete: true }); kid = decodedToken?.header.kid; if (!kid) { throw new Error('kid not found'); } } 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 = this.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}`); } } getPublicKeyFromJwk(jwkKey: JwkSignKey): string { try { // JWK形式のJSONなのでJWTの公開鍵として使えるようにPEM形式に変換 const publicKey = jwkToPem({ kty: 'RSA', n: jwkKey.n, e: jwkKey.e, }); return publicKey; } catch (e) { this.logger.error(`error=${e}`); throw e; } finally { this.logger.log(`[OUT] ${this.getPublicKeyFromJwk.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, ); }; /** * 同意済み利用規約バージョンが最新かチェック * @param idToken AzureAD B2Cにより発行されたIDトークン * @returns boolean */ async isAcceptedLatestVersion( context: Context, idToken: IDToken, ): Promise { this.logger.log( `[IN] [${context.trackingId}] ${this.isAcceptedLatestVersion.name} | params: { ` + `idToken.sub: ${idToken.sub}, };`, ); try { // DBからユーザーの同意済み利用規約バージョンと最新バージョンを取得 const { acceptedEulaVersion, latestEulaVersion, acceptedDpaVersion, latestDpaVersion, tier, } = await this.usersRepository.getAcceptedAndLatestVersion(idToken.sub); // 第五階層はEULAのみ判定 if (tier === TIERS.TIER5) { if (!acceptedEulaVersion) { return false; } // 最新バージョンに同意済みか判定 const eulaAccepted = acceptedEulaVersion === latestEulaVersion; return eulaAccepted; } else { // 第一~第四階層はEULA、DPAを判定 if (!acceptedEulaVersion || !acceptedDpaVersion) { return false; } // 最新バージョンに同意済みか判定 const eulaAccepted = acceptedEulaVersion === latestEulaVersion; const dpaAccepted = acceptedDpaVersion === latestDpaVersion; return eulaAccepted && dpaAccepted; } } catch (e) { this.logger.error(`[${context.trackingId}] error=${e}`); throw new HttpException( makeErrorResponse('E009999'), HttpStatus.INTERNAL_SERVER_ERROR, ); } finally { this.logger.log( `[OUT] [${context.trackingId}] ${this.isAcceptedLatestVersion.name}`, ); } } }