From 882ed5f7fa0c31dce24c091f5638e9465d1b804b Mon Sep 17 00:00:00 2001 From: msmsrep <113223770+msmsrep@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:00:37 +0900 Subject: [PATCH] =?UTF-8?q?=E3=82=A2=E3=82=AF=E3=82=BB=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=83=BC=E3=82=AF=E3=83=B3=E3=82=92=E7=84=A1=E5=8A=B9=E5=8C=96?= =?UTF-8?q?=E3=83=BB=E3=83=AD=E3=83=BC=E3=83=AB=E5=A4=89=E6=9B=B4=E3=81=A7?= =?UTF-8?q?=E5=8D=B3=E6=99=82=E5=A4=B1=E5=8A=B9=E3=81=95=E3=81=9B=E3=80=81?= =?UTF-8?q?CSV=E5=87=BA=E5=8A=9B=E3=81=AE=E6=95=B0=E5=BC=8F=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=82=B8=E3=82=A7=E3=82=AF=E3=82=B7=E3=83=A7=E3=83=B3?= =?UTF-8?q?=E3=82=92=E9=98=B2=E3=81=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - アクセストークンに SecurityStamp を載せ、検証のたびにDBの値・有効フラグと照合する(Api/AccessTokenValidation)。 これまではリフレッシュトークンだけを失効させていたため、無効化・ロール変更後も最大60分は操作できた - 無効化・ロール変更で UpdateSecurityStampAsync を呼ぶ(単票API・ユーザーCSVの両方) - Identity のトークンプロバイダが未登録で、管理者のパスワードリセット(API・ユーザーCSVの InitialPassword・ 初期管理者の再生成)が例外になっていたのを AddDefaultTokenProviders で直した - CSV出力で先頭が = @ タブ CR、または数値でない + - の値に ' を付ける。取込では外す(出力をそのまま取り込み直せる) - Spec.md 7.4・3.8、改訂145 Co-Authored-By: Claude Opus 5.5 --- Spec.md | 4 +- src/MesApp.Api/AccessTokenValidation.cs | 43 ++++++++++++++ src/MesApp.Api/Controllers/UsersController.cs | 6 ++ src/MesApp.Api/MesAppHost.cs | 7 ++- src/MesApp.Api/MustChangePasswordFilter.cs | 4 +- src/MesApp.Api/Services/CsvFile.cs | 30 +++++++++- src/MesApp.Api/Services/JwtTokenService.cs | 2 + .../Services/MasterCsvService.Import.cs | 8 +++ src/MesApp.Core/Constants/MesClaimTypes.cs | 6 ++ tests/MesApp.Api.Tests/AuthTests.cs | 56 +++++++++++++++++++ tests/MesApp.Api.Tests/MasterCsvTests.cs | 33 +++++++++++ 11 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 src/MesApp.Api/AccessTokenValidation.cs diff --git a/Spec.md b/Spec.md index ac6a036..77e1c19 100644 --- a/Spec.md +++ b/Spec.md @@ -134,6 +134,7 @@ 改訂133: 2026-09-23(**標準時間の見直し候補を生産性モニタリングに追加した**(`api/productivity/standard-time-review`。B-60-10-05)。標準時間の予実は作業指示ごとに並ぶだけで、工順マスタを見直すきっかけにつながっていなかった(実践的MES導入ガイド 8.3.2(4):標準時間などの業務パラメータは定期的に再設計する)。工順の工程ごとに実績の中央値をマスタの現在値と比べ、件数としきい値で候補を挙げる。マスタは書き換えない。新規エンティティは無い。3.2節・6.1節に反映) 改訂134: 2026-09-23(1.3節に**エネルギー管理を[将来拡張]とする**ことを明記した。仕様にエネルギーの記述が無く、対象かどうかが読めなかった(実践的MES導入ガイド 3.4.2・4.4.7・7.2.2 は、MES の役割を FEMS へ製造オーダ別の分母を渡すことと位置づける)。計測・総量監視・ユーティリティ制御は FEMS/FMCS の領域として持たず、実装する場合は設備×期間の使用量をCSVで受け取り稼働区間の時間比で作業指示へ按分する形とした。機能の追加・変更は無い) 改訂135: 2026-09-23(**検査項目・基準に必要スキル・資格を持たせ、検査実績の登録時に検査員と照合する**ようにした(`InspectionItem.RequiredSkillId`。F-20-30-01)。改訂130で保全を実装した際、検査の見直し(改訂125〜128)と衝突するため後回しにしていたもの。必要スキルは発行時に `InspectionOrderItem` へ写し(作業指示の必要スキルと同じ)、発行後にマスタを変えても発行済みの検査の照合条件は変わらない。写す値なので、CSV取込で変えると版数が上がる。判定は差立・着手・保全と同じ `SkillQualificationPolicy`。実績の訂正では照合しない。マスタCSVの検査項目に `RequiredSkillCode` 列を追加(列が無ければ現在値のまま)。3.6節・5.1節・5.4節に反映) +改訂145: 2026-10-23(7.4節:**アクセストークンを無効化・ロール変更・パスワード変更で即時に失効させる**ことを追加した。これまでリフレッシュトークンだけを失効させていたため、発行済みのアクセストークンは有効期限(既定60分)まで使えた。トークンに Identity のセキュリティスタンプを載せ、検証のたびにDBの値・有効フラグと照合する(`Api/AccessTokenValidation`)。あわせて、Identity のトークンプロバイダが未登録で管理者のパスワードリセット(API・ユーザーCSVの `InitialPassword`・初期管理者の再生成)が例外になっていたのを直した。3.8節:**CSV出力の数式インジェクション対策**を追加した) 改訂144: 2026-10-23(7.4節・3.x節保全実施・3.8節⑨:**保全実績の登録を保全担当者(`MaintenanceManage`)に限った**。単票API(`POST api/maintenance-orders/{id}/record`)と実績CSV `maintenance-records` が全ロールに開いており、物流・品質の担当者でも在庫の引落し・保全指示の完了・設備の復帰・治工具の寿命リセットができた。7.4節の「異常・使用実績の記録は絞らない」は保全依頼(起票)までで、実績は含まない。画面の実績登録フォームも同じ定数で隠す) 改訂143: 2026-09-24(3.8節:実績CSVに保全計画 `maintenance-plans` を追加した(CSV対応バックログで保留にしていたもの)。計画の登録を `MaintenancePlansController.Create` から `MaintenanceOrderService.CreatePlanAsync` へ移し、単票APIとCSV取込の両方から呼ぶ(API契約は変えない)。計画には番号が無いため、保全指示CSVに `FromPlan` 列を足し、設備と予定日が一致する未指示の計画が1件だけのときにその計画から指示を作る。サンプル `samples/actual-csv/18_maintenance-plans.csv` を追加し、以降のサンプルの番号を1つずつ繰り下げた(19〜30)) 改訂142: 2026-09-24(3.8節:実績CSVに不適合 `nonconformances`・サンプル品保管 `sample-storages` を追加した(CSV-07)。不適合の対応と保管サンプルが画面からしか進められなかった。検査の不合格で自動起票された不適合は、ロット番号で指す(そのロットの未完了の不適合が1件だけのときに限る)。サンプルの採取・保管の終了を `SampleStoragesController` から新設の `SampleStorageService` へ移し、単票APIとCSV取込の両方から呼ぶ(API契約は変えない。サンプル番号の手入力はCSVのみ)。サンプル `samples/actual-csv/28〜29` を追加) @@ -359,7 +360,7 @@ AGV/AMR連携(D-50-20-02)・リサイクル管理(D-50-30-02)・VMI/外 - 生産実績も稼働ログも**開始時刻が属する製造日**で振り分ける(品質分析・生産性・OEEも同じ)。稼働は工程・品目では絞れない(作業指示との紐付けが任意のため)。生産実績は設備では分けられない(設備が作業指示に固定されないため)。求められない指標は0でなくnull(「実績なし」と区別する)。ライン軸は最下段の作業区を祖先のライン段へまとめる - 帳票・ラベル発行基盤(作業指示書・現品票・検査成績書・出荷伝票・出荷判定書・棚卸表等の印刷/PDF出力。B-10-30-04、C-20-10-05、D-10-10-03、D-50-30-05〜06、H-10-10-04) - 監査ログ(実績訂正・検査訂正・マスタ変更等の変更履歴。7.6節) -- **マスタのCSV一括入出力**:各マスタ(品目/MBOM/工順/工程/作業区/設備/設備の保全部品/治工具/ロケーション/検査項目・基準/**検査機・測定器**/工程管理項目/チェックリスト/作業手順書/勤務シフト/スキル・資格/ユーザー/ユーザーのスキル資格/**保全手順書**)のCSV出力・テンプレート出力・一括取込。保全手順書(`maintenance-procedures`)は単票APIと同じく保全の権限(`MaintenanceManage`)で取り込み、取込欄は保全管理画面の保全手順書タブに置く(一括ZIPでは最後の番号。対象設備・治工具・必要スキルをコードで参照するため)。取込はコード(自然キー)によるupsertで、他マスタはコードで参照する。文字コードはUTF-8/Shift_JISを自動判別し、出力はExcel互換(UTF-8 BOM+CRLF)。行単位で検証し、**1行でもエラーがあれば全件ロールバック**して行番号付きのエラー一覧を返す。反映せず検証のみ実行するモードを持つ。**参照されている状態のマスタを無効化できないという制約は、単票APIと同じ判定をCSV取込にも通す**(工順から参照中の作業手順書、在籍中の従業員が所属する直など)。片方にしか書かないとCSVから迂回できてしまうため、判定はポリシーへ集約する。なお**マスタの無効化は各マスタ画面から、再有効化はCSV取込の `IsActive` 列から**行う(全マスタ共通。画面に再有効化の導線は持たせていない) +- **マスタのCSV一括入出力**:各マスタ(品目/MBOM/工順/工程/作業区/設備/設備の保全部品/治工具/ロケーション/検査項目・基準/**検査機・測定器**/工程管理項目/チェックリスト/作業手順書/勤務シフト/スキル・資格/ユーザー/ユーザーのスキル資格/**保全手順書**)のCSV出力・テンプレート出力・一括取込。保全手順書(`maintenance-procedures`)は単票APIと同じく保全の権限(`MaintenanceManage`)で取り込み、取込欄は保全管理画面の保全手順書タブに置く(一括ZIPでは最後の番号。対象設備・治工具・必要スキルをコードで参照するため)。取込はコード(自然キー)によるupsertで、他マスタはコードで参照する。文字コードはUTF-8/Shift_JISを自動判別し、出力はExcel互換(UTF-8 BOM+CRLF)。**表計算ソフトで数式として実行される値(先頭が `=`・`@`・タブ・CR、または数値でない `+`・`-`)は前に `'` を付けて出力する**(CSVインジェクション対策。品目名・備考など現場の入力がそのまま出るため)。取込では同じ印を外すので、出力したCSVをそのまま取り込み直しても値は変わらない。行単位で検証し、**1行でもエラーがあれば全件ロールバック**して行番号付きのエラー一覧を返す。反映せず検証のみ実行するモードを持つ。**参照されている状態のマスタを無効化できないという制約は、単票APIと同じ判定をCSV取込にも通す**(工順から参照中の作業手順書、在籍中の従業員が所属する直など)。片方にしか書かないとCSVから迂回できてしまうため、判定はポリシーへ集約する。なお**マスタの無効化は各マスタ画面から、再有効化はCSV取込の `IsActive` 列から**行う(全マスタ共通。画面に再有効化の導線は持たせていない) - **生産計画(`production-plans`)は取込の仕組みだけをこれに乗せる**。生産計画はマスタではなく日付を持つ業務データだが、改訂のたびに同じ計画を上書きしたいので、常に新規登録する実績CSVではなくキーで上書きするマスタCSVの取込を使う(A-30-10-01)。キーは `BusinessDate, ProductCode, ProcessCode, WorkCenterCode(任意)` で、同じキーなら計画数量(と `Note` 列があれば備考)を上書きし、無ければ追加する。**`WorkCenterCode` が空の行も1つのキーとして扱い**、同じ製造日・品目・工程の作業区なしの計画を上書きする(二重に追加しない)。計画数量は0以上。ファイル内で同じキーが複数行にあればエラー。取り込んだ後に同じ製造日・品目・工程で作業区の範囲が重なる行(既存の計画との間・ファイル内とも)もエラー。数量とキーの判定は単票API(`api/production-plans`)と共通の `ProductionPlanPolicy` に置く。取込の権限は単票の登録と同じ `ProductionManage`(種別の `WriteRoles`)。取り込み直しても変わらない行は更新日時を動かさない。**マスタではないので一括ZIPの取込・出力には含めない**(日々増えるデータを、マスタの移行や全件出力に巻き込まないため。ZIPに入っていれば取込を拒む)。取込は生産計画画面の計画登録タブから1ファイルずつ行い、出力は一覧と同じ絞り込み(製造日の期間・品目・工程・作業区)の `api/production-plans/csv` で行う - **実績のCSV一括取込**:試験・移行・教育用に、実績(取引データ)をCSVで一括登録する(`api/actuals/csv/{kind}`。テンプレート出力と取込。対応種別は受入 `receiving`・製造指図 `manufacturing-orders`・段取り実績 `setup-records`・チェックリスト実施 `checklist-records`・部材投入 `consumptions`・生産実績 `production-records`・製造条件データ `data-records`・検査 `inspections`・作業時間 `work-time-records`・製造トラブル報告 `trouble-reports`・設備稼働記録 `equipment-logs`・出荷指示 `shipping-orders`・出荷判定 `shipment-judgments`・出荷実行 `shipments`・保全計画 `maintenance-plans`・保全指示 `maintenance-orders`・保全実績 `maintenance-records`・治工具の利用実績 `tool-usages`・治工具の引当 `tool-issues`・検査機の校正 `calibrations`・在庫オペレーション `inventory-operations`・搬送指示 `transfer-orders`・ピッキング指示 `picking-orders`・棚卸指示 `stocktakes`・実棚数 `stocktake-counts`・不適合 `nonconformances`・サンプル品保管 `sample-storages`)。マスタCSVと同じく**1行でもエラーがあれば全件ロールバック**し、行番号付きのエラー一覧を返し、検証のみのモードを持つ。マスタCSVとの違いは次のとおり。①**取込は常に新規登録**で既存の実績を更新しない(実績の訂正は理由付きの訂正機能で行う)。②行ごとに**単票APIと同じ業務サービスを通す**(受入は `ReceivingService`、製造指図は `ManufacturingOrderService`、作業指示の実行記録は `WorkOrderExecutionService`、検査は `InspectionService`、作業時間・トラブル報告は `ShopFloorReportService`、出荷指示・出荷実行は `ShippingService`、出荷判定は `ShipmentJudgmentService`、保全指示・保全実績は `MaintenanceOrderService`、治工具の利用実績は `ShopFloorReportService`、治工具の引当は `ToolIssueService`、校正は `InspectionDeviceService`、在庫オペレーションは `LotOperationService`、搬送は `TransferOrderService`、ピッキングは `PickingService`、棚卸は `StocktakeService`、不適合は `NonconformanceService`、サンプル品保管は `SampleStorageService`)——実績は在庫・ロット・状態履歴を連鎖して動かすため、行をエンティティへ直接写すと判定や監査ログが迂回される。③取込の権限は**種別ごとに単票APIと同じロールグループ**(受入は在庫・物流オペレーション)。④行は保存しながら進めるので、後の行は前の行の結果(登録済みロット番号の重複判定など)を前提にでき、検証のみも実際に登録してからロールバックする。他のマスタ・実績はIDではなくコード・番号で参照する。⑤**作業指示は「指図番号+工程順序」で指す**。自動採番の指図番号は取込前に分からないため、製造指図CSVに限り指図番号の手入力を許す(自動採番の形式 `MO〜` と重なる番号は、採番テーブルの連番と後で衝突するため拒否)。製造指図CSVは1行で登録・承認・工程展開まで進められるが、承認は省略できない(展開は承認済みの指図だけ)。⑥**実行記録の記録者は取込を実行したユーザー**とし、作業者を指定する列は持たない(他人名義の実績を作れると、記録者に意味が無くなる)。投入ロットはロット番号で、日時はオフセットの無い値を工場の時刻として読む。生産実績の直は開始日時で決まる。⑦**検査CSVは `InspectionKey` が同じ行を1件の検査指示にまとめる**。キーはファイルの中だけの名前でDBに残さない(検査指示番号は自動採番のままにし、後続のCSVから検査指示を指す必要も無いため)。まとまりに含まれる検査項目がその指示の対象になり、`Judge=true` なら実績に続けて総合判定する(ロットの状態反映・不合格時の不適合起票も単票APIと同じ)。⑧**出荷は指示・判定・実行の3種別に分ける**(判定は品質保証、指示と実行は物流の権限で、1種別にまとめると③を満たせない)。出荷指示CSVは出荷番号が同じ行を1件の指示(1行=1品目の明細。同じ品目を2行書けない)にまとめ、後続の判定・実行CSVから番号で指せるよう**出荷番号を必須の手入力**とする(⑤と同じ理由で自動採番の形式 `SH〜` と重なる番号は拒否)。出荷判定CSVは1行=1件の判定で、`Approve=true` なら続けて承認する。出荷実行CSVは出荷番号が同じ行を1回の出荷にまとめ、**承認済みの判定(可・特採)とロットの使用可否は単票APIと同じく出荷実行の処理で判定する**(判定の無い指示を含むファイルは全件取り消し)。⑨**保全は指示と実績の2種別に分ける**。保全指示CSVは1行=1件の指示で、保全実績CSVから指せるよう**保全指示番号を必須の手入力**とする(⑤と同じ理由で `MT〜` は拒否)。単票APIと同じく突発依頼は誰でも、計画保全の指示は保全担当者(`MaintenanceOrderService.PlannedOrderRoles`)だけが登録でき、CSVでは行ごとに取り込んだユーザーのロールで判定する。**保全計画CSV**は1行=1件の計画で常に新規登録する(設備×年度×区分でも同じ区分の計画が複数ありうるため自然なキーが無く、マスタCSVのように上書きしない。権限は単票と同じ `MaintenanceManage`)。計画には番号が無いので、保全指示CSVの `FromPlan=true` の行は**設備と予定日が一致する未指示の計画が1件だけのとき**にその計画から作り、計画を指示済みにする(0件・2件以上は行エラー。同じ設備の計画は予定日を分ける)。保全実績CSVは保全指示番号が同じ行を1件の実績にまとめ、1行に消費部材を1つ(ロット番号・払出元・数量)書ける。実施者は取り込んだユーザーで、手順書の必要スキルもこのユーザーと照合する。権限は単票と同じ `MaintenanceManage`。登録で指示は完了するので、1件の指示に実績は1件だけ。資産管理部品を書いた行・在庫不足は単票と同じく拒否する。⑩**番号の無い記録は1行で完結させる**。治工具の引当CSVは1行=1件の引当で、`Issue`・`Return` の列で払出・返却まで進め、後のファイルからは指さない(引当・払出の両方で `ToolIssuePolicy` が使用中・メンテ中・寿命到達を拒否する)。治工具の利用実績CSVは記録日時(`RecordedAt`)を指定でき、空欄なら取り込んだ時刻とする(寿命の累計はメンテで寿命をリセットした時刻より後の記録だけを数えるので、リセットより前の日時の記録は累計に入らない)。検査機の校正CSVは1行=1回の校正で、単票と同じく検査機の最終校正日・次回期限(省略時は校正周期から)を更新する。⑪**在庫オペレーションは1種別にまとめ、`Operation` 列で操作を選ぶ**(移動・数量調整・状態変更・分割・統合・振替・廃棄・返品・払出戻し。操作ごとに種別を分けると取込画面のパネルが9枚増えるため)。1行=1操作で、単票と同じ `LotOperationService` の同名メソッドを呼ぶ。操作ごとの必須列はコードで検査する(数量調整の理由は単票でも必須)。分割・振替は新ロット番号(`NewLotNumber`)を指定できるので、同じファイルの後の行から新しいロットを指せる。ロット系譜・状態履歴・監査ログは単票と同じに残る。⑫**物流の指示**:搬送指示CSVは番号が無いので1行=1件で、`Execute` の列で移動の実行まで進める。ピッキング指示CSVはピッキング番号(必須の手入力。`PK〜` は拒否)が同じ行を1件の指示にまとめ、1行=1明細(品目と数量)。ロット・ロケーションは単票と同じく先入れ先出し(有効期限優先。期限切れは引き当てない)で自動引当し、`Execute` で払出まで進める。棚卸指示CSVは棚卸番号(必須の手入力。`ST〜` は拒否)と対象ロケーションで作り、作成時点の在庫が明細になる。実棚数CSVは棚卸番号と明細のロット番号+ロケーションで指し、`Finalize` で確定(差異の棚卸調整)まで進める。⑬**不適合CSVは1行=1件の不適合**で、不適合番号(未登録なら新規起票。`NC〜` は拒否)か、番号を空欄にしてロット番号で指し、`Action`(対応指示)→`ActionRecord`(対応記録)→`Approve`(承認)の列があればその順に進める。検査の不合格で自動起票された不適合は番号が取り込むまで分からないため、**ロット番号で指せるのはそのロットの未完了(クローズ前)の不適合が1件だけのとき**に限る(0件・2件以上は行エラー。どれを進めるかを推測で決めない)。起票と対応記録は誰でも、対応指示と承認は単票と同じく品質管理の担当者だけ(行ごとに取り込んだユーザーのロールで判定)。⑭**サンプル品保管CSV**は1行=1件で、未登録のサンプル番号(`SP〜` は拒否)なら採取(在庫から抜く)、`Close`(払出・廃棄)を書けば保管を終える。登録済みの番号の行は保管の終了だけを行う - **CSVのZIP一括取込・一括出力**:複数のCSVをまとめたZIPを1回で取り込む(マスタは `POST api/masters/csv/bundle`、実績は `POST api/actuals/csv/bundle`)。**ファイル名の昇順に取り込み、種別はファイル名の最初の「_」より後ろで決める**(`01_work-centers.csv` → `work-centers`。同じ種別を番号違いで複数入れてよい)。CSV以外のファイル(README.md等)は無視する。**全ファイルを1つのトランザクションで処理し、どれか1ファイルでもエラーがあれば全ファイルを取り消す**(後のファイルが前のファイルの登録結果を参照するのでファイルごとに保存はするが、途中まで反映された状態が残るとどこから取り込み直せばよいか分からなくなるため)。エラーが出たファイルより後は処理せず、未処理として名前を返す。**マスタと実績が混ざったZIPは受け付けない**(取込の権限も対象画面も異なるため、別のZIPにする)。取込には**ZIPに含まれる全種別の取込権限**が要り、1種別でも権限が無ければ何も取り込まない。種別が分からないファイルは取り込む前に拒否する。ZIP爆弾対策として、CSVの数・1ファイルと合計の展開後サイズに上限を設け、読みながら数えて超えたら打ち切る。一括出力(`GET api/masters/csv/bundle`)は全マスタを取り込む順の番号付きファイル名でZIPにし、そのまま一括取込に使える(参照を絞っている種別は権限が無ければ含めない。ユーザーはパスワードを出力しないため、新しいDBへ移すときはユーザー系のファイルに初期パスワードを書き足す)。実績の一括出力は持たない @@ -676,6 +677,7 @@ DBはバックエンド(MesApp.Api)のみが保持し、既定はSQLiteと - **JWTの構成と保管**:アクセストークン(短命、既定60分)+リフレッシュトークン(既定12時間、交代勤務のシフト長を考慮)で構成する。アクセストークンはWebクライアントのメモリ上にのみ保持し、localStorage等の永続ストレージには保存しない(XSS時の窃取面を最小化)。リフレッシュトークンはHttpOnly Cookieで保持し、サイレント更新する - **リフレッシュトークンの再利用検知**:リフレッシュはローテーション(旧トークンを即失効し新トークンを発行)する。**失効済みトークンが再提示された場合は盗用の可能性があるものとして、そのユーザーの全リフレッシュトークンを失効させ**、監査ログに記録して再ログインを求める。正規の利用者も巻き添えで再ログインになるが、盗んだ側にセッションを継続させないことを優先する - **リフレッシュトークンの保持期間**:失効済み・期限切れのレコードは有効期間の2倍を過ぎた時点で削除する(発行のたびに当該ユーザー分を掃除する)。監査は監査ログ(7.6節)側で行うため、トークン行を残し続けない +- **アクセストークンの即時失効**:ユーザーの無効化・ロール変更・パスワード変更(本人の変更・管理者のリセット)は、発行済みのアクセストークンにも有効期限を待たずに効かせる。アクセストークンに Identity のセキュリティスタンプを載せ、API側(`Api/AccessTokenValidation`)がリクエストごとにDBのスタンプ・有効フラグと照合し、違えば401にする。ロール変更だけならリフレッシュトークンは失効させないので、クライアントはサイレント更新で新しいロールのトークンを取り直す。照合結果はキャッシュしない(キャッシュが切れるまで無効化が効かない時間ができるため。主キー検索1回のコストを許容する) - **JWT署名鍵**:発行側と検証側は同一のインスタンスから鍵を取る。鍵ファイルの自動生成は一時ファイルへ書いてから移動し、同時起動で別々の鍵を作らないようにする。UNIX系では鍵ファイルを所有者のみ読み書き可とする - **CSRF対策**:状態変更APIはすべてAuthorizationヘッダのJWTを必須とし(Cookieのみでは実行不可)、Cookie(リフレッシュトークン)には`HttpOnly`・`Secure`・`SameSite=Strict`を設定する。トークンリフレッシュ等Cookieのみで動作するエンドポイントへのクロスサイトからの発火はSameSite属性で遮断する - 通信は全てHTTPS。バックエンドはローカル(社内)環境で稼働するため公的CA証明書は取得できない前提とし、プライベートCA(社内CA or mkcert等)で発行した証明書をサーバーに設定し、CA証明書をクライアント端末(ブラウザ利用端末)に配布・信頼させる運用手順をドキュメント化する diff --git a/src/MesApp.Api/AccessTokenValidation.cs b/src/MesApp.Api/AccessTokenValidation.cs new file mode 100644 index 0000000..ea60f70 --- /dev/null +++ b/src/MesApp.Api/AccessTokenValidation.cs @@ -0,0 +1,43 @@ +using System.Security.Claims; +using MesApp.Core.Constants; +using MesApp.Infrastructure; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.EntityFrameworkCore; + +namespace MesApp.Api; + +/// +/// アクセストークンの即時失効(Spec.md 7.4)。署名・有効期限の検証が通ったあとに、 +/// ユーザーが有効か、トークンのセキュリティスタンプがDBの値と一致するかを照合する。 +/// +/// +/// 署名と有効期限だけでは、無効化・ロール変更・パスワードリセットをしても発行済みのトークンが +/// 有効期限(既定60分)まで使えてしまう。リクエストごとに主キー検索が1回増えるが、 +/// キャッシュは挟まない(挟むとキャッシュが切れるまで無効化が効かない)。 +/// +public static class AccessTokenValidation +{ + /// JwtBearer の OnTokenValidated に渡す + public static async Task ValidateAsync(TokenValidatedContext context) + { + var principal = context.Principal; + var userId = principal?.FindFirstValue(ClaimTypes.NameIdentifier); + var stamp = principal?.FindFirstValue(MesClaimTypes.SecurityStamp); + if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(stamp)) + { + // スタンプを載せる前に発行されたトークンも弾く(クライアントはリフレッシュで取り直す) + context.Fail("トークンにユーザーまたはセキュリティスタンプがありません。"); + return; + } + + var db = context.HttpContext.RequestServices.GetRequiredService(); + var user = await db.Users.AsNoTracking() + .Where(u => u.Id == userId) + .Select(u => new { u.IsActive, u.SecurityStamp }) + .FirstOrDefaultAsync(context.HttpContext.RequestAborted); + if (user is null || !user.IsActive || user.SecurityStamp != stamp) + { + context.Fail("ユーザーが無効化されたか、権限・パスワードが変更されたためトークンは失効しています。"); + } + } +} diff --git a/src/MesApp.Api/Controllers/UsersController.cs b/src/MesApp.Api/Controllers/UsersController.cs index 7e8712c..8b1dde5 100644 --- a/src/MesApp.Api/Controllers/UsersController.cs +++ b/src/MesApp.Api/Controllers/UsersController.cs @@ -159,6 +159,12 @@ public async Task> Update(string id, UpdateUse await userManager.RemoveFromRolesAsync(user, currentRoles.Except(request.Roles)); await userManager.AddToRolesAsync(user, request.Roles.Except(currentRoles)); + // 無効化・ロール変更は発行済みのアクセストークンにも即時に効かせる(Spec.md 7.4)。 + // ロール変更だけならリフレッシュトークンは生きているので、クライアントは新しいロールで取り直す + if (deactivated || !currentRoles.ToHashSet().SetEquals(request.Roles)) + { + await userManager.UpdateSecurityStampAsync(user); + } if (deactivated) { // 無効化されたユーザーのセッションを止める(Spec.md 7.4) diff --git a/src/MesApp.Api/MesAppHost.cs b/src/MesApp.Api/MesAppHost.cs index f751548..22586aa 100644 --- a/src/MesApp.Api/MesAppHost.cs +++ b/src/MesApp.Api/MesAppHost.cs @@ -71,7 +71,10 @@ public static WebApplication Build(string[] args) }) .AddRoles() .AddEntityFrameworkStores() - .AddSignInManager(); + .AddSignInManager() + // 管理者によるパスワードリセット(ユーザーCSVの InitialPassword・初期管理者の再生成を含む)は + // リセット用トークンを発行してから使う。プロバイダが無いと発行で例外になる + .AddDefaultTokenProviders(); // JWT認証(アクセストークン。リフレッシュはRefreshTokenService+HttpOnly Cookie) builder.Services.Configure(builder.Configuration.GetSection(JwtOptions.SectionName)); @@ -128,6 +131,8 @@ public static WebApplication Build(string[] args) ValidateLifetime = true, ClockSkew = TimeSpan.FromMinutes(1), }; + // 無効化・ロール変更・パスワード変更を発行済みトークンにも即時に効かせる(Spec.md 7.4) + options.Events = new JwtBearerEvents { OnTokenValidated = AccessTokenValidation.ValidateAsync }; }); builder.Services.AddAuthorization(); diff --git a/src/MesApp.Api/MustChangePasswordFilter.cs b/src/MesApp.Api/MustChangePasswordFilter.cs index b7f5437..a0a9492 100644 --- a/src/MesApp.Api/MustChangePasswordFilter.cs +++ b/src/MesApp.Api/MustChangePasswordFilter.cs @@ -15,8 +15,8 @@ namespace MesApp.Api; /// リクエストごとのDB参照はしない。 /// /// -/// 管理者によるパスワードリセット直後など、既に発行済みのトークンにはこのクレームが付かない。 -/// 反映は次回のトークン発行時(ロール変更と同じ扱い)。 +/// 管理者によるパスワードリセットでは、セキュリティスタンプが変わって発行済みのトークンが失効する +/// ()。再ログインで発行し直したトークンにこのクレームが付く。 /// /// public sealed class MustChangePasswordFilter : IAuthorizationFilter diff --git a/src/MesApp.Api/Services/CsvFile.cs b/src/MesApp.Api/Services/CsvFile.cs index 07128ae..1cd9a49 100644 --- a/src/MesApp.Api/Services/CsvFile.cs +++ b/src/MesApp.Api/Services/CsvFile.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text; namespace MesApp.Api.Services; @@ -85,7 +86,7 @@ public static List Parse(string text) void EndRecord() { - fields.Add(field.ToString()); + fields.Add(Unprotect(field.ToString())); field.Clear(); if (fields.Count > 1 || fields[0].Trim().Length > 0) { @@ -128,7 +129,7 @@ void EndRecord() inQuotes = true; break; case ',': - fields.Add(field.ToString()); + fields.Add(Unprotect(field.ToString())); field.Clear(); break; case '\r': @@ -172,9 +173,34 @@ private static string Escape(string? value) { return string.Empty; } + if (IsFormulaLike(value)) + { + value = FormulaGuard + value; + } var needsQuotes = value.AsSpan().IndexOfAny(',', '"', '\n') >= 0 || value.Contains('\r') || value[0] == ' ' || value[^1] == ' '; return needsQuotes ? $"\"{value.Replace("\"", "\"\"")}\"" : value; } + + /// + /// 数式とみなされる値の前に付ける印。Excel等の表計算ソフトで開いたとき、利用者が入力した品目名や備考が + /// 数式(=HYPERLINK(...) やDDE)として実行されないようにする(CSVインジェクション対策。Spec.md 3.8) + /// + private const char FormulaGuard = '\''; + + /// + /// 表計算ソフトが数式として解釈する先頭文字か。+・- は数値(-5・+1.5)なら数式にならないので除く + /// (数量・座標の負値に印が付くと、表計算での集計や取り込み直しで数値として扱えなくなる) + /// + private static bool IsFormulaLike(string value) => value[0] switch + { + '=' or '@' or '\t' or '\r' => true, + '+' or '-' => !decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out _), + _ => false, + }; + + /// 出力時に付けた印を外す(出力したCSVをそのまま取り込み直しても元の値に戻るようにする) + private static string Unprotect(string value) => + value.Length >= 2 && value[0] == FormulaGuard && IsFormulaLike(value[1..]) ? value[1..] : value; } diff --git a/src/MesApp.Api/Services/JwtTokenService.cs b/src/MesApp.Api/Services/JwtTokenService.cs index 06e7a96..cd04fec 100644 --- a/src/MesApp.Api/Services/JwtTokenService.cs +++ b/src/MesApp.Api/Services/JwtTokenService.cs @@ -22,6 +22,8 @@ public class JwtTokenService(SigningKeyProvider keyProvider, IOptions public const string MustChangePassword = "must_change_password"; + + /// + /// 発行時点のセキュリティスタンプ(Identity の SecurityStamp)。無効化・ロール変更・パスワード変更で + /// DB側の値が変わると、発行済みのアクセストークンは有効期限を待たずに使えなくなる + /// + public const string SecurityStamp = "security_stamp"; } diff --git a/tests/MesApp.Api.Tests/AuthTests.cs b/tests/MesApp.Api.Tests/AuthTests.cs index e833dc8..bbdd200 100644 --- a/tests/MesApp.Api.Tests/AuthTests.cs +++ b/tests/MesApp.Api.Tests/AuthTests.cs @@ -351,6 +351,62 @@ public async Task 信頼するプロキシが未設定なら転送ヘッダを /// プロキシ(remoteAddress)から、HTTPSで受けた ClientAddress のリクエストを転送された形でログインする。 /// HttpClient経由では接続元IPを指定できないため、TestServerに直接HttpContextを組み立てて渡す /// + [Fact] + public async Task ロール変更で発行済みのアクセストークンは失効しリフレッシュで新しいロールになる() + { + using var factory = new ApiFactory(); + using var admin = await TestAuth.CreateAdminClientAsync(factory); + using var worker = await TestAuth.CreateUserClientAsync(factory, admin, "worker1", "Passw0rd123", MesRoles.Operator); + Assert.Equal(HttpStatusCode.OK, (await worker.GetAsync("/api/locations")).StatusCode); + + var id = await UserIdAsync(admin, "worker1"); + (await admin.PutAsJsonAsync($"/api/users/{id}", + new UpdateUserRequest("worker1", [MesRoles.ProductionManager], IsActive: true))).EnsureSuccessStatusCode(); + + // 古いロールのトークンは有効期限を待たずに使えなくなる + Assert.Equal(HttpStatusCode.Unauthorized, (await worker.GetAsync("/api/locations")).StatusCode); + + // リフレッシュトークンは生きているので、取り直せば新しいロールで操作を続けられる + var refresh = await worker.PostAsync("/api/auth/refresh", null); + Assert.Equal(HttpStatusCode.OK, refresh.StatusCode); + var token = (await refresh.Content.ReadFromJsonAsync())!; + Assert.Equal([MesRoles.ProductionManager], token.User.Roles); + worker.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); + Assert.Equal(HttpStatusCode.OK, (await worker.GetAsync("/api/locations")).StatusCode); + + // 表示名だけの更新ではトークンを失効させない + (await admin.PutAsJsonAsync($"/api/users/{id}", + new UpdateUserRequest("作業者1", [MesRoles.ProductionManager], IsActive: true))).EnsureSuccessStatusCode(); + Assert.Equal(HttpStatusCode.OK, (await worker.GetAsync("/api/locations")).StatusCode); + } + + [Fact] + public async Task 無効化とパスワードリセットで発行済みのアクセストークンは即時に失効する() + { + using var factory = new ApiFactory(); + using var admin = await TestAuth.CreateAdminClientAsync(factory); + using var retired = await TestAuth.CreateUserClientAsync(factory, admin, "retired1", "Passw0rd123", MesRoles.Operator); + using var reset = await TestAuth.CreateUserClientAsync(factory, admin, "reset1", "Passw0rd123", MesRoles.Operator); + + // 無効化:アクセストークンもリフレッシュも使えない + (await admin.PutAsJsonAsync($"/api/users/{await UserIdAsync(admin, "retired1")}", + new UpdateUserRequest("retired1", [MesRoles.Operator], IsActive: false))).EnsureSuccessStatusCode(); + Assert.Equal(HttpStatusCode.Unauthorized, (await retired.GetAsync("/api/locations")).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await retired.PostAsync("/api/auth/refresh", null)).StatusCode); + + // 管理者によるパスワードリセット + (await admin.PostAsJsonAsync($"/api/users/{await UserIdAsync(admin, "reset1")}/reset-password", + new ResetPasswordRequest("NewPassw0rd456"))).EnsureSuccessStatusCode(); + Assert.Equal(HttpStatusCode.Unauthorized, (await reset.GetAsync("/api/locations")).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await reset.PostAsync("/api/auth/refresh", null)).StatusCode); + + // 他のユーザー(管理者)のトークンには影響しない + Assert.Equal(HttpStatusCode.OK, (await admin.GetAsync("/api/locations")).StatusCode); + } + + private static async Task UserIdAsync(HttpClient admin, string userName) => + (await admin.GetFromJsonAsync>("/api/users"))!.Single(u => u.UserName == userName).Id; + private static Task LoginViaProxyAsync(ApiFactory factory, string remoteAddress) => factory.Server.SendAsync(context => { diff --git a/tests/MesApp.Api.Tests/MasterCsvTests.cs b/tests/MesApp.Api.Tests/MasterCsvTests.cs index 23b6b64..0f415da 100644 --- a/tests/MesApp.Api.Tests/MasterCsvTests.cs +++ b/tests/MesApp.Api.Tests/MasterCsvTests.cs @@ -626,6 +626,39 @@ public async Task 不良理由マスタをCSVで入出力できる() Assert.Contains("DF-01,寸法外れ(外径),Equipment,true", exported); } + [Fact] + public async Task CSV出力は数式とみなされる値に印を付け_取り込み直すと元の値に戻る() + { + using var factory = new ApiFactory(); + using var client = await TestAuth.CreateAdminClientAsync(factory); + var result = await ImportAsync(client, "defect-reasons", """ + Code,Name,Category,IsActive + DF-01,"=HYPERLINK(""http://example.invalid"",""x"")",Process,true + DF-02,@SUM(A1),Process,true + DF-03,-A1+1,Process,true + DF-04,-5,Process,true + DF-05,寸法外れ,Process,true + """); + Assert.Empty(result.Errors); + + // Excel等で開いたときに数式として実行されないよう ' を前に付ける。負の数・通常の文字列には付けない + var exported = Encoding.UTF8.GetString( + await (await client.GetAsync("/api/masters/csv/defect-reasons")).Content.ReadAsByteArrayAsync()); + Assert.Contains("DF-01,\"'=HYPERLINK(\"\"http://example.invalid\"\",\"\"x\"\")\",Process", exported); + Assert.Contains("DF-02,'@SUM(A1),Process", exported); + Assert.Contains("DF-03,'-A1+1,Process", exported); + Assert.Contains("DF-04,-5,Process", exported); + Assert.Contains("DF-05,寸法外れ,Process", exported); + + // 出力したCSVをそのまま取り込み直しても、印は外れて元の値のまま + var again = await ImportAsync(client, "defect-reasons", exported.TrimStart('')); + Assert.Empty(again.Errors); + var reasons = (await client.GetFromJsonAsync>("/api/defect-reasons"))!; + Assert.Equal("=HYPERLINK(\"http://example.invalid\",\"x\")", reasons.Single(r => r.Code == "DF-01").Name); + Assert.Equal("@SUM(A1)", reasons.Single(r => r.Code == "DF-02").Name); + Assert.Equal("-A1+1", reasons.Single(r => r.Code == "DF-03").Name); + } + [Fact] public async Task ユーザーCSVで最後のシステム管理者を降格できない() {