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()
{
$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')]
@ -70,6 +93,7 @@ class AuthController extends Controller
'data' => [
'user' => $user?->load('roles'),
'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->force_password_change = false;
$user->save();
$this->auditLog($request, 'password_update');

View File

@ -148,6 +148,7 @@ class UserController extends Controller
'email' => trim($this->firstMatchedValue($normalizedRow, ['email', 'mail', '邮箱'])),
'phone' => trim($this->firstMatchedValue($normalizedRow, ['phone', 'mobile', '手机号', '手机', '电话'])),
'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, [
@ -387,6 +388,16 @@ class UserController extends Controller
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
{
$zip = new \ZipArchive;
@ -418,9 +429,9 @@ class UserController extends Controller
.'</Relationships>';
$rows = [
['nickname', 'email', 'phone', 'password', 'role_ids'],
['张三', 'zhangsan@example.com', '13800138000', 'Pass@123456', '1'],
['李四', 'lisi@example.com', '13900139000', 'Pass@123456', '1,2'],
['nickname', 'email', 'phone', 'password', 'role_ids', 'force_password_change'],
['张三', 'zhangsan@example.com', '13800138000', 'Pass@123456', '1', '0'],
['李四', 'lisi@example.com', '13900139000', 'Pass@123456', '1,2', '1'],
];
$sheetData = '';

View File

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

View File

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