oura.a c844837aec Merged PR 514: Revert "Merge branch 'develop' into main"
Revert "Merge branch 'develop' into main"

Reverted commit `463b372c`.
2023-10-23 06:53:23 +00:00

399 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<number>('REFRESH_TOKEN_LIFETIME_WEB');
private readonly refreshTokenLifetimeDefault =
this.configService.getOrThrow<number>('REFRESH_TOKEN_LIFETIME_DEFAULT');
private readonly accessTokenlifetime = this.configService.getOrThrow<number>(
'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<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(
context: Context,
idToken: IDToken,
type: string,
): Promise<string> {
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<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,
);
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<string> {
this.logger.log(
`[IN] [${context.trackingId}] ${this.generateAccessToken.name}`,
);
const privateKey = getPrivateKey(this.configService);
const pubkey = getPublicKey(this.configService);
const token = verify<RefreshToken>(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<AccessToken>(
{
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<IDToken> {
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<boolean> {
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}`,
);
}
}
}