Merged PR 786: Azure Functions実装(一括登録)

## 概要
[Task3756: Azure Functions実装(一括登録)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/3756)

- ユーザー一括登録用のAzure Functionを実装しました。

## レビューポイント
- 処理の流れがラフスケッチと認識通りでしょうか?
- JSONファイルの内容はイメージ通りでしょうか?

## UIの変更
- なし

## 動作確認状況
- ローカルで確認
  - テスト実行

実際の詳細な動作についてはdevelop環境で確認します。
This commit is contained in:
makabe.t 2024-03-06 01:48:02 +00:00
parent 31de71f743
commit d6a47932e7
32 changed files with 17441 additions and 15 deletions

View File

@ -0,0 +1,2 @@
npx openapi-generator-cli version-manager set 7.1.0
npx openapi-generator-cli generate -g typescript-axios -i /app/dictation_function/src/api/odms/openapi.json -o /app/dictation_function/src/api/

View File

@ -0,0 +1,7 @@
{
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "7.1.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -9,11 +9,13 @@
"clean": "rimraf dist",
"prestart": "npm run clean && npm run build",
"start": "func start",
"test": "jest"
"test": "jest",
"codegen": "sh codegen.sh"
},
"dependencies": {
"@azure/functions": "^4.0.0",
"@azure/identity": "^3.1.3",
"@azure/storage-blob": "^12.17.0",
"@microsoft/microsoft-graph-client": "^3.0.5",
"@sendgrid/mail": "^7.7.0",
"dotenv": "^16.0.3",
@ -22,11 +24,13 @@
"typeorm": "^0.3.10"
},
"devDependencies": {
"@openapitools/openapi-generator-cli": "^2.9.0",
"@types/jest": "^27.5.0",
"@types/node": "18.x",
"@types/redis": "^2.8.13",
"@types/redis-mock": "^0.17.3",
"azure-functions-core-tools": "^4.x",
"base64url": "^3.0.1",
"jest": "^28.0.3",
"redis-mock": "^0.56.3",
"rimraf": "^5.0.0",

View File

@ -1,5 +1,5 @@
export type AdB2cResponse = {
'@odata.context': string;
"@odata.context": string;
value: AdB2cUser[];
};
export type AdB2cUser = {

4
dictation_function/src/api/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
wwwroot/*.js
node_modules
typings
dist

View File

@ -0,0 +1 @@
# empty npmignore to ensure all required files (e.g., in the dist folder) are published by npm

View File

@ -0,0 +1,23 @@
# OpenAPI Generator Ignore
# Generated by openapi-generator https://github.com/openapitools/openapi-generator
# Use this file to prevent files from being overwritten by the generator.
# The patterns follow closely to .gitignore or .dockerignore.
# As an example, the C# client generator defines ApiClient.cs.
# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
#ApiClient.cs
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
#foo/*/qux
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
#foo/**/qux
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
# You can also negate patterns with an exclamation (!).
# For example, you can ignore all files in a docs folder with the file extension .md:
#docs/*.md
# Then explicitly reverse the ignore rule for a single file:
#!docs/README.md

View File

@ -0,0 +1,8 @@
.gitignore
.npmignore
api.ts
base.ts
common.ts
configuration.ts
git_push.sh
index.ts

View File

@ -0,0 +1 @@
7.1.0

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,86 @@
/* tslint:disable */
/* eslint-disable */
/**
* ODMSOpenAPI
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 1.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from './configuration';
// Some imports not used depending on template conditions
// @ts-ignore
import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from 'axios';
import globalAxios from 'axios';
export const BASE_PATH = "http://localhost".replace(/\/+$/, "");
/**
*
* @export
*/
export const COLLECTION_FORMATS = {
csv: ",",
ssv: " ",
tsv: "\t",
pipes: "|",
};
/**
*
* @export
* @interface RequestArgs
*/
export interface RequestArgs {
url: string;
options: AxiosRequestConfig;
}
/**
*
* @export
* @class BaseAPI
*/
export class BaseAPI {
protected configuration: Configuration | undefined;
constructor(configuration?: Configuration, protected basePath: string = BASE_PATH, protected axios: AxiosInstance = globalAxios) {
if (configuration) {
this.configuration = configuration;
this.basePath = configuration.basePath ?? basePath;
}
}
};
/**
*
* @export
* @class RequiredError
* @extends {Error}
*/
export class RequiredError extends Error {
constructor(public field: string, msg?: string) {
super(msg);
this.name = "RequiredError"
}
}
interface ServerMap {
[key: string]: {
url: string,
description: string,
}[];
}
/**
*
* @export
*/
export const operationServerMap: ServerMap = {
}

View File

@ -0,0 +1,150 @@
/* tslint:disable */
/* eslint-disable */
/**
* ODMSOpenAPI
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 1.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from "./configuration";
import type { RequestArgs } from "./base";
import type { AxiosInstance, AxiosResponse } from 'axios';
import { RequiredError } from "./base";
/**
*
* @export
*/
export const DUMMY_BASE_URL = 'https://example.com'
/**
*
* @throws {RequiredError}
* @export
*/
export const assertParamExists = function (functionName: string, paramName: string, paramValue: unknown) {
if (paramValue === null || paramValue === undefined) {
throw new RequiredError(paramName, `Required parameter ${paramName} was null or undefined when calling ${functionName}.`);
}
}
/**
*
* @export
*/
export const setApiKeyToObject = async function (object: any, keyParamName: string, configuration?: Configuration) {
if (configuration && configuration.apiKey) {
const localVarApiKeyValue = typeof configuration.apiKey === 'function'
? await configuration.apiKey(keyParamName)
: await configuration.apiKey;
object[keyParamName] = localVarApiKeyValue;
}
}
/**
*
* @export
*/
export const setBasicAuthToObject = function (object: any, configuration?: Configuration) {
if (configuration && (configuration.username || configuration.password)) {
object["auth"] = { username: configuration.username, password: configuration.password };
}
}
/**
*
* @export
*/
export const setBearerAuthToObject = async function (object: any, configuration?: Configuration) {
if (configuration && configuration.accessToken) {
const accessToken = typeof configuration.accessToken === 'function'
? await configuration.accessToken()
: await configuration.accessToken;
object["Authorization"] = "Bearer " + accessToken;
}
}
/**
*
* @export
*/
export const setOAuthToObject = async function (object: any, name: string, scopes: string[], configuration?: Configuration) {
if (configuration && configuration.accessToken) {
const localVarAccessTokenValue = typeof configuration.accessToken === 'function'
? await configuration.accessToken(name, scopes)
: await configuration.accessToken;
object["Authorization"] = "Bearer " + localVarAccessTokenValue;
}
}
function setFlattenedQueryParams(urlSearchParams: URLSearchParams, parameter: any, key: string = ""): void {
if (parameter == null) return;
if (typeof parameter === "object") {
if (Array.isArray(parameter)) {
(parameter as any[]).forEach(item => setFlattenedQueryParams(urlSearchParams, item, key));
}
else {
Object.keys(parameter).forEach(currentKey =>
setFlattenedQueryParams(urlSearchParams, parameter[currentKey], `${key}${key !== '' ? '.' : ''}${currentKey}`)
);
}
}
else {
if (urlSearchParams.has(key)) {
urlSearchParams.append(key, parameter);
}
else {
urlSearchParams.set(key, parameter);
}
}
}
/**
*
* @export
*/
export const setSearchParams = function (url: URL, ...objects: any[]) {
const searchParams = new URLSearchParams(url.search);
setFlattenedQueryParams(searchParams, objects);
url.search = searchParams.toString();
}
/**
*
* @export
*/
export const serializeDataIfNeeded = function (value: any, requestOptions: any, configuration?: Configuration) {
const nonString = typeof value !== 'string';
const needsSerialization = nonString && configuration && configuration.isJsonMime
? configuration.isJsonMime(requestOptions.headers['Content-Type'])
: nonString;
return needsSerialization
? JSON.stringify(value !== undefined ? value : {})
: (value || "");
}
/**
*
* @export
*/
export const toPathString = function (url: URL) {
return url.pathname + url.search + url.hash
}
/**
*
* @export
*/
export const createRequestFunction = function (axiosArgs: RequestArgs, globalAxios: AxiosInstance, BASE_PATH: string, configuration?: Configuration) {
return <T = unknown, R = AxiosResponse<T>>(axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => {
const axiosRequestArgs = {...axiosArgs.options, url: (configuration?.basePath || axios.defaults.baseURL || basePath) + axiosArgs.url};
return axios.request<T, R>(axiosRequestArgs);
};
}

View File

@ -0,0 +1,110 @@
/* tslint:disable */
/* eslint-disable */
/**
* ODMSOpenAPI
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 1.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface ConfigurationParameters {
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
username?: string;
password?: string;
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
basePath?: string;
serverIndex?: number;
baseOptions?: any;
formDataCtor?: new () => any;
}
export class Configuration {
/**
* parameter for apiKey security
* @param name security name
* @memberof Configuration
*/
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
/**
* parameter for basic security
*
* @type {string}
* @memberof Configuration
*/
username?: string;
/**
* parameter for basic security
*
* @type {string}
* @memberof Configuration
*/
password?: string;
/**
* parameter for oauth2 security
* @param name security name
* @param scopes oauth2 scope
* @memberof Configuration
*/
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
/**
* override base path
*
* @type {string}
* @memberof Configuration
*/
basePath?: string;
/**
* override server index
*
* @type {number}
* @memberof Configuration
*/
serverIndex?: number;
/**
* base options for axios calls
*
* @type {any}
* @memberof Configuration
*/
baseOptions?: any;
/**
* The FormData constructor that will be used to create multipart form data
* requests. You can inject this here so that execution environments that
* do not support the FormData class can still run the generated client.
*
* @type {new () => FormData}
*/
formDataCtor?: new () => any;
constructor(param: ConfigurationParameters = {}) {
this.apiKey = param.apiKey;
this.username = param.username;
this.password = param.password;
this.accessToken = param.accessToken;
this.basePath = param.basePath;
this.serverIndex = param.serverIndex;
this.baseOptions = param.baseOptions;
this.formDataCtor = param.formDataCtor;
}
/**
* Check if the given MIME is a JSON MIME.
* JSON MIME examples:
* application/json
* application/json; charset=UTF8
* APPLICATION/JSON
* application/vnd.company+json
* @param mime - MIME (Multipurpose Internet Mail Extensions)
* @return True if the given MIME is JSON, false otherwise.
*/
public isJsonMime(mime: string): boolean {
const jsonMime: RegExp = new RegExp('^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$', 'i');
return mime !== null && (jsonMime.test(mime) || mime.toLowerCase() === 'application/json-patch+json');
}
}

View File

@ -0,0 +1,57 @@
#!/bin/sh
# ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/
#
# Usage example: /bin/sh ./git_push.sh wing328 openapi-petstore-perl "minor update" "gitlab.com"
git_user_id=$1
git_repo_id=$2
release_note=$3
git_host=$4
if [ "$git_host" = "" ]; then
git_host="github.com"
echo "[INFO] No command line input provided. Set \$git_host to $git_host"
fi
if [ "$git_user_id" = "" ]; then
git_user_id="GIT_USER_ID"
echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id"
fi
if [ "$git_repo_id" = "" ]; then
git_repo_id="GIT_REPO_ID"
echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id"
fi
if [ "$release_note" = "" ]; then
release_note="Minor update"
echo "[INFO] No command line input provided. Set \$release_note to $release_note"
fi
# Initialize the local directory as a Git repository
git init
# Adds the files in the local repository and stages them for commit.
git add .
# Commits the tracked changes and prepares them to be pushed to a remote repository.
git commit -m "$release_note"
# Sets the new remote
git_remote=$(git remote)
if [ "$git_remote" = "" ]; then # git remote not defined
if [ "$GIT_TOKEN" = "" ]; then
echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment."
git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git
else
git remote add origin https://${git_user_id}:"${GIT_TOKEN}"@${git_host}/${git_user_id}/${git_repo_id}.git
fi
fi
git pull origin master
# Pushes (Forces) the changes in the local repository up to the remote repository
echo "Git pushing to https://${git_host}/${git_user_id}/${git_repo_id}.git"
git push origin master 2>&1 | grep -v 'To https'

View File

@ -0,0 +1,18 @@
/* tslint:disable */
/* eslint-disable */
/**
* ODMSOpenAPI
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 1.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export * from "./api";
export * from "./configuration";

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,184 @@
import {
BlobServiceClient,
StorageSharedKeyCredential,
} from "@azure/storage-blob";
import { IMPORT_USERS_CONTAINER_NAME } from "../constants";
import { InvocationContext } from "@azure/functions";
export class BlobstorageService {
private readonly blobServiceClient: BlobServiceClient;
private readonly sharedKeyCredential: StorageSharedKeyCredential;
constructor() {
if (
!process.env.STORAGE_ACCOUNT_NAME_IMPORT ||
!process.env.STORAGE_ACCOUNT_KEY_IMPORT ||
!process.env.STORAGE_ACCOUNT_ENDPOINT_IMPORT
) {
throw new Error("Storage account information is missing");
}
this.sharedKeyCredential = new StorageSharedKeyCredential(
process.env.STORAGE_ACCOUNT_NAME_IMPORT,
process.env.STORAGE_ACCOUNT_KEY_IMPORT
);
this.blobServiceClient = new BlobServiceClient(
process.env.STORAGE_ACCOUNT_ENDPOINT_IMPORT,
this.sharedKeyCredential
);
}
/**
* Lists blobs
* @returns blobs
*/
async listBlobs(context: InvocationContext): Promise<string[]> {
context.log(`[IN] ${this.listBlobs.name}`);
try {
const containerClient = this.blobServiceClient.getContainerClient(
IMPORT_USERS_CONTAINER_NAME
);
const blobNames: string[] = [];
// stage.json以外のファイルを取得
for await (const blob of containerClient.listBlobsFlat({
prefix: "U_",
})) {
blobNames.push(blob.name);
}
return blobNames;
} catch (error) {
context.error(error);
throw error;
} finally {
context.log(`[OUT] ${this.listBlobs.name}`);
}
}
/**
* Downloads a blob
* @param context
* @param filename
* @returns file buffer
*/
public async downloadFileData(
context: InvocationContext,
filename: string
): Promise<string | undefined> {
context.log(
`[IN] ${this.downloadFileData.name} | params: { filename: ${filename} }`
);
try {
const containerClient = this.blobServiceClient.getContainerClient(
IMPORT_USERS_CONTAINER_NAME
);
const blobClient = containerClient.getBlobClient(filename);
try {
const downloadBlockBlobResponse = await blobClient.downloadToBuffer();
return downloadBlockBlobResponse.toString();
} catch (error) {
// ファイルが存在しない場合はundefinedを返す
if (error?.statusCode === 404) {
return undefined;
}
throw error;
}
} catch (error) {
context.error(error);
throw error;
} finally {
context.log(`[OUT] ${this.downloadFileData.name}`);
}
}
/**
* Updates file
* @param context
* @param filename
* @param data
* @returns file
*/
public async updateFile(
context: InvocationContext,
filename: string,
data: string
): Promise<boolean> {
context.log(
`[IN] ${this.updateFile.name} | params: { filename: ${filename} }`
);
try {
const containerClient = this.blobServiceClient.getContainerClient(
IMPORT_USERS_CONTAINER_NAME
);
const { response } = await containerClient.uploadBlockBlob(
filename,
data,
data.length
);
if (response.errorCode) {
context.log(`update failed. response errorCode: ${response.errorCode}`);
return false;
}
return true;
} catch (error) {
context.error(error);
throw error;
} finally {
context.log(`[OUT] ${this.updateFile.name}`);
}
}
/**
* Deletes file
* @param context
* @param filename
* @returns file
*/
public async deleteFile(
context: InvocationContext,
filename: string
): Promise<void> {
context.log(
`[IN] ${this.deleteFile.name} | params: { filename: ${filename} }`
);
try {
const containerClient = this.blobServiceClient.getContainerClient(
IMPORT_USERS_CONTAINER_NAME
);
const blobClient = containerClient.getBlobClient(filename);
await blobClient.deleteIfExists();
} catch (error) {
context.error(error);
throw error;
} finally {
context.log(`[OUT] ${this.deleteFile.name}`);
}
}
/**
* Determines whether file exists is
* @param context
* @param filename
* @returns file exists
*/
public async isFileExists(
context: InvocationContext,
filename: string
): Promise<boolean> {
context.log(
`[IN] ${this.isFileExists.name} | params: { filename: ${filename} }`
);
try {
const containerClient = this.blobServiceClient.getContainerClient(
IMPORT_USERS_CONTAINER_NAME
);
const blobClient = containerClient.getBlobClient(filename);
return await blobClient.exists();
} catch (error) {
context.error(error);
throw error;
} finally {
context.log(`[OUT] ${this.isFileExists.name}`);
}
}
}

View File

@ -0,0 +1,155 @@
import { IMPORT_USERS_STAGES } from "../../constants";
import { ErrorRow, ImportData, ImportJson, StageJson } from "./types";
const isErrorRow = (obj: any): obj is ErrorRow => {
if (typeof obj !== "object") return false;
const errorRow = obj as ErrorRow;
if (errorRow.name === undefined || typeof errorRow.name !== "string") {
return false;
}
if (errorRow.row === undefined || typeof errorRow.row !== "number") {
return false;
}
if (errorRow.error === undefined || typeof errorRow.error !== "string") {
return false;
}
return true;
};
export const isStageJson = (obj: any): obj is StageJson => {
if (typeof obj !== "object") return false;
const stageJson = obj as StageJson;
if (
stageJson.filename !== undefined &&
typeof stageJson.filename !== "string"
) {
return false;
}
if (stageJson.update === undefined || typeof stageJson.update !== "number") {
return false;
}
if (stageJson.row !== undefined && typeof stageJson.row !== "number") {
return false;
}
if (
stageJson.errors !== undefined &&
(!Array.isArray(stageJson.errors) ||
!stageJson.errors.every((x) => isErrorRow(x)))
) {
return false;
}
if (
stageJson.state === undefined ||
!Object.values(IMPORT_USERS_STAGES).includes(stageJson.state)
) {
return false;
}
return true;
};
const isImportData = (obj: any): obj is ImportData => {
if (typeof obj !== "object") return false;
const importData = obj as ImportData;
if (importData.name === undefined || typeof importData.name !== "string") {
return false;
}
if (importData.email === undefined || typeof importData.email !== "string") {
return false;
}
if (importData.role === undefined || typeof importData.role !== "number") {
return false;
}
if (
importData.author_id !== undefined &&
typeof importData.author_id !== "string"
) {
return false;
}
if (
importData.auto_renew === undefined ||
typeof importData.auto_renew !== "number"
) {
return false;
}
if (
importData.notification === undefined ||
typeof importData.notification !== "number"
) {
return false;
}
if (
importData.encryption !== undefined &&
typeof importData.encryption !== "number"
) {
return false;
}
if (
importData.encryption_password !== undefined &&
typeof importData.encryption_password !== "string"
) {
return false;
}
if (
importData.prompt !== undefined &&
typeof importData.prompt !== "number"
) {
return false;
}
return true;
};
export const isImportJson = (obj: any): obj is ImportJson => {
if (typeof obj !== "object") return false;
const importJson = obj as ImportJson;
if (
importJson.account_id === undefined ||
typeof importJson.account_id !== "number"
) {
return false;
}
if (
importJson.user_id === undefined ||
typeof importJson.user_id !== "number"
) {
return false;
}
if (
importJson.user_role === undefined ||
typeof importJson.user_role !== "string"
) {
return false;
}
if (
importJson.external_id === undefined ||
typeof importJson.external_id !== "string"
) {
return false;
}
if (
importJson.delegation_account_id !== undefined &&
typeof importJson.delegation_account_id !== "number"
) {
return false;
}
if (
importJson.delegation_user_id !== undefined &&
typeof importJson.delegation_user_id !== "number"
) {
return false;
}
if (
importJson.file_name === undefined ||
typeof importJson.file_name !== "string"
) {
return false;
}
if (
importJson.data === undefined ||
!Array.isArray(importJson.data) ||
!importJson.data.every((x) => isImportData(x))
) {
return false;
}
return true;
};

View File

@ -0,0 +1,56 @@
import { IMPORT_USERS_STAGES, USER_ROLES } from "../../constants";
export type StageType =
(typeof IMPORT_USERS_STAGES)[keyof typeof IMPORT_USERS_STAGES];
export type StageJson = {
filename?: string | undefined;
update: number;
row?: number | undefined;
errors?: ErrorRow[] | undefined;
state: StageType;
};
export type ErrorRow = {
name: string;
row: number;
error: string;
};
export type ImportJson = {
account_id: number;
user_id: number;
user_role: RoleType;
external_id: string;
delegation_account_id?: number | undefined;
delegation_user_id?: number | undefined;
file_name: string;
date: number;
data: ImportData[];
};
export type ImportData = {
name: string;
email: string;
role: number;
author_id?: string | undefined;
auto_renew: number;
notification: number;
encryption?: number | undefined;
encryption_password?: string | undefined;
prompt?: number | undefined;
};
export type RoleType = (typeof USER_ROLES)[keyof typeof USER_ROLES];
export type User = {
name: string;
role: RoleType;
email: string;
autoRenew: boolean;
notification: boolean;
authorId?: string | undefined;
encryption?: boolean | undefined;
encryptionPassword?: string | undefined;
prompt?: boolean | undefined;
};

View File

@ -0,0 +1,79 @@
/*
E+6
- 1~2...
- 3~4DB...
- 5~6
ex)
E00XXXX : システムエラーDB接続失敗など
E01XXXX : 業務エラー
EXX00XX : 内部エラー
EXX01XX : トークンエラー
EXX02XX : DBエラーDB関連
EXX03XX : ADB2CエラーDB関連
*/
export const errorCodes = [
"E009999", // 汎用エラー
"E000101", // トークン形式不正エラー
"E000102", // トークン有効期限切れエラー
"E000103", // トークン非アクティブエラー
"E000104", // トークン署名エラー
"E000105", // トークン発行元エラー
"E000106", // トークンアルゴリズムエラー
"E000107", // トークン不足エラー
"E000108", // トークン権限エラー
"E000301", // ADB2Cへのリクエスト上限超過エラー
"E010001", // パラメータ形式不正エラー
"E010201", // 未認証ユーザエラー
"E010202", // 認証済ユーザエラー
"E010203", // 管理ユーザ権限エラー
"E010204", // ユーザ不在エラー
"E010205", // DBのRoleが想定外の値エラー
"E010206", // DBのTierが想定外の値エラー
"E010207", // ユーザーのRole変更不可エラー
"E010208", // ユーザーの暗号化パスワード不足エラー
"E010209", // ユーザーの同意済み利用規約バージョンが最新でないエラー
"E010301", // メールアドレス登録済みエラー
"E010302", // authorId重複エラー
"E010401", // PONumber重複エラー
"E010501", // アカウント不在エラー
"E010502", // アカウント情報変更不可エラー
"E010503", // 代行操作不許可エラー
"E010601", // タスク変更不可エラー(タスクが変更できる状態でない、またはタスクが存在しない)
"E010602", // タスク変更権限不足エラー
"E010603", // タスク不在エラー
"E010701", // Blobファイル不在エラー
"E010801", // ライセンス不在エラー
"E010802", // ライセンス取り込み済みエラー
"E010803", // ライセンス発行済みエラー
"E010804", // ライセンス数不足エラー
"E010805", // ライセンス有効期限切れエラー
"E010806", // ライセンス割り当て不可エラー
"E010807", // ライセンス割り当て解除不可エラー
"E010808", // ライセンス注文キャンセル不可エラー
"E010809", // ライセンス発行キャンセル不可エラー(ステータスが変えられている場合)
"E010810", // ライセンス発行キャンセル不可エラー(発行から一定期間経過した場合)
"E010811", // ライセンス発行キャンセル不可エラー(発行したライセンスが割り当てされている場合)
"E010908", // タイピストグループ不在エラー
"E010909", // タイピストグループ名重複エラー
"E011001", // ワークタイプ重複エラー
"E011002", // ワークタイプ登録上限超過エラー
"E011003", // ワークタイプ不在エラー
"E011004", // ワークタイプ使用中エラー
"E013001", // ワークフローのAuthorIDとWorktypeIDのペア重複エラー
"E013002", // ワークフロー不在エラー
"E014001", // ユーザー削除エラー(削除しようとしたユーザーがすでに削除済みだった)
"E014002", // ユーザー削除エラー(削除しようとしたユーザーが管理者だった)
"E014003", // ユーザー削除エラー削除しようとしたAuthorのAuthorIDがWorkflowに指定されていた
"E014004", // ユーザー削除エラー削除しようとしたTypistがWorkflowのTypist候補として指定されていた
"E014005", // ユーザー削除エラー削除しようとしたTypistがUserGroupに所属していた
"E014006", // ユーザー削除エラー(削除しようとしたユーザが所有者の未完了のタスクが残っている)
"E014007", // ユーザー削除エラー(削除しようとしたユーザーが有効なライセンスを持っていた)
"E014009", // ユーザー削除エラー削除しようとしたTypistが未完了のタスクのルーティングに設定されている
"E015001", // タイピストグループ削除済みエラー
"E015002", // タイピストグループがワークフローに紐づいているエラー
"E015003", // タイピストグループがルーティングされているエラー
"E016001", // テンプレートファイル削除エラー(削除しようとしたテンプレートファイルがすでに削除済みだった)
"E016002", // テンプレートファイル削除エラー削除しようとしたテンプレートファイルがWorkflowに指定されていた
"E016003", // テンプレートファイル削除エラー(削除しようとしたテンプレートファイルが未完了のタスクに紐づいていた)
] as const;

View File

@ -0,0 +1,3 @@
export * from "./code";
export * from "./types";
export * from "./utils";

View File

@ -0,0 +1,9 @@
import { errorCodes } from "./code";
export type ErrorObject = {
message: string;
code: ErrorCodeType;
statusCode?: number;
};
export type ErrorCodeType = typeof errorCodes[number];

View File

@ -0,0 +1,101 @@
import { AxiosError } from "axios";
import { isError } from "lodash";
import { ErrorResponse } from "../../api";
import { errorCodes } from "./code";
import { ErrorCodeType, ErrorObject } from "./types";
export const createErrorObject = (error: unknown): ErrorObject => {
// 最低限通常のエラーかを判定
// Error以外のものがthrowされた場合
// 基本的にないはずだがプログラム上あるので拾う
if (!isError(error)) {
return {
message: "not error type.",
code: "E009999",
};
}
// Axiosエラー 通信してのエラーであるかを判定
if (!isAxiosError(error)) {
return {
message: "not axios error.",
code: "E009999",
};
}
const errorResponse = error.response;
if (!errorResponse) {
return {
message: error.message,
code: "E009999",
statusCode: errorResponse,
};
}
const { data } = errorResponse;
// 想定しているエラーレスポンスの型か判定
if (!isErrorResponse(data)) {
return {
message: error.message,
code: "E009999",
statusCode: errorResponse.status,
};
}
const { message, code } = data;
// 想定しているエラーコードかを判定
if (!isErrorCode(code)) {
return {
message,
code: "E009999",
statusCode: errorResponse.status,
};
}
return {
message,
code,
statusCode: errorResponse.status,
};
};
const isAxiosError = (e: unknown): e is AxiosError => {
const error = e as AxiosError;
return error?.isAxiosError ?? false;
};
const isErrorResponse = (error: unknown): error is ErrorResponse => {
const errorResponse = error as ErrorResponse;
if (
errorResponse === undefined ||
errorResponse.message === undefined ||
errorResponse.code === undefined
) {
return false;
}
return true;
};
const isErrorCode = (errorCode: string): errorCode is ErrorCodeType =>
errorCodes.includes(errorCode as ErrorCodeType);
export const isErrorObject = (
data: unknown
): data is { error: ErrorObject } => {
if (
data &&
typeof data === "object" &&
"error" in data &&
typeof (data as { error: ErrorObject }).error === "object" &&
typeof (data as { error: ErrorObject }).error.message === "string" &&
typeof (data as { error: ErrorObject }).error.code === "string" &&
(typeof (data as { error: ErrorObject }).error.statusCode === "number" ||
(data as { error: ErrorObject }).error.statusCode === undefined)
) {
return true;
}
return false;
};

View File

@ -0,0 +1,3 @@
import { isVerifyError, sign, verify, decode, getJwtKey } from "./jwt";
export { isVerifyError, sign, verify, decode, getJwtKey };

View File

@ -0,0 +1,250 @@
import { sign, verify, isVerifyError } from "./jwt";
import base64url from "base64url";
test("success sign and verify", () => {
const token = sign({ value: "testvalue" }, 5 * 60, privateKey);
const payload = verify<{ value: "testvalue" }>(token, publicKey);
if (isVerifyError(payload)) {
throw new Error(`${payload.reason} | ${payload.message}`);
}
expect(payload.value).toBe("testvalue");
});
test("failed sign and verify (jwt expired)", () => {
// 有効期限を0秒にすることで、検証を行った時点で有効期限切れにする
const token = sign({ value: "testvalue" }, 0, privateKey);
const payload = verify<{ value: "testvalue" }>(token, publicKey);
if (!isVerifyError(payload)) {
throw new Error(JSON.stringify(payload));
}
expect(payload.reason).toBe("ExpiredError");
});
test("failed sign and verify (invalid key pair)", () => {
const token = sign({ value: "testvalue" }, 5 * 60, privateKey);
// 秘密鍵と対ではない公開鍵を使用して検証する
const payload = verify<{ value: "testvalue" }>(token, anotherPublicKey);
if (!isVerifyError(payload)) {
throw new Error(JSON.stringify(payload));
}
expect(payload.reason).toBe("InvalidToken");
expect(payload.message).toBe("invalid signature");
});
test("failed sign and verify (invalid public key)", () => {
const token = sign({ value: "testvalue" }, 5 * 60, privateKey);
// 公開鍵の形式になっていない文字列を使用して検証する
const payload = verify<{ value: "testvalue" }>(token, fakePublicKey);
if (!isVerifyError(payload)) {
throw new Error(JSON.stringify(payload));
}
expect(payload.reason).toBe("InvalidToken");
expect(payload.message).toBe(
"secretOrPublicKey must be an asymmetric key when using RS256"
);
});
test("failed sign (invalid private key)", () => {
expect(() => {
// 不正な秘密鍵で署名しようとする場合はエラーがthrowされる
sign({ value: "testvalue" }, 5 * 60, fakePrivateKey);
}).toThrowError();
});
test("success rewrite-token verify (as is)", () => {
const token = sign({ value: "testvalue" }, 5 * 60, privateKey);
const { header, payload, verifySignature } = splitToken(token);
{
// 何も操作せずに構築しなおした場合、成功する
const validToken = rebuildToken(header, payload, verifySignature);
const value = verify<{ value: string }>(validToken, publicKey);
if (isVerifyError(value)) {
throw new Error(`${value.reason} | ${value.message}`);
}
expect(value.value).toBe("testvalue");
}
});
test("failed rewrite-token verify (override algorithm)", () => {
const token = sign({ value: "testvalue" }, 5 * 60, privateKey);
const { payload, verifySignature } = splitToken(token);
{
// 検証アルゴリズムを「検証なし」に書き換える
const headerObject = { alg: "none" };
const payloadObject = JSON.parse(payload) as {
value: string;
iat: number;
exp: number;
};
// 内容を操作して構築しなおした場合、失敗する
const customToken = rebuildToken(
JSON.stringify(headerObject),
JSON.stringify(payloadObject),
verifySignature
);
const value = verify<{ value: string }>(customToken, publicKey);
if (!isVerifyError(value)) {
throw new Error(JSON.stringify(payload));
}
expect(value.reason).toBe("InvalidToken");
expect(value.message).toBe("invalid algorithm");
}
});
test("failed rewrite-token verify (override expire)", () => {
const token = sign({ value: "testvalue" }, 5 * 60, privateKey);
const { header, payload, verifySignature } = splitToken(token);
{
// expの値を操作する
const payloadObject = JSON.parse(payload) as {
value: string;
iat: number;
exp: number;
};
payloadObject.exp = payloadObject.exp + 100000;
// 内容を操作して構築しなおした場合、失敗する
const customToken = rebuildToken(
header,
JSON.stringify(payloadObject),
verifySignature
);
const value = verify<{ value: string }>(customToken, publicKey);
if (!isVerifyError(value)) {
throw new Error(JSON.stringify(payload));
}
expect(value.reason).toBe("InvalidToken");
expect(value.message).toBe("invalid signature");
}
});
// JWT改竄テスト用ユーティリティ
const splitToken = (
token: string
): { header: string; payload: string; verifySignature: string } => {
const splited = token.split(".");
const header = base64url.decode(splited[0]);
const payload = base64url.decode(splited[1]);
const verifySignature = splited[2];
return { header, payload, verifySignature };
};
// JWT改竄テスト用ユーティリティ
const rebuildToken = (
header: string,
payload: string,
verifySignature: string
): string => {
const rebuild_header = base64url.encode(header);
const rebuild_payload = base64url.encode(payload);
return `${rebuild_header}.${rebuild_payload}.${verifySignature}`;
};
// テスト用に生成した秘密鍵
const privateKey = [
"-----BEGIN RSA PRIVATE KEY-----",
"MIIEpAIBAAKCAQEAsTVLNpW0/FzVCU7qo1DDjOkYWx6s/jE56YOOc3UzaaG/zb1F",
"GyfRoUUgS4DnQxPNz9oM63RpQlhvG6UCwx23tL7p3PS0ZCsLeggcyLctbJAzLy/a",
"fF9ABoreorqp/AaEs+Vdwbykb+M+nB2Sxsc57Tli2x8NiOZr5dafs3vMuIIKNsBa",
"FAugFrd2ApxXR04jBRAorZRRFPtECE7D+hxDalw5DCd0mmdY0vrbRsgkbej0Zzzq",
"zukJVXTMjy1YScqi3I9gLx2hLVmpK76Gtxn21AIcn8P3rKZmDyPH+9KNfWC8+ubF",
"+VuY6nItlCgiSyTKErAp6M9pyRHKbPpdUM3aIQIDAQABAoIBAQCk7fkmwIdGKhCN",
"LUns3opiZ8AnbpGLs702vR6kDvze35BoqDPdZl4RPwkjvMGBCLmRLly/+ATPnwcq",
"L5Y2iz4jl1yKLaaHZBi2Zz6DARnh5QP+cwdiojQw4qb7xcfXrSltVZjBbBWPnWz0",
"WAH3yAz94V9Emc47EFpz/CF/J0YOokxY8GlR4cwfK6NE0goAjzmatwV3IVFeR/eE",
"x6JZAmd/0HMfOn3k/NumAMCJXKnZMQBAMQ3AduTO2lbZm+29yBqymtzTGFjrj0gm",
"+E/ibD8vVzh0toPvUfPIqetdRT8vkUJ5UHhAkz9Vzvqhr6BhYhc2ft0x/z7HpaiX",
"cDqnaRLBAoGBAODdPEktK1VOVXhOuikZBUHXU25iQdQRbM4kCtWiE8lBZ/f+6OPc",
"BN+OedYMDhpFe/oFqGU4t610SPO1CdVRPnWHhMSabjh9G3gqOZjSW5tEAgT2wi+H",
"IOVXnsos1qCMFdXWgVZw6F8wNcui9VabGic/EOqMRihEeSOjcradTSQFAoGBAMm+",
"y2wZ8usanIDzADgTJnA4kBZzhIxK6qcPf3tPVXKuFUOFWwzGiDXeXTwM0sWN7kGb",
"iymqhTWlYETQ3C6jPXTJiyOSco1rw45wO+xSHeQvUzXpk+9whbVAlhTcoVGiKz+9",
"BS7+3+lKtBzXDNADxQfSGjiGb+ceilBGLV+WurRtAoGAPxn2a/aP/X1hAMTe+t95",
"mTNqx0Qtguxs4yA8Jh04fjarjW1sP10jxPR/fjCd2IN9OflSey1CZhuGyVUZcFI/",
"O84O1PkdSx7YkY0P4rHNYTHhezEf5yR9d75x4fxZMm59RifO3coLe4LU5dNSE76s",
"xSyue5NnsK8ea4DXlSVpW10CgYAfHz3GWWJt/lbyVYpNHDcrzK39qKhj9BKq3ust",
"nJlz7YL+PY5ENERC+yCq6NeC/lgo6tPXA6U1F2P4ebfdwfTzFTxPqoHdayhpysqT",
"tD9EOkC96mCV6WfXBDWi1j5Ul43QcVphW5QzKwEKCerCFDLK+BBvc93Da6SuqYTK",
"YDhBKQKBgQDKtNe8CjHRvkWoyKErMMpv5D0ce/yWq+oAaoqW1QKwngPyaiDeDwqM",
"iOJzQxtvK4YqMYQdkgj5VLfWzeazd28RLODZua6phe776zuUv93LHTvYq/8RZfhk",
"JIQJ7GETBnHmoTemwmJiSdVDsjJdtsyR4XRjIDNR5bGe7NNbZJpCUw==",
"-----END RSA PRIVATE KEY-----",
].join("\n");
// テスト用に生成した公開鍵
const publicKey = [
"-----BEGIN PUBLIC KEY-----",
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsTVLNpW0/FzVCU7qo1DD",
"jOkYWx6s/jE56YOOc3UzaaG/zb1FGyfRoUUgS4DnQxPNz9oM63RpQlhvG6UCwx23",
"tL7p3PS0ZCsLeggcyLctbJAzLy/afF9ABoreorqp/AaEs+Vdwbykb+M+nB2Sxsc5",
"7Tli2x8NiOZr5dafs3vMuIIKNsBaFAugFrd2ApxXR04jBRAorZRRFPtECE7D+hxD",
"alw5DCd0mmdY0vrbRsgkbej0ZzzqzukJVXTMjy1YScqi3I9gLx2hLVmpK76Gtxn2",
"1AIcn8P3rKZmDyPH+9KNfWC8+ubF+VuY6nItlCgiSyTKErAp6M9pyRHKbPpdUM3a",
"IQIDAQAB",
"-----END PUBLIC KEY-----",
].join("\n");
// テスト用に作成した、違う秘密鍵から生成した公開鍵
const anotherPublicKey = [
"-----BEGIN PUBLIC KEY-----",
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt1WsgrjpjsEfRa7vqlR3",
"2mGxErXpvC+uRQnFtSXdP4tEYicPb1cNFUcu5xW6attTyzKHKMzwJrvmKEKVYGig",
"n43rM+UyW79DNOQWQQblCHAc3hMolLWC+Tkw7xL4JhzZLH0rm5DF52YNYSicV1S9",
"RpxYEeyHUa+ExV82lT47ySWAwg+yPwtDeDPMbOxHXqyw1wdqR2WVuxsQBaIRQgMk",
"EL/qObQjA4e5jOOwERRvVLxzjhnldUZcG0cYGDfjPTewRYfCeXzMx2YM4Uo0vx0x",
"2ZIY+im061GvfugX4/31xB5YEi+62qIwuSL5UpKjMv5yx1cvIqO76Ro3XNwsR+81",
"KQIDAQAB",
"-----END PUBLIC KEY-----",
].join("\n");
// 秘密鍵のように見えるが想定する形式と違う
const fakePrivateKey = [
"-----BAGIN RSA PRIVATE KEY-----",
"MIIEpAIBAAKCAQEAsTVLNpW0/FzVCU7qo1DDjOkYWx6s/jE56YOOc3UzaaG/zb1F",
"GyfRoUUgS4DnQxPNz9oM63RpQlhvG6UCwx23tL7p3PS0ZCsLeggcyLctbJAzLy/a",
"fF9ABoreorqp/AaEs+Vdwbykb+M+nB2Sxsc57Tli2x8NiOZr5dafs3vMuIIKNsBa",
"FAugFrd2ApxXR04jBRAorZRRFPtECE7D+hxDalw5DCd0mmdY0vrbRsgkbej0Zzzq",
"zukJVXTMjy1YScqi3I9gLx2hLVmpK76Gtxn21AIcn8P3rKZmDyPH+9KNfWC8+ubF",
"+VuY6nItlCgiSyTKErAp6M9pyRHKbPpdUM3aIQIDAQABAoIBAQCk7fkmwIdGKhCN",
"LUns3opiZ8AnbpGLs702vR6kDvze35BoqDPdZl4RPwkjvMGBCLmRLly/+ATPnwcq",
"L5Y2iz4jl1yKLaaHZBi2Zz6DARnh5QP+cwdiojQw4qb7xcfXrSltVZjBbBWPnWz0",
"WAH3yAz94V9Emc47EFpz/CF/J0YOokxY8GlR4cwfK6NE0goAjzmatwV3IVFeR/eE",
"x6JZAmd/0HMfOn3k/NumAMCJXKnZMQBAMQ3AduTO2lbZm+29yBqymtzTGFjrj0gm",
"+E/ibD8vVzh0toPvUfPIqetdRT8vkUJ5UHhAkz9Vzvqhr6BhYhc2ft0x/z7HpaiX",
"cDqnaRLBAoGBAODdPEktK1VOVXhOuikZBUHXU25iQdQRbM4kCtWiE8lBZ/f+6OPc",
"BN+OedYMDhpFe/oFqGU4t610SPO1CdVRPnWHhMSabjh9G3gqOZjSW5tEAgT2wi+H",
"IOVXnsos1qCMFdXWgVZw6F8wNcui9VabGic/EOqMRihEeSOjcradTSQFAoGBAMm+",
"y2wZ8usanIDzADgTJnA4kBZzhIxK6qcPf3tPVXKuFUOFWwzGiDXeXTwM0sWN7kGb",
"iymqhTWlYETQ3C6jPXTJiyOSco1rw45wO+xSHeQvUzXpk+9whbVAlhTcoVGiKz+9",
"BS7+3+lKtBzXDNADxQfSGjiGb+ceilBGLV+WurRtAoGAPxn2a/aP/X1hAMTe+t95",
"mTNqx0Qtguxs4yA8Jh04fjarjW1sP10jxPR/fjCd2IN9OflSey1CZhuGyVUZcFI/",
"O84O1PkdSx7YkY0P4rHNYTHhezEf5yR9d75x4fxZMm59RifO3coLe4LU5dNSE76s",
"xSyue5NnsK8ea4DXlSVpW10CgYAfHz3GWWJt/lbyVYpNHDcrzK39qKhj9BKq3ust",
"nJlz7YL+PY5ENERC+yCq6NeC/lgo6tPXA6U1F2P4ebfdwfTzFTxPqoHdayhpysqT",
"tD9EOkC96mCV6WfXBDWi1j5Ul43QcVphW5QzKwEKCerCFDLK+BBvc93Da6SuqYTK",
"YDhBKQKBgQDKtNe8CjHRvkWoyKErMMpv5D0ce/yWq+oAaoqW1QKwngPyaiDeDwqM",
"iOJzQxtvK4YqMYQdkgj5VLfWzeazd28RLODZua6phe776zuUv93LHTvYq/8RZfhk",
"JIQJ7GETBnHmoTemwmJiSdVDsjJdtsyR4XRjIDNR5bGe7NNbZJpCUw==",
"-----END RSA PRIVATE KEY-----",
].join("\n");
// 公開鍵のように見えるが想定する形式と違う
const fakePublicKey = [
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt1WsgrjpjsEfRa7vqlR3",
"2mGxErXpvC+uRQnFtSXdP4tEYicPb1cNFUcu5xW6attTyzKHKMzwJrvmKEKVYGig",
"n43rM+UyW79DNOQWQQblCHAc3hMolLWC+Tkw7xL4JhzZLH0rm5DF52YNYSicV1S9",
"RpxYEeyHUa+ExV82lT47ySWAwg+yPwtDeDPMbOxHXqyw1wdqR2WVuxsQBaIRQgMk",
"EL/qObQjA4e5jOOwERRvVLxzjhnldUZcG0cYGDfjPTewRYfCeXzMx2YM4Uo0vx0x",
"2ZIY+im061GvfugX4/31xB5YEi+62qIwuSL5UpKjMv5yx1cvIqO76Ro3XNwsR+81",
"KQIDAQAB",
].join("\n");

View File

@ -0,0 +1,130 @@
import * as jwt from "jsonwebtoken";
// XXX: decodeがうまく使えないことがあるので応急対応 バージョン9以降だとなる
import { decode as jwtDecode } from "jsonwebtoken";
export type VerifyError = {
reason: "ExpiredError" | "InvalidToken" | "InvalidTimeStamp" | "Unknown";
message: string;
};
export const isVerifyError = (arg: unknown): arg is VerifyError => {
const value = arg as VerifyError;
if (value.message === undefined) {
return false;
}
if (value.reason === undefined) {
return false;
}
switch (value.reason) {
case "ExpiredError":
case "InvalidTimeStamp":
case "InvalidToken":
case "Unknown":
return true;
default:
return false;
}
};
/**
* Payloadと秘密鍵を使用して署名されたJWTを生成します
* @param {T} payload payloadの型
* @param {number} expirationSeconds ()
* @param {string} privateKey 使
* @return {string}
* @throws {Error} Errorオブジェクト
*/
export const sign = <T extends object>(
payload: T,
expirationSeconds: number,
privateKey: string
): string => {
try {
const token = jwt.sign(payload, privateKey, {
expiresIn: expirationSeconds,
algorithm: "RS256",
});
return token;
} catch (e) {
throw e;
}
};
/**
* tokenと公開鍵を使用して検証済みJWTのpayloadを取得します
* @param {string} token JWT
* @param {string} publicKey 使
* @return {T | VerifyError} Payload
*/
export const verify = <T extends object>(
token: string,
publicKey: string
): T | VerifyError => {
try {
const payload = jwt.verify(token, publicKey, {
algorithms: ["RS256"],
}) as T;
return payload;
} catch (e) {
if (e instanceof jwt.TokenExpiredError) {
return {
reason: "ExpiredError",
message: e.message,
};
} else if (e instanceof jwt.NotBeforeError) {
return {
reason: "InvalidTimeStamp",
message: e.message,
};
} else if (e instanceof jwt.JsonWebTokenError) {
return {
reason: "InvalidToken",
message: e.message,
};
} else {
return {
reason: "Unknown",
message: e.message,
};
}
}
};
/**
* tokenから未検証のJWTのpayloadを取得します
* @param {string} token JWT
* @return {T | VerifyError} Payload
*/
export const decode = <T extends object>(token: string): T | VerifyError => {
try {
const payload = jwtDecode(token, {
json: true,
}) as T;
return payload;
} catch (e) {
if (e instanceof jwt.TokenExpiredError) {
return {
reason: "ExpiredError",
message: e.message,
};
} else if (e instanceof jwt.NotBeforeError) {
return {
reason: "InvalidTimeStamp",
message: e.message,
};
} else if (e instanceof jwt.JsonWebTokenError) {
return {
reason: "InvalidToken",
message: e.message,
};
} else {
return {
reason: "Unknown",
message: e.message,
};
}
}
};
export const getJwtKey = (key: string): string => key.replace(/\\n/g, "\n");

View File

@ -0,0 +1,32 @@
export type AccessToken = {
/**
* ()
*/
delegateUserId?: string | undefined;
/**
*
*/
userId: string;
/**
* Roleを表現する文字列(ex. "author admin")
*/
role: string;
/**
* 1~5
*/
tier: number;
};
// システムの内部で発行し、外部に公開しないトークン
// システム間通信用(例: Azure Functions→AppServiceに使用する
export type SystemAccessToken = {
/**
*
*/
systemName: string;
/**
*
*/
context?: string;
};

View File

@ -293,3 +293,45 @@ export const HTTP_STATUS_CODES = {
BAD_REQUEST: 400,
INTERNAL_SERVER_ERROR: 500,
};
/**
* Blobコンテナ名
* @const {string}
*/
export const IMPORT_USERS_CONTAINER_NAME = "import-users";
/**
*
* @const {number}
*/
export const IMPORT_USERS_MAX_DURATION_MINUTES = 30;
/**
*
* @const {string}
*/
export const IMPORT_USERS_STAGE_FILE_NAME = "stage.json";
/**
*
* @const {string}
*/
export const IMPORT_USERS_STAGES = {
CREATED: "created",
PRAPARE: "prepare",
START: "start",
COMPLETE: "complete",
DONE: "done",
} as const;
/**
*
* @const {string}
*/
export const RoleNumberMap: Record<number, string> = {
1: USER_ROLES.NONE,
2: USER_ROLES.AUTHOR,
3: USER_ROLES.TYPIST,
} as const;
export const SYSTEM_IMPORT_USERS = "import-users";

View File

@ -0,0 +1,510 @@
import { app, InvocationContext, Timer } from "@azure/functions";
import * as dotenv from "dotenv";
import { BlobstorageService } from "../blobstorage/blobstorage.service";
import {
ADMIN_ROLES,
IMPORT_USERS_MAX_DURATION_MINUTES,
IMPORT_USERS_STAGE_FILE_NAME,
IMPORT_USERS_STAGES,
RoleNumberMap,
SYSTEM_IMPORT_USERS,
TIERS,
} from "../constants";
import { ErrorRow, ImportData } from "../blobstorage/types/types";
import { Configuration, UsersApi } from "../api";
import { createErrorObject } from "../common/errors/utils";
import { sign, getJwtKey } from "../common/jwt";
import { AccessToken, SystemAccessToken } from "../common/jwt/types";
import { isImportJson, isStageJson } from "../blobstorage/types/guards";
export async function importUsersProcessing(
context: InvocationContext,
blobstorageService: BlobstorageService,
userApi: UsersApi
): Promise<void> {
context.log(`[IN] importUsersProcessing`);
try {
dotenv.config({ path: ".env" });
dotenv.config({ path: ".env.local", override: true });
const startUnixTime = getCurrentUnixTime();
context.log(`importUsersProcessing start: ${startUnixTime}`);
// ファイルが存在する間ループ
while (true) {
// Blobストレージからファイル名の一覧を取得stage.json以外
const bloblist = await blobstorageService.listBlobs(context);
context.log(bloblist);
// stage.json以外のファイルが存在しない場合は処理中断
if (bloblist.length === 0) {
break;
}
// ファイルのうち、日付が最も古いファイルを取得
let targetFileName = bloblist.sort().at(0);
if (targetFileName === undefined) {
throw new Error("targetFileName is undefined");
}
let row = 1;
// stage.jsonを取得ダウンロードして読み込む
let stageData = await blobstorageService.downloadFileData(
context,
IMPORT_USERS_STAGE_FILE_NAME
);
// stage.jsonが存在しない場合は、新規作成する
if (stageData === undefined) {
stageData = JSON.stringify({
update: getCurrentUnixTime(),
state: IMPORT_USERS_STAGES.CREATED,
});
const updateSuccess = await blobstorageService.updateFile(
context,
IMPORT_USERS_STAGE_FILE_NAME,
stageData
);
if (!updateSuccess) {
throw new Error(
`update stage.json failed. state: ${IMPORT_USERS_STAGES.CREATED} filename: ${targetFileName}`
);
}
}
const stage = JSON.parse(stageData);
if (!isStageJson(stage)) {
throw new Error("stage.json is invalid");
}
// 作業中のstage.jsonが存在する場合は、処理を再開する
if (
stage.state !== IMPORT_USERS_STAGES.CREATED &&
stage.state !== IMPORT_USERS_STAGES.DONE
) {
// stage.jsonが存在し、内部状態が処理中で、最終更新日時が10分以上前だった場合は処理中断とみなして途中から再開
const nowUnixTime = getCurrentUnixTime();
if (nowUnixTime - stage.update > 10 * 60) {
// stage.jsonの内容から処理対象のfilepathを特定する
context.log(stage.filename);
if (stage.filename === undefined) {
context.log("stage.filename is undefined");
break;
}
targetFileName = stage.filename;
// 処理開始行をstage.jsonを元に復元する
row = stage.row ?? 1;
} else {
// 内部状態が処理中であれば処理中断処理が終わる前にTimerから再度起動されてしまったケース
context.log("stage is processing");
break;
}
}
{
const updateSuccess = await blobstorageService.updateFile(
context,
IMPORT_USERS_STAGE_FILE_NAME,
JSON.stringify({
update: getCurrentUnixTime(),
state: IMPORT_USERS_STAGES.PRAPARE,
filename: targetFileName,
})
);
if (!updateSuccess) {
throw new Error(
`update stage.json failed. state: ${IMPORT_USERS_STAGES.PRAPARE} filename: ${targetFileName}`
);
}
}
// 対象ファイルをダウンロードして読み込む
const importsData = await blobstorageService.downloadFileData(
context,
targetFileName
);
// 一括登録ユーザー一覧をメモリ上に展開
const imports =
importsData === undefined ? undefined : JSON.parse(importsData);
if (!isImportJson(imports)) {
throw new Error(`json: ${targetFileName} is invalid`);
}
if (imports === undefined) {
break;
}
// 代行操作トークンを発行する
const accsessToken = await generateDelegationAccessToken(
context,
imports.external_id,
imports.user_role
);
// 一括登録ユーザー一覧をループして、一括登録ユーザーを一括登録する
const errors: ErrorRow[] = [];
for (const user of imports.data) {
{
// stage.jsonを更新ユーザー追加開始
const updateSuccess = await blobstorageService.updateFile(
context,
IMPORT_USERS_STAGE_FILE_NAME,
JSON.stringify({
update: getCurrentUnixTime(),
state: IMPORT_USERS_STAGES.START,
filename: targetFileName,
row: row,
})
);
if (!updateSuccess) {
throw new Error(
`update stage.json failed. state: ${IMPORT_USERS_STAGES.START} filename: ${targetFileName} row: ${row}`
);
}
}
try {
if (!checkUser(context, user, targetFileName, row)) {
throw new Error(
`Invalid user data. filename: ${targetFileName} row: ${row}`
);
}
// ユーザーを追加する
await addUser(context, userApi, user, accsessToken);
} catch (e) {
const error = createErrorObject(e);
context.log(error);
// エラーが発生したらエラーコードを控えておく
errors.push({ row: row, error: error.code, name: user.name });
}
{
// stage.jsonを更新ユーザー追加完了
const updateSuccess = await blobstorageService.updateFile(
context,
IMPORT_USERS_STAGE_FILE_NAME,
JSON.stringify({
update: getCurrentUnixTime(),
state: IMPORT_USERS_STAGES.COMPLETE,
filename: targetFileName,
row: row,
errors: errors,
})
);
if (!updateSuccess) {
throw new Error(
`update stage.json failed. state: ${IMPORT_USERS_STAGES.COMPLETE} filename: ${targetFileName} row: ${row}`
);
}
}
row++;
// 500ms待機
await new Promise((resolve) => setTimeout(resolve, 500));
}
// 処理対象のユーザー一覧ファイルを削除する
await blobstorageService.deleteFile(context, targetFileName);
// システムトークンを発行
const systemToken = await generateSystemToken(context);
// 一括登録完了メールを送信するODMS Cloudの一括追加完了APIを呼び出す
await userApi.multipleImportsComplate(
{
accountId: imports.account_id,
filename: imports.file_name,
requestTime: getCurrentUnixTime(),
errors: errors.map((error) => {
return {
name: error.name,
line: error.row,
errorCode: error.error,
};
}),
},
{
headers: { authorization: `Bearer ${systemToken}` },
}
);
{
// stage.jsonを更新処理完了
const updateSuccess = await blobstorageService.updateFile(
context,
IMPORT_USERS_STAGE_FILE_NAME,
JSON.stringify({
update: getCurrentUnixTime(),
state: IMPORT_USERS_STAGES.DONE,
})
);
if (!updateSuccess) {
throw new Error(
`update stage.json failed. state: ${IMPORT_USERS_STAGES.DONE} filename: ${targetFileName}`
);
}
}
// 経過時間を確認して、30分以上経過していたら処理を中断する
{
const currentUnixTime = getCurrentUnixTime();
// 時間の差分を計算(秒)
const elapsedSec = currentUnixTime - startUnixTime;
// 30分以上経過していたら処理を中断する
if (elapsedSec > IMPORT_USERS_MAX_DURATION_MINUTES * 60) {
context.log("timeout");
break;
}
}
}
} catch (e) {
context.log("importUsers failed.");
context.error(e);
throw e;
} finally {
context.log(`[OUT] importUsersProcessing`);
}
}
export async function importUsers(
myTimer: Timer,
context: InvocationContext
): Promise<void> {
context.log(`[IN] importUsers`);
try {
dotenv.config({ path: ".env" });
dotenv.config({ path: ".env.local", override: true });
const blobstorageService = new BlobstorageService();
const userApi = new UsersApi(
new Configuration({
basePath: process.env.BASE_PATH,
})
);
await importUsersProcessing(context, blobstorageService, userApi);
} catch (e) {
context.log("importUsers failed.");
context.error(e);
throw e;
} finally {
context.log(`[OUT] importUsers`);
}
}
/**
* ODMS CloudのAPIを呼び出してユーザーを追加する
* @param context
* @param user
* @returns user
*/
export async function addUser(
context: InvocationContext,
userApi: UsersApi,
user: ImportData,
token: string
): Promise<void> {
context.log(`[IN] addUser`);
try {
await userApi.signup(
{
email: user.email,
name: user.name,
role: RoleNumberMap[user.role],
autoRenew: user.auto_renew === 1,
notification: user.notification === 1,
authorId: user.author_id,
encryption: user.encryption === 1,
encryptionPassword: user.encryption_password,
prompt: user.prompt === 1,
},
{
headers: { authorization: `Bearer ${token}` },
}
);
} catch (e) {
context.error(e);
throw e;
} finally {
context.log(`[OUT] addUser`);
}
}
/**
*
* @param context
* @param user
* @param fileName
* @param row
* @returns true if user
*/
function checkUser(
context: InvocationContext,
user: ImportData,
fileName: string,
row: number
): boolean {
context.log(
`[IN] checkUser | params: { fileName: ${fileName}, row: ${row} }`
);
try {
// 名前が255文字以内であること
if (user.name.length > 255) {
context.log(`name is too long. fileName: ${fileName}, row: ${row}`);
return false;
}
const emailPattern =
/^[a-zA-Z0-9!#$%&'_`/=~+\-?^{|}.]+@[a-zA-Z0-9!#$%&'_`/=~+\-?^{|}.]*\.[a-zA-Z0-9!#$%&'_`/=~+\-?^{|}.]*[a-zA-Z]$/;
// メールアドレスが255文字以内であること
if (user.email.length > 255) {
context.log(`email is too long. fileName: ${fileName}, row: ${row}`);
return false;
}
if (!emailPattern.test(user.email)) {
context.log(`Invalid email. fileName: ${fileName}, row: ${row}`);
return false;
}
// ロールが(0/1/2)のいずれかであること
if (![0, 1, 2].includes(user.role)) {
context.log(`Invalid role number. fileName: ${fileName}, row: ${row}`);
return false;
}
// ロールがAuthorの場合
if (user.role === 1) {
// author_idが必須
if (user.author_id === undefined) {
context.log(
`author_id is required. fileName: ${fileName}, row: ${row}`
);
return false;
}
// author_idが16文字以内であること
if (user.author_id.length > 16) {
context.log(
`author_id is too long. fileName: ${fileName}, row: ${row}`
);
return false;
}
// author_idが半角大文字英数字とハイフンであること
if (!/^[A-Z0-9_]*$/.test(user.author_id)) {
context.log(`author_id is invalid. fileName: ${fileName}, row: ${row}`);
return false;
}
// encryptionが必須
if (user.encryption === undefined) {
context.log(
`encryption is required. fileName: ${fileName}, row: ${row}`
);
return false;
}
// encryptionが1の場合
if (user.encryption === 1) {
// encryption_passwordが必須
if (user.encryption_password === undefined) {
context.log(
`encryption_password is required. fileName: ${fileName}, row: ${row}`
);
return false;
}
// 416文字の半角英数字と記号のみであること
if (!/^[!-~]{4,16}$/.test(user.encryption_password)) {
context.log(
`encryption_password is invalid. fileName: ${fileName}, row: ${row}`
);
return false;
}
if (user.prompt === undefined) {
context.log(`prompt is required. fileName: ${fileName}, row: ${row}`);
return false;
}
}
}
return true;
} catch (e) {
context.error(e);
throw e;
} finally {
context.log(`[OUT] checkUser`);
}
}
/**
*
* @param context
* @param externalId
* @returns delegation token
*/
async function generateDelegationAccessToken(
context: InvocationContext,
externalId: string,
role: string
): Promise<string> {
context.log(
`[IN] generateDelegationAccessToken | params: { externalId: ${externalId} }`
);
try {
// 要求されたトークンの寿命を決定
const tokenLifetime = Number(process.env.ACCESS_TOKEN_LIFETIME_WEB);
const privateKey = getJwtKey(process.env.JWT_PRIVATE_KEY ?? "");
const token = sign<AccessToken>(
{
role: `${role} ${ADMIN_ROLES.ADMIN}`,
tier: TIERS.TIER5,
userId: externalId,
delegateUserId: SYSTEM_IMPORT_USERS,
},
tokenLifetime,
privateKey
);
return token;
} catch (e) {
context.error(e);
throw e;
} finally {
context.log(`[OUT] generateDelegationAccessToken`);
}
}
/**
* System用のアクセストークンを生成します
* @param context
* @returns system token
*/
async function generateSystemToken(
context: InvocationContext
): Promise<string> {
context.log(`[IN] generateSystemToken`);
try {
// 要求されたトークンの寿命を決定
const tokenLifetime = Number(process.env.ACCESS_TOKEN_LIFETIME_WEB);
const privateKey = getJwtKey(process.env.JWT_PRIVATE_KEY ?? "");
const token = sign<SystemAccessToken>(
{
systemName: SYSTEM_IMPORT_USERS,
},
tokenLifetime,
privateKey
);
return token;
} catch (e) {
context.error(e);
throw e;
} finally {
context.log(`[OUT] generateSystemToken`);
}
}
const getCurrentUnixTime = () => Math.floor(new Date().getTime() / 1000);
// 5分毎に実行
app.timer("importUsers", {
schedule: "0 */5 * * * *",
handler: importUsers,
});

View File

@ -0,0 +1,90 @@
import * as dotenv from "dotenv";
import { InvocationContext } from "@azure/functions";
import { BlobstorageService } from "../blobstorage/blobstorage.service";
import { importUsersProcessing } from "../functions/importUsers";
import {
PostMultipleImportsCompleteRequest,
SignupRequest,
UsersApi,
} from "../api/api";
import { AxiosRequestConfig, AxiosResponse } from "axios";
describe("importUsersProcessing", () => {
dotenv.config({ path: ".env" });
dotenv.config({ path: ".env.local", override: true });
it("stage.jsonがない状態でユーザー追加できること", async () => {
const context = new InvocationContext();
const userApiMock = new UsersApiMock() as UsersApi;
// // 呼び出し回数でテスト成否を判定
const spySignup = jest.spyOn(userApiMock, "signup");
const blobService = new BlobstorageService();
const mockListBlobs = jest
.fn()
.mockReturnValueOnce(["U_20210101_000000.json"])
.mockReturnValue([]);
const mockDownloadFileData = jest
.fn()
.mockReturnValueOnce(undefined)
.mockReturnValue(
`{"account_id": 1, "user_id": 1, "external_id": "hoge", "user_role": "none", "file_name": "U_20240216_143802_8001_12211.csv", "date": 1111111, "data": [{"name": "name1","email": "email1@example.com","role": 1,"author_id": "AUTHOR_ID1","auto_renew": 1,"notification": 1,"encryption": 1,"encryption_password": "password","prompt": 1},{"name": "name2","email": "email2@example.com","role": 1,"author_id": "AUTHOR_ID2","auto_renew": 1,"notification": 1,"encryption": 1,"encryption_password": "password","prompt": 1}]}`
);
const mockUpdateFile = jest.fn().mockReturnValue(true);
const mockDeleteFile = jest.fn().mockReturnValue(undefined);
const mockIsFileExists = jest.fn().mockReturnValueOnce(false);
blobService.listBlobs = mockListBlobs;
blobService.downloadFileData = mockDownloadFileData;
blobService.updateFile = mockUpdateFile;
blobService.deleteFile = mockDeleteFile;
blobService.isFileExists = mockIsFileExists;
await importUsersProcessing(context, blobService, userApiMock);
expect(spySignup.mock.calls).toHaveLength(2);
}, 30000);
it("ファイルがない場合はそのまま終了すること", async () => {
const context = new InvocationContext();
const userApiMock = new UsersApiMock() as UsersApi;
// // 呼び出し回数でテスト成否を判定
const spySignup = jest.spyOn(userApiMock, "signup");
const blobService = new BlobstorageService();
const mockListBlobs = jest.fn().mockReturnValue([]);
const mockDownloadFileData = jest.fn().mockReturnValue("");
const mockUpdateFile = jest.fn().mockReturnValue(undefined);
const mockDeleteFile = jest.fn().mockReturnValue(undefined);
const mockIsFileExists = jest.fn().mockReturnValueOnce(false);
blobService.listBlobs = mockListBlobs;
blobService.downloadFileData = mockDownloadFileData;
blobService.updateFile = mockUpdateFile;
blobService.deleteFile = mockDeleteFile;
blobService.isFileExists = mockIsFileExists;
await importUsersProcessing(context, blobService, userApiMock);
expect(spySignup.mock.calls).toHaveLength(0);
}, 30000);
});
export class UsersApiMock extends UsersApi {
async signup(
ignupRequest: SignupRequest,
options?: AxiosRequestConfig
): Promise<AxiosResponse<object, void>> {
return { data: {}, status: 200, statusText: "", headers: {}, config: {} };
}
async multipleImportsComplate(
postMultipleImportsCompleteRequest: PostMultipleImportsCompleteRequest,
options?: AxiosRequestConfig
): Promise<AxiosResponse<object, void>> {
return { data: {}, status: 200, statusText: "", headers: {}, config: {} };
}
}

View File

@ -1636,7 +1636,7 @@
}
},
"400": {
"description": "パラメータ不正/アカウント不在",
"description": "パラメータ不正",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
@ -2267,6 +2267,118 @@
"security": [{ "bearer": [] }]
}
},
"/users/multiple-imports": {
"post": {
"operationId": "multipleImports",
"summary": "",
"description": "ユーザーを一括登録します",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PostMultipleImportsRequest"
}
}
}
},
"responses": {
"200": {
"description": "成功時のレスポンス",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PostMultipleImportsResponse"
}
}
}
},
"400": {
"description": "不正なパラメータ",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
},
"401": {
"description": "認証エラー",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
},
"500": {
"description": "想定外のサーバーエラー",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
}
},
"tags": ["users"],
"security": [{ "bearer": [] }]
}
},
"/users/multiple-imports/complete": {
"post": {
"operationId": "multipleImportsComplate",
"summary": "",
"description": "ユーザー一括登録の完了を通知します",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PostMultipleImportsCompleteRequest"
}
}
}
},
"responses": {
"200": {
"description": "成功時のレスポンス",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PostMultipleImportsCompleteResponse"
}
}
}
},
"400": {
"description": "不正なパラメータ",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
},
"401": {
"description": "認証エラー",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
},
"500": {
"description": "想定外のサーバーエラー",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
}
},
"tags": ["users"],
"security": [{ "bearer": [] }]
}
},
"/files/audio/upload-finished": {
"post": {
"operationId": "uploadFinished",
@ -4676,6 +4788,68 @@
"required": ["userId"]
},
"PostDeleteUserResponse": { "type": "object", "properties": {} },
"MultipleImportUser": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "ユーザー名" },
"email": { "type": "string", "description": "メールアドレス" },
"role": {
"type": "number",
"description": "0(none)/1(author)/2(typist)"
},
"authorId": { "type": "string" },
"autoRenew": { "type": "number", "description": "0(false)/1(true)" },
"notification": {
"type": "number",
"description": "0(false)/1(true)"
},
"encryption": { "type": "number", "description": "0(false)/1(true)" },
"encryptionPassword": { "type": "string" },
"prompt": { "type": "number", "description": "0(false)/1(true)" }
},
"required": ["name", "email", "role", "autoRenew", "notification"]
},
"PostMultipleImportsRequest": {
"type": "object",
"properties": {
"filename": { "type": "string", "description": "CSVファイル名" },
"users": {
"type": "array",
"items": { "$ref": "#/components/schemas/MultipleImportUser" }
}
},
"required": ["filename", "users"]
},
"PostMultipleImportsResponse": { "type": "object", "properties": {} },
"MultipleImportErrors": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "ユーザー名" },
"line": { "type": "number", "description": "エラー発生行数" },
"errorCode": { "type": "string", "description": "エラーコード" }
},
"required": ["name", "line", "errorCode"]
},
"PostMultipleImportsCompleteRequest": {
"type": "object",
"properties": {
"accountId": { "type": "number", "description": "アカウントID" },
"filename": { "type": "string", "description": "CSVファイル名" },
"requestTime": {
"type": "number",
"description": "一括登録受付時刻(UNIXTIME/ミリ秒)"
},
"errors": {
"type": "array",
"items": { "$ref": "#/components/schemas/MultipleImportErrors" }
}
},
"required": ["accountId", "filename", "requestTime", "errors"]
},
"PostMultipleImportsCompleteResponse": {
"type": "object",
"properties": {}
},
"AudioOptionItem": {
"type": "object",
"properties": {