feat(用户安全): 支持要求更改密码并强制登录后改密

- 新增 users.force_password_change 字段与迁移

- 用户新增/编辑/批量导入支持要求更改密码

- 登录后未改密用户仅允许访问改密相关接口
This commit is contained in:
Boen_Shi 2026-04-30 14:41:11 +08:00
parent e10f050dfc
commit 777c682a4e
6 changed files with 69 additions and 4 deletions

View File

@ -16,6 +16,29 @@ class AuthController extends Controller
public function __construct() public function __construct()
{ {
$this->middleware('auth:api')->except('login'); $this->middleware('auth:api')->except('login');
$this->middleware(function (Request $request, \Closure $next) {
/** @var User|null $user */
$user = Auth::guard('api')->user();
if (! $user || ! $user->force_password_change) {
return $next($request);
}
$allowed = [
'api/auth/me',
'api/auth/password',
'api/auth/logout',
];
if (in_array($request->path(), $allowed, true)) {
return $next($request);
}
return response()->json([
'code' => 423,
'message' => '请先修改密码后再继续操作',
'data' => ['force_password_change' => true],
], 423);
})->except('login');
} }
#[Apidoc\Title('登录'), Apidoc\Method('POST'), Apidoc\Url('/auth/login')] #[Apidoc\Title('登录'), Apidoc\Method('POST'), Apidoc\Url('/auth/login')]
@ -70,6 +93,7 @@ class AuthController extends Controller
'data' => [ 'data' => [
'user' => $user?->load('roles'), 'user' => $user?->load('roles'),
'permissions' => $user?->getAllPermissions()->pluck('name')->values(), 'permissions' => $user?->getAllPermissions()->pluck('name')->values(),
'force_password_change' => (bool) ($user?->force_password_change ?? false),
], ],
]); ]);
} }
@ -104,6 +128,7 @@ class AuthController extends Controller
]); ]);
$user->password = $validated['password']; $user->password = $validated['password'];
$user->force_password_change = false;
$user->save(); $user->save();
$this->auditLog($request, 'password_update'); $this->auditLog($request, 'password_update');

View File

@ -148,6 +148,7 @@ class UserController extends Controller
'email' => trim($this->firstMatchedValue($normalizedRow, ['email', 'mail', '邮箱'])), 'email' => trim($this->firstMatchedValue($normalizedRow, ['email', 'mail', '邮箱'])),
'phone' => trim($this->firstMatchedValue($normalizedRow, ['phone', 'mobile', '手机号', '手机', '电话'])), 'phone' => trim($this->firstMatchedValue($normalizedRow, ['phone', 'mobile', '手机号', '手机', '电话'])),
'password' => trim($this->firstMatchedValue($normalizedRow, ['password', 'passwd', 'pwd', '密码'])), 'password' => trim($this->firstMatchedValue($normalizedRow, ['password', 'passwd', 'pwd', '密码'])),
'force_password_change' => $this->parseBooleanValue($this->firstMatchedValue($normalizedRow, ['force_password_change', 'require_password_change', '要求更改密码', 'force_change_password'])),
]; ];
$validator = Validator::make($payload, [ $validator = Validator::make($payload, [
@ -387,6 +388,16 @@ class UserController extends Controller
return ''; return '';
} }
private function parseBooleanValue(string $value): bool
{
$normalized = strtolower(trim($value));
if ($normalized === '') {
return false;
}
return in_array($normalized, ['1', 'true', 'yes', 'y', 'on', '是', '真'], true);
}
private function buildUsersImportTemplateXlsx(string $xlsxPath): void private function buildUsersImportTemplateXlsx(string $xlsxPath): void
{ {
$zip = new \ZipArchive; $zip = new \ZipArchive;
@ -418,9 +429,9 @@ class UserController extends Controller
.'</Relationships>'; .'</Relationships>';
$rows = [ $rows = [
['nickname', 'email', 'phone', 'password', 'role_ids'], ['nickname', 'email', 'phone', 'password', 'role_ids', 'force_password_change'],
['张三', 'zhangsan@example.com', '13800138000', 'Pass@123456', '1'], ['张三', 'zhangsan@example.com', '13800138000', 'Pass@123456', '1', '0'],
['李四', 'lisi@example.com', '13900139000', 'Pass@123456', '1,2'], ['李四', 'lisi@example.com', '13900139000', 'Pass@123456', '1,2', '1'],
]; ];
$sheetData = ''; $sheetData = '';

View File

@ -18,6 +18,7 @@ class StoreUserRequest extends FormRequest
'email' => ['required', 'email', 'max:255', 'unique:users,email'], 'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'phone' => ['nullable', 'string', 'max:32', 'unique:users,phone'], 'phone' => ['nullable', 'string', 'max:32', 'unique:users,phone'],
'password' => ['required', 'string', 'min:6'], 'password' => ['required', 'string', 'min:6'],
'force_password_change' => ['sometimes', 'boolean'],
'role_ids' => ['sometimes', 'array'], 'role_ids' => ['sometimes', 'array'],
'role_ids.*' => ['integer', 'exists:roles,id'], 'role_ids.*' => ['integer', 'exists:roles,id'],
]; ];

View File

@ -21,6 +21,7 @@ class UpdateUserRequest extends FormRequest
'email' => ['sometimes', 'required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($userId)], 'email' => ['sometimes', 'required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($userId)],
'phone' => ['nullable', 'string', 'max:32', Rule::unique('users', 'phone')->ignore($userId)], 'phone' => ['nullable', 'string', 'max:32', Rule::unique('users', 'phone')->ignore($userId)],
'password' => ['sometimes', 'required', 'string', 'min:6'], 'password' => ['sometimes', 'required', 'string', 'min:6'],
'force_password_change' => ['sometimes', 'boolean'],
'role_ids' => ['sometimes', 'array'], 'role_ids' => ['sometimes', 'array'],
'role_ids.*' => ['integer', 'exists:roles,id'], 'role_ids.*' => ['integer', 'exists:roles,id'],
]; ];

View File

@ -3,8 +3,8 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles; use Spatie\Permission\Traits\HasRoles;
@ -23,6 +23,7 @@ class User extends Authenticatable implements JWTSubject
'email', 'email',
'phone', 'phone',
'password', 'password',
'force_password_change',
]; ];
protected $hidden = [ protected $hidden = [
@ -62,6 +63,7 @@ class User extends Authenticatable implements JWTSubject
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'force_password_change' => 'boolean',
]; ];
} }
} }

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('force_password_change')
->default(false)
->after('password')
->comment('是否要求下次登录后必须修改密码');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('force_password_change');
});
}
};