399 lines
12 KiB
TypeScript
399 lines
12 KiB
TypeScript
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}`,
|
||
);
|
||
}
|
||
}
|
||
}
|