From 6aa82dde3b78e4a73a0451cd070d22f383333fe3 Mon Sep 17 00:00:00 2001 From: "OU.ZAIKOU" Date: Tue, 27 Jan 2026 01:13:17 +0900 Subject: [PATCH] =?UTF-8?q?=E3=80=90=E5=AE=9A=E6=9C=9F=E3=83=91=E3=82=B9?= =?UTF-8?q?=E3=83=AF=E3=83=BC=E3=83=89=E5=A4=89=E6=9B=B4=E3=80=91=E5=AE=9F?= =?UTF-8?q?=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Console/Commands/CheckPasswordExpiry.php | 81 ++++++ .../Admin/InformationController.php | 34 ++- .../Auth/PasswordChangeController.php | 135 +++++++++ .../Auth/ResetPasswordController.php | 5 +- .../CheckPasswordChangeRequired.php | 102 +++++++ app/Http/Requests/ChangePasswordRequest.php | 97 +++++++ app/Models/Ope.php | 2 + bootstrap/app.php | 1 + .../auth/password-change-success.blade.php | 159 +++++++++++ .../views/auth/password-change.blade.php | 259 ++++++++++++++++++ routes/web.php | 13 +- 11 files changed, 885 insertions(+), 3 deletions(-) create mode 100644 app/Console/Commands/CheckPasswordExpiry.php create mode 100644 app/Http/Controllers/Auth/PasswordChangeController.php create mode 100644 app/Http/Middleware/CheckPasswordChangeRequired.php create mode 100644 app/Http/Requests/ChangePasswordRequest.php create mode 100644 resources/views/auth/password-change-success.blade.php create mode 100644 resources/views/auth/password-change.blade.php diff --git a/app/Console/Commands/CheckPasswordExpiry.php b/app/Console/Commands/CheckPasswordExpiry.php new file mode 100644 index 0000000..01634bc --- /dev/null +++ b/app/Console/Commands/CheckPasswordExpiry.php @@ -0,0 +1,81 @@ +argument('ope_id'); + + if ($opeId) { + $ope = Ope::find($opeId); + if (!$ope) { + $this->error("Ope with ID {$opeId} not found"); + return 1; + } + $this->checkOpe($ope); + } else { + // すべてのオペレータをチェック + $opes = Ope::all(); + foreach ($opes as $ope) { + $this->checkOpe($ope); + } + } + + return 0; + } + + /** + * 単一オペレータの有効期限をチェック + */ + private function checkOpe(Ope $ope): void + { + $this->info("=== Ope ID: {$ope->ope_id} ({$ope->ope_name}) ==="); + $this->info("ope_pass_changed_at: " . ($ope->ope_pass_changed_at ?? 'NULL')); + + if (is_null($ope->ope_pass_changed_at)) { + $this->warn("❌ Password change REQUIRED (never changed)"); + return; + } + + try { + $changedAt = Carbon::parse($ope->ope_pass_changed_at); + $now = Carbon::now(); + $monthsDiff = $now->diffInMonths($changedAt); + + $this->info("Changed At: " . $changedAt->format('Y-m-d H:i:s')); + $this->info("Now: " . $now->format('Y-m-d H:i:s')); + $this->info("Months Difference: {$monthsDiff}"); + + if ($monthsDiff >= 3) { + $this->warn("❌ Password change REQUIRED ({$monthsDiff} months passed)"); + } else { + $this->line("✅ Password is valid ({$monthsDiff} months passed, {3 - $monthsDiff} months remaining)"); + } + } catch (\Exception $e) { + $this->error("Error parsing date: {$e->getMessage()}"); + } + + $this->line(""); + } +} diff --git a/app/Http/Controllers/Admin/InformationController.php b/app/Http/Controllers/Admin/InformationController.php index 05cfaaa..6598892 100644 --- a/app/Http/Controllers/Admin/InformationController.php +++ b/app/Http/Controllers/Admin/InformationController.php @@ -59,7 +59,39 @@ class InformationController extends Controller // ダッシュボード表示 public function dashboard(Request $request) { - return view('admin.information.dashboard'); + // ダッシュボード統計情報を集計 + + // 駐輪場の総収容台数 + $totalCapacity = DB::table('park') + ->sum('park_capacity') ?? 0; + + // 予約待ち人数(regular_contractで状態チェック) + $totalWaiting = DB::table('regular_contract') + ->whereIn('rc_status', [1]) // 待機中など + ->count(); + + // 利用率計算(使用中台数 / 総容量) + $utilizationRate = $totalCapacity > 0 + ? round((DB::table('park') + ->where('park_status', 1) + ->sum('park_capacity') ?? 0) / $totalCapacity * 100) + : 0; + + $totalStats = [ + 'total_cities' => DB::table('city')->count(), + 'total_parks' => DB::table('park')->count(), + 'total_contracts' => DB::table('regular_contract')->count(), + 'total_users' => DB::table('user')->count(), + 'total_devices' => DB::table('device')->count(), + 'today_queues' => DB::table('operator_que') + ->whereDate('created_at', today()) + ->count(), + 'total_waiting' => $totalWaiting, + 'total_capacity' => $totalCapacity, + 'total_utilization_rate' => $utilizationRate, + ]; + + return view('admin.information.dashboard', compact('totalStats')); } // ステータス一括更新(着手=2 / 対応完了=3) diff --git a/app/Http/Controllers/Auth/PasswordChangeController.php b/app/Http/Controllers/Auth/PasswordChangeController.php new file mode 100644 index 0000000..e8dfd9e --- /dev/null +++ b/app/Http/Controllers/Auth/PasswordChangeController.php @@ -0,0 +1,135 @@ +isPasswordChangeRequired($ope); + + return view('auth.password-change', [ + 'isRequired' => $isRequired, + ]); + } + + /** + * パスワード変更成功画面を表示 + * + * GET /password/change/success + * + * @return \Illuminate\View\View + */ + public function showSuccessPage() + { + return view('auth.password-change-success'); + } + + /** + * パスワード変更処理 + * + * POST /password/change + * + * バリデーション: + * - 当前パスワード:必填、8-64文字、ハッシュ値一致確認 + * - 新パスワード:必填、8-64文字、英数字+記号のみ、当前と異なる + * - 新パスワード確認:必填、新パスワードと一致 + * + * @param \App\Http\Requests\ChangePasswordRequest $request + * @return \Illuminate\Http\RedirectResponse + */ + public function updatePassword(ChangePasswordRequest $request) + { + // 現在のユーザーを取得 + $ope = Auth::user(); + + // ステップ1:当前パスワードの認証(ハッシュ値の確認) + if (!Hash::check($request->current_password, $ope->ope_pass)) { + // バリデーションエラーとして当前パスワード が正しくないことを返す + throw ValidationException::withMessages([ + 'current_password' => '当前パスワードが正しくありません。', + ]); + } + + // ステップ2:新パスワードが当前パスワードと同一でないか確認 + // FormRequest側でも not_in ルールで確認しているが、ハッシュ値での二重チェック + if (Hash::check($request->password, $ope->ope_pass)) { + throw ValidationException::withMessages([ + 'password' => '新パスワードは当前パスワードと異なる必要があります。', + ]); + } + + // ステップ3:データベース更新 + // パスワードをハッシュ化して更新 + $ope->ope_pass = Hash::make($request->password); + + // パスワード変更時刻を現在時刻に更新 + $ope->ope_pass_changed_at = Carbon::now(); + + // updated_at も自動更新される + $ope->save(); + + // イベント発火:パスワード変更イベント + event(new PasswordReset($ope)); + + // 成功画面へリダイレクト + return redirect()->route('password.change.success'); + } + + /** + * パスワード変更が必須かどうかを判定 + * + * 初回ログイン時(ope_pass_changed_at が NULL)または + * 最後変更から3ヶ月以上経過している場合、TRUE を返す + * + * @param \App\Models\Ope $ope + * @return bool + */ + private function isPasswordChangeRequired($ope): bool + { + // パスワード変更日時が未設定(初回ログイン等) + if (is_null($ope->ope_pass_changed_at)) { + return true; + } + + // パスワード変更から経過日数を計算 + $changedAt = Carbon::parse($ope->ope_pass_changed_at); + $now = Carbon::now(); + + // 3ヶ月以上経過している場合 + if ($now->diffInMonths($changedAt) >= 3) { + return true; + } + + return false; + } +} diff --git a/app/Http/Controllers/Auth/ResetPasswordController.php b/app/Http/Controllers/Auth/ResetPasswordController.php index 815e80f..3b4f1e0 100644 --- a/app/Http/Controllers/Auth/ResetPasswordController.php +++ b/app/Http/Controllers/Auth/ResetPasswordController.php @@ -42,11 +42,14 @@ class ResetPasswordController extends Controller } $user->password = Hash::make($request->password); $user->updated_at = now(); + // パスワード再設定時もope_pass_changed_atを更新 + $user->ope_pass_changed_at = now(); $user->save(); // トークン削除 DB::table('password_reset_tokens')->where('ope_mail', $request->email)->delete(); - return redirect()->route('login')->with('status', 'パスワードを再設定しました。'); + // パスワード再設定成功画面へリダイレクト + return redirect()->route('password.change.success'); } } \ No newline at end of file diff --git a/app/Http/Middleware/CheckPasswordChangeRequired.php b/app/Http/Middleware/CheckPasswordChangeRequired.php new file mode 100644 index 0000000..1ffcdf8 --- /dev/null +++ b/app/Http/Middleware/CheckPasswordChangeRequired.php @@ -0,0 +1,102 @@ +routeIs('password.change.show', 'password.change.update')) { + return $next($request); + } + + // 現在のユーザーを取得 + $ope = Auth::user(); + + // パスワード変更が必須か判定 + if ($this->isPasswordChangeRequired($ope)) { + return redirect()->route('password.change.show'); + } + + return $next($request); + } + + /** + * パスワード変更が必須かどうかを判定 + * + * 初回ログイン時(ope_pass_changed_at が NULL)または + * 最後変更から3ヶ月以上経過している場合、TRUE を返す + * + * @param \App\Models\Ope $ope + * @return bool + */ + private function isPasswordChangeRequired($ope): bool + { + // パスワード変更日時が未設定(初回ログイン等) + if (is_null($ope->ope_pass_changed_at)) { + \Log::info('Password change required: ope_pass_changed_at is null', [ + 'ope_id' => $ope->ope_id, + ]); + return true; + } + + // パスワード変更から経過日数を計算 + // ope_pass_changed_at は複数のフォーマットに対応 + try { + $changedAt = Carbon::parse($ope->ope_pass_changed_at); + } catch (\Exception $e) { + // パース失敗時は強制変更 + \Log::warning('Failed to parse ope_pass_changed_at', [ + 'ope_id' => $ope->ope_id, + 'value' => $ope->ope_pass_changed_at, + 'error' => $e->getMessage(), + ]); + return true; + } + + $now = Carbon::now(); + + // 3ヶ月以上経過しているか判定 + // diffInMonths は絶対値ではなく符号付きなので、abs() で絶対値を取得 + $monthsDiff = abs($now->diffInMonths($changedAt)); + + \Log::info('Password change check', [ + 'ope_id' => $ope->ope_id, + 'changed_at' => $changedAt->format('Y-m-d H:i:s'), + 'now' => $now->format('Y-m-d H:i:s'), + 'months_diff' => $monthsDiff, + 'is_required' => $monthsDiff >= 3, + ]); + + if ($monthsDiff >= 3) { + return true; + } + + return false; + } +} diff --git a/app/Http/Requests/ChangePasswordRequest.php b/app/Http/Requests/ChangePasswordRequest.php new file mode 100644 index 0000000..eea6761 --- /dev/null +++ b/app/Http/Requests/ChangePasswordRequest.php @@ -0,0 +1,97 @@ +check(); + } + + /** + * 入力値の検証ルール + * + * クライアント側:必填(3項目)、長度 8-64、新密码仅半角英数字+记号、新/确认一致 + * サーバー側:当前密码认证(hash check)、新密码不能等于旧密码(CustomRulesで実装) + * + * @return array|string> + */ + public function rules(): array + { + return [ + // 当前パスワード:必填、8-64文字 + 'current_password' => [ + 'required', + 'string', + 'min:8', + 'max:64', + ], + + // 新パスワード:必填、8-64文字、英数字+記号のみ + 'password' => [ + 'required', + 'string', + 'min:8', + 'max:64', + 'regex:/^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};:\'",.<>?\/\\|`~]+$/', // 半角英数字+記号のみ + ], + + // 新パスワード確認:必填、新パスワードと一致 + 'password_confirmation' => [ + 'required', + 'string', + 'same:password', + ], + + // hidden フィールド(フォーム側で出力) + 'updated_at' => 'nullable|date_format:Y-m-d H:i:s', + 'ope_pass_changed_at' => 'nullable|date_format:Y-m-d H:i:s', + ]; + } + + /** + * 属性の表示名 + * + * @return array + */ + public function attributes(): array + { + return [ + 'current_password' => '当前パスワード', + 'password' => '新パスワード', + 'password_confirmation' => '新パスワード確認', + ]; + } + + /** + * 検証エラーメッセージのカスタマイズ + * + * @return array + */ + public function messages(): array + { + return [ + 'current_password.required' => '当前パスワードを入力してください。', + 'current_password.min' => '当前パスワードは8文字以上です。', + 'current_password.max' => '当前パスワードは64文字以下です。', + + 'password.required' => '新パスワードを入力してください。', + 'password.min' => '新パスワードは8文字以上です。', + 'password.max' => '新パスワードは64文字以下です。', + 'password.regex' => '新パスワードは英数字と記号のみ使用できます。', + + 'password_confirmation.required' => '新パスワード確認を入力してください。', + 'password_confirmation.same' => '新パスワードと新パスワード確認は一致する必要があります。', + ]; + } +} diff --git a/app/Models/Ope.php b/app/Models/Ope.php index e54af2d..b41fe40 100644 --- a/app/Models/Ope.php +++ b/app/Models/Ope.php @@ -61,6 +61,8 @@ class Ope extends Authenticatable 'email_otp_expires_at', 'email_otp_last_sent_at', 'email_otp_verified_at', + // パスワード変更関連フィールド(定期パスワード変更) + 'ope_pass_changed_at', ]; /** diff --git a/bootstrap/app.php b/bootstrap/app.php index d0f5fe1..52ac71d 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -25,6 +25,7 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->alias([ 'check.city.access' => \App\Http\Middleware\CheckCityAccess::class, 'ensure.otp.verified' => \App\Http\Middleware\EnsureOtpVerified::class, + 'check.password.change.required' => \App\Http\Middleware\CheckPasswordChangeRequired::class, ]); }) ->withExceptions(function (Exceptions $exceptions) { diff --git a/resources/views/auth/password-change-success.blade.php b/resources/views/auth/password-change-success.blade.php new file mode 100644 index 0000000..5616a94 --- /dev/null +++ b/resources/views/auth/password-change-success.blade.php @@ -0,0 +1,159 @@ + + + + + + + パスワード変更完了 - So-Manager + + + + + +
+ {{-- 成功アイコン --}} +
+ + {{-- 成功タイトル --}} +
パスワード変更完了
+ + {{-- 成功メッセージ --}} +
+ パスワードが正常に変更されました。
+ システムは安全に保護されています。 +
+ + {{-- カウントダウン --}} +
+ 自動的にログイン画面に戻ります:
+
+ 10 秒 +
+
+ + {{-- 手動でログイン画面に戻るボタン --}} + + + {{-- 補足テキスト --}} +
+ ※ 自動的にログイン画面に遷移しない場合は、上記ボタンをクリックしてください。 +
+
+ + + + + diff --git a/resources/views/auth/password-change.blade.php b/resources/views/auth/password-change.blade.php new file mode 100644 index 0000000..03f133f --- /dev/null +++ b/resources/views/auth/password-change.blade.php @@ -0,0 +1,259 @@ + + + + + + + パスワード変更 - So-Manager + + + + + +
+ {{-- パネルタイトル --}} +
So-Manager 管理パネル
+ + {{-- バリデーションエラー表示 --}} + @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + + {{-- 警告メッセージ --}} +
+

+ セキュリティを維持するため、 + + パスワードの有効期限が切れました(3ヶ月)。
+
+ + 新しいパスワードへの変更をお願いいたします。
+
+ 安全にご利用いただくため、定期的なパスワード更新が必要です。
+ 変更後、引き続きシステムをご利用いただけます。 +

+
+ + {{-- パスワード変更フォーム --}} +
+ @csrf + + {{-- 当前パスワード --}} +
+ + + @error('current_password') +
{{ $message }}
+ @enderror +
+ + {{-- 新パスワード --}} +
+ + ?/\\|`~]+"> + @error('password') +
{{ $message }}
+ @enderror +
+ + {{-- 新パスワード確認 --}} +
+ + ?/\\|`~]+"> + @error('password_confirmation') +
{{ $message }}
+ @enderror +
+ + {{-- 要件説明 --}} +
+ ※新しいパスワードは、以下の条件を満たす必要があります。 +
    +
  • 8文字以上64文字以内
  • +
  • 半角英数字および記号のみ使用可能
  • +
  • 現在のパスワードと同一のものは使用できません
  • +
+
+ + {{-- Hidden フィールド(設計書に記載) --}} + + + + {{-- 送信ボタン & キャンセルボタン --}} +
+ + + ログイン画面へ + +
+
+
+ + + + + diff --git a/routes/web.php b/routes/web.php index 17f944c..b082f9f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -90,10 +90,21 @@ Route::middleware('auth')->group(function () { Route::post('/resend', [EmailOtpController::class, 'resend'])->name('resend'); }); + // パスワード変更ルート(OTP チェック対象外、有効期限チェック対象) + // 定期的なパスワード変更が必須(3ヶ月ごと) + Route::prefix('password')->name('password.')->group(function () { + Route::get('/change', [App\Http\Controllers\Auth\PasswordChangeController::class, 'showChangeForm'])->name('change.show'); + Route::post('/change', [App\Http\Controllers\Auth\PasswordChangeController::class, 'updatePassword'])->name('change.update'); + Route::get('/success', [App\Http\Controllers\Auth\PasswordChangeController::class, 'showSuccessPage'])->name('change.success'); + })->middleware('check.password.change.required'); + // 以下のすべてのルートに OTP 認証チェックミドルウェアを適用 Route::middleware('ensure.otp.verified')->group(function () { // ダッシュボード(ホーム画面) - Route::get('/home', [App\Http\Controllers\HomeController::class, 'index'])->name('home'); + // パスワード有効期限チェック対象:3ヶ月超過時は強制リダイレクト + Route::get('/home', [App\Http\Controllers\HomeController::class, 'index']) + ->name('home') + ->middleware('check.password.change.required'); // Laravel 12 移行時の一時的な占位符路由 // 他の開発者が継続して開発できるように、エラーを防ぐための仮ルート定義