明确文章创作核心要求
GEO 优化系统的本质是企业权威信息的沉淀与分发器。它存储企业的品牌信息、产品参数、联系方式、资质证明、经营地址等敏感数据,通过内容分发渠道将这些信息推送到数十个平台。一旦安全防线被突破,后果不仅是数据泄露,更可能是被篡改的企业信息在 AI 搜索中被引用,对品牌信誉造成不可逆的损害。对于选择源码私有化部署的政企客户,安全架构的严谨程度直接决定了系统能否通过等保测评和内部安全审计。
本文从一次典型的安全审计场景切入,完整拆解旗引科技 GEO 系统的安全架构,包括身份认证、权限控制、数据加密、请求防护、合规审核、审计追溯六个核心模块。每个模块的分析都基于源码中的实际实现,聚焦安全模型、加密算法、防护机制和工程决策。
一、安全架构总览:纵深防御体系
旗引科技 GEO 系统的安全架构遵循 "纵深防御"(Defense in Depth)原则,在网络层、应用层、数据层、业务层四个层面分别部署安全控制,任何单一防线被突破都不会导致系统整体沦陷。
┌──────────────────────────────────────────────────────────────────┐
│ 网络层 (Network Layer) │
│ HTTPS强制 │ WAF规则引擎 │ IP黑白名单 │ 地域访问控制 │ DDoS防护 │
└──────────────────────────────┬───────────────────────────────────┘
│
┌──────────────────────────────▼───────────────────────────────────┐
│ 应用层 (Application Layer) │
│ 身份认证(JWT+2FA) │ RBAC权限 │ 接口限流 │ SQL注入防护 │ XSS过滤 │
│ CSRF防护 │ 文件上传校验 │ 敏感操作二次确认 │ 会话安全 │
└──────────────────────────────┬───────────────────────────────────┘
│
┌──────────────────────────────▼───────────────────────────────────┐
│ 数据层 (Data Layer) │
│ 字段级加密 │ 敏感数据脱敏 │ 密钥管理(KMS) │ 数据库访问控制 │
│ 备份加密 │ 数据导出审批 │ 数据保留策略 │
└──────────────────────────────┬───────────────────────────────────┘
│
┌──────────────────────────────▼───────────────────────────────────┐
│ 业务层 (Business Layer) │
│ 内容合规审核 │ 违禁词拦截 │ 实名+企业双认证 │ 操作审计日志 │
│ 数据变更追溯 │ 登录异常检测 │ 权限变更审批 │
└──────────────────────────────────────────────────────────────────┘
这个架构的设计有几个关键原则。第一,默认拒绝 —— 所有访问默认被拒绝,只有明确授权的才能通过。第二,最小权限 —— 每个用户和服务只拥有完成其职责所必需的最小权限。第三,全程可追溯 —— 所有敏感操作都有审计日志,支持事后追溯。第四,数据不出域 —— 源码部署版本的所有数据处理在企业自有服务器内完成,敏感数据不离开企业网络边界。第五,合规先行 —— 内容发布前必须经过合规审核,从源头防止违规内容流出。
二、身份认证体系:JWT 与双因素认证
身份认证是安全架构的第一道门。旗引科技 GEO 系统采用了 JWT(JSON Web Token)作为无状态认证机制,配合双因素认证(2FA)和会话管理,构建了多层次的身份验证体系。
<?php
// app/Support/Auth/JWTManager.php
namespace App\Support\Auth;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\ExpiredException;
use Firebase\JWT\SignatureInvalidException;
use Illuminate\Support\Facades\Cache;
class JWTManager
{
/** Token类型 */
public const TYPE_ACCESS = 'access';
public const TYPE_REFRESH = 'refresh';
/** 算法 */
private const ALGORITHM = 'RS256'; // RSA非对称签名
/** 有效期 */
private const ACCESS_TTL = 7200; // 2小时
private const REFRESH_TTL = 1209600; // 14天
private string $privateKey;
private string $publicKey;
private string $issuer;
public function __construct()
{
$this->privateKey = file_get_contents(config('auth.jwt.private_key_path'));
$this->publicKey = file_get_contents(config('auth.jwt.public_key_path'));
$this->issuer = config('app.url');
}
/**
* 签发访问令牌
*/
public function issueAccessToken(int $userId, array $claims = []): string
{
$now = time();
$jti = bin2hex(random_bytes(16)); // 唯一Token ID 用于黑名单
$payload = array_merge([
'iss' => $this->issuer,
'sub' => $userId,
'aud' => 'geo-api',
'iat' => $now,
'nbf' => $now,
'exp' => $now + self::ACCESS_TTL,
'jti' => $jti,
'type' => self::TYPE_ACCESS,
'scope' => $claims['scope'] ?? 'read write',
], $claims);
$token = JWT::encode($payload, $this->privateKey, self::ALGORITHM);
// 记录Token元数据 (用于会话管理和吊销)
Cache::put("jwt:meta:{$jti}", [
'user_id' => $userId,
'type' => self::TYPE_ACCESS,
'issued_at' => $now,
'expires_at' => $now + self::ACCESS_TTL,
'ip' => request()->ip(),
'user_agent' => request()->header('User-Agent'),
], self::ACCESS_TTL);
return $token;
}
/**
* 签发刷新令牌
*/
public function issueRefreshToken(int $userId): string
{
$now = time();
$jti = bin2hex(random_bytes(16));
$payload = [
'iss' => $this->issuer,
'sub' => $userId,
'iat' => $now,
'exp' => $now + self::REFRESH_TTL,
'jti' => $jti,
'type' => self::TYPE_REFRESH,
];
$token = JWT::encode($payload, $this->privateKey, self::ALGORITHM);
// 刷新令牌只存哈希 不存明文
Cache::put("jwt:refresh:{$jti}", [
'user_id' => $userId,
'token_hash' => hash('sha256', $token),
'issued_at' => $now,
'expires_at' => $now + self::REFRESH_TTL,
], self::REFRESH_TTL);
return $token;
}
/**
* 验证并解析Token
*/
public function verify(string $token, string $expectedType = self::TYPE_ACCESS): array
{
try {
$decoded = JWT::decode($token, new Key($this->publicKey, self::ALGORITHM));
$payload = (array)$decoded;
// 验证Token类型
if (($payload['type'] ?? '') !== $expectedType) {
throw new \InvalidArgumentException("Token类型不匹配: 期望{$expectedType}");
}
// 检查是否在黑名单中 (被吊销)
$jti = $payload['jti'] ?? '';
if (Cache::has("jwt:blacklist:{$jti}")) {
throw new TokenRevokedException('Token已被吊销');
}
// 刷新令牌额外验证哈希匹配
if ($expectedType === self::TYPE_REFRESH) {
$meta = Cache::get("jwt:refresh:{$jti}");
if (!$meta || $meta['token_hash'] !== hash('sha256', $token)) {
throw new TokenRevokedException('刷新令牌无效');
}
}
return $payload;
} catch (ExpiredException $e) {
throw new TokenExpiredException('Token已过期', 0, $e);
} catch (SignatureInvalidException $e) {
throw new TokenInvalidException('Token签名无效', 0, $e);
}
}
/**
* 吊销Token (登出)
*/
public function revoke(string $token): void
{
try {
$payload = $this->verify($token);
$jti = $payload['jti'];
$exp = $payload['exp'];
$ttl = max(1, $exp - time());
// 加入黑名单 直到原Token过期
Cache::put("jwt:blacklist:{$jti}", true, $ttl);
// 清除元数据
Cache::forget("jwt:meta:{$jti}");
} catch (\Throwable $e) {
// Token本身无效 无需吊销
}
}
/**
* 刷新令牌轮换 (Refresh Token Rotation)
* 使用一次后立即失效 防止重放攻击
*/
public function rotateRefreshToken(string $oldRefreshToken): array
{
$payload = $this->verify($oldRefreshToken, self::TYPE_REFRESH);
$userId = $payload['sub'];
$oldJti = $payload['jti'];
// 吊销旧刷新令牌
Cache::put("jwt:blacklist:{$oldJti}", true, self::REFRESH_TTL);
Cache::forget("jwt:refresh:{$oldJti}");
// 签发新令牌对
return [
'access_token' => $this->issueAccessToken($userId),
'refresh_token' => $this->issueRefreshToken($userId),
];
}
/**
* 获取用户的所有活跃会话
*/
public function getActiveSessions(int $userId): array
{
// 通过用户ID索引查找所有活跃Token
$keys = Cache::get("jwt:user:{$userId}:tokens", []);
$sessions = [];
foreach ($keys as $jti) {
$meta = Cache::get("jwt:meta:{$jti}");
if ($meta) {
$sessions[] = [
'jti' => $jti,
'ip' => $meta['ip'],
'user_agent' => $meta['user_agent'],
'issued_at' => date('Y-m-d H:i:s', $meta['issued_at']),
'expires_at' => date('Y-m-d H:i:s', $meta['expires_at']),
'is_current' => $jti === $this->currentJti(),
];
}
}
return $sessions;
}
private function currentJti(): ?string
{
return request()->attributes->get('jwt_jti');
}
}
JWT 管理器的实现有几个关键安全设计。
第一,使用 RS256 非对称签名算法。RS256 使用 RSA 私钥签名、公钥验证,私钥只保存在认证服务器,公钥可以分发给所有需要验证 Token 的服务。相比 HS256 对称算法,RS256 避免了签名密钥在多服务间共享导致的泄露风险。源码部署版本中,私钥由企业自行生成和保管,旗引科技无法访问。
第二,Token ID(JTI)与黑名单机制。每个 Token 有唯一的 JTI,登出时将 JTI 加入黑名单,直到 Token 自然过期。这解决了 JWT 无状态机制下无法主动吊销 Token 的问题。黑名单存储在 Redis 中,过期时间与 Token 剩余有效期一致,不会无限增长。
第三,刷新令牌轮换(Refresh Token Rotation)。刷新令牌使用一次后立即失效,同时签发新的刷新令牌。这防止了刷新令牌被窃取后的重放攻击 —— 攻击者即使截获了刷新令牌,也只能使用一次,合法用户的下一次刷新会使攻击者的令牌失效。如果检测到同一个刷新令牌被使用两次,说明令牌可能已泄露,系统会立即吊销该用户的所有会话。
第四,刷新令牌只存哈希。服务端不存储刷新令牌的明文,只存储其 SHA256 哈希。即使 Redis 数据泄露,攻击者也无法获得可用的刷新令牌。
双因素认证为高权限操作提供了额外的安全层:
<?php
// app/Support/Auth/TwoFactorAuthenticator.php
namespace App\Support\Auth;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Mail;
use PragmaRX\Google2FA\Google2FA;
class TwoFactorAuthenticator
{
private Google2FA $google2fa;
/** 2FA验证有效期 */
private const VERIFY_TTL = 300; // 5分钟
/** 2FA会话有效期 */
private const SESSION_TTL = 3600; // 1小时
public function __construct()
{
$this->google2fa = new Google2FA();
}
/**
* 为用户生成2FA密钥和二维码
*/
public function provision(User $user): array
{
$secret = $this->google2fa->generateSecretKey(32);
// 临时存储待确认的密钥
Cache::put("2fa:pending:{$user->id}", $secret, 600);
$qrCodeUrl = $this->google2fa->getQRCodeUrl(
'旗引GEO系统',
$user->email,
$secret
);
return [
'secret' => $secret,
'qr_code_url' => $qrCodeUrl,
'recovery_codes' => $this->generateRecoveryCodes(),
];
}
/**
* 确认并启用2FA
*/
public function enable(User $user, string $oneTimePassword): bool
{
$pendingSecret = Cache::get("2fa:pending:{$user->id}");
if (!$pendingSecret) {
throw new \RuntimeException('2FA配置已过期,请重新开始');
}
// 验证OTP (窗口为1 允许前后1个时间步长的误差)
$valid = $this->google2fa->verifyKey($pendingSecret, $oneTimePassword, 1);
if (!$valid) {
return false;
}
// 保存加密后的密钥到数据库
$user->two_factor_secret = encrypt($pendingSecret);
$user->two_factor_confirmed_at = now();
$user->two_factor_recovery_codes = encrypt(json_encode($this->generateRecoveryCodes()));
$user->save();
Cache::forget("2fa:pending:{$user->id}");
return true;
}
/**
* 验证2FA验证码
*/
public function verify(User $user, string $oneTimePassword): bool
{
if (!$user->two_factor_confirmed_at) {
return true; // 未启用2FA的用户直接通过
}
$secret = decrypt($user->two_factor_secret);
// 防重放: 检查该OTP是否已被使用
$otpHash = hash('sha256', $oneTimePassword . $user->id);
if (Cache::has("2fa:used:{$otpHash}")) {
return false; // OTP已被使用 拒绝重放
}
$valid = $this->google2fa->verifyKey($secret, $oneTimePassword, 1);
if ($valid) {
// 标记该OTP为已使用 (30秒内不能重复使用)
Cache::put("2fa:used:{$otpHash}", true, 30);
// 创建2FA验证会话
$sessionId = bin2hex(random_bytes(16));
Cache::put("2fa:session:{$sessionId}", [
'user_id' => $user->id,
'verified_at' => time(),
], self::SESSION_TTL);
return true;
}
return false;
}
/**
* 检查当前2FA会话是否有效
*/
public function isVerified(int $userId, string $sessionId): bool
{
$session = Cache::get("2fa:session:{$sessionId}");
if (!$session || $session['user_id'] !== $userId) {
return false;
}
return (time() - $session['verified_at']) < self::SESSION_TTL;
}
/**
* 使用恢复码 (当用户丢失认证设备时)
*/
public function useRecoveryCode(User $user, string $code): bool
{
$codes = json_decode(decrypt($user->two_factor_recovery_codes), true);
$codeHash = hash('sha256', $code);
foreach ($codes as &$storedCode) {
if (is_array($storedCode)) {
if ($storedCode['hash'] === $codeHash && !$storedCode['used']) {
$storedCode['used'] = true;
$storedCode['used_at'] = now();
$user->two_factor_recovery_codes = encrypt(json_encode($codes));
$user->save();
return true;
}
}
}
return false;
}
/**
* 生成恢复码
*/
private function generateRecoveryCodes(): array
{
$codes = [];
for ($i = 0; $i < 8; $i++) {
$code = strtoupper(substr(bin2hex(random_bytes(8)), 0, 16));
$codes[] = [
'code' => $code,
'hash' => hash('sha256', $code),
'used' => false,
'used_at' => null,
];
}
return $codes;
}
/**
* 禁用2FA (需要当前密码验证)
*/
public function disable(User $user, string $password): bool
{
if (!\Hash::check($password, $user->password)) {
return false;
}
$user->two_factor_secret = null;
$user->two_factor_confirmed_at = null;
$user->two_factor_recovery_codes = null;
$user->save();
return true;
}
}
双因素认证基于 TOTP(基于时间的一次性密码)标准,兼容 Google Authenticator 等主流认证器应用。关键安全设计包括:OTP 防重放(每个 OTP 使用后 30 秒内不能重复使用)、恢复码机制(用户丢失设备时可以用一次性恢复码登录)、密钥加密存储(2FA 密钥使用 Laravel 的加密器加密后存入数据库)、2FA 会话有效期(验证后 1 小时内不需要重复验证)。
三、权限控制:RBAC 与数据行级权限
认证解决了 "你是谁" 的问题,授权解决了 "你能做什么" 的问题。旗引科技 GEO 系统采用了 RBAC(基于角色的访问控制)模型,并在此基础上扩展了数据行级权限,实现了从接口到数据的细粒度权限控制。
<?php
// app/Support/Auth/PermissionManager.php
namespace App\Support\Auth;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
class PermissionManager
{
/** 权限缓存时间 */
private const CACHE_TTL = 3600;
/** 超级管理员角色 - 拥有所有权限 */
private const SUPER_ADMIN_ROLE = 'super_admin';
public function __construct() {}
/**
* 检查用户是否拥有指定权限
*/
public function hasPermission(User $user, string $permission): bool
{
// 超级管理员拥有所有权限
if ($this->isSuperAdmin($user)) {
return true;
}
$permissions = $this->getUserPermissions($user);
return in_array($permission, $permissions) || $this->hasWildcardPermission($permissions, $permission);
}
/**
* 检查用户是否拥有指定角色
*/
public function hasRole(User $user, string $role): bool
{
$roles = $this->getUserRoles($user);
return in_array($role, $roles);
}
/**
* 获取用户的所有权限 (合并角色权限和直接权限)
*/
public function getUserPermissions(User $user): array
{
return Cache::remember("user:permissions:{$user->id}", self::CACHE_TTL, function () use ($user) {
// 从角色获取权限
$rolePermissions = \DB::table('permissions')
->join('role_permissions', 'permissions.id', '=', 'role_permissions.permission_id')
->join('user_roles', 'role_permissions.role_id', '=', 'user_roles.role_id')
->where('user_roles.user_id', $user->id)
->pluck('permissions.name')
->toArray();
// 直接授予用户的权限
$directPermissions = \DB::table('permissions')
->join('user_permissions', 'permissions.id', '=', 'user_permissions.permission_id')
->where('user_permissions.user_id', $user->id)
->pluck('permissions.name')
->toArray();
return array_unique(array_merge($rolePermissions, $directPermissions));
});
}
/**
* 获取用户的所有角色
*/
public function getUserRoles(User $user): array
{
return Cache::remember("user:roles:{$user->id}", self::CACHE_TTL, function () use ($user) {
return \DB::table('roles')
->join('user_roles', 'roles.id', '=', 'user_roles.role_id')
->where('user_roles.user_id', $user->id)
->pluck('roles.name')
->toArray();
});
}
/**
* 通配符权限匹配
* 例如 entity.* 匹配 entity.create, entity.read, entity.update, entity.delete
*/
private function hasWildcardPermission(array $permissions, string $target): bool
{
foreach ($permissions as $perm) {
if (str_ends_with($perm, '.*')) {
$prefix = substr($perm, 0, -2);
if (str_starts_with($target, $prefix)) {
return true;
}
}
}
return false;
}
/**
* 检查数据行级权限
* 判断用户是否可以访问指定企业的数据
*/
public function canAccessEnterprise(User $user, int $enterpriseId): bool
{
if ($this->isSuperAdmin($user)) {
return true;
}
// 检查用户是否属于该企业
return \DB::table('enterprise_users')
->where('user_id', $user->id)
->where('enterprise_id', $enterpriseId)
->where('status', 'active')
->exists();
}
/**
* 获取用户可访问的企业ID列表 (用于数据过滤)
*/
public function getAccessibleEnterpriseIds(User $user): array
{
if ($this->isSuperAdmin($user)) {
return []; // 空数组表示不限制
}
return Cache::remember("user:enterprises:{$user->id}", self::CACHE_TTL, function () use ($user) {
return \DB::table('enterprise_users')
->where('user_id', $user->id)
->where('status', 'active')
->pluck('enterprise_id')
->toArray();
});
}
/**
* 清除用户权限缓存 (权限变更时调用)
*/
public function flushCache(User $user): void
{
Cache::forget("user:permissions:{$user->id}");
Cache::forget("user:roles:{$user->id}");
Cache::forget("user:enterprises:{$user->id}");
}
private function isSuperAdmin(User $user): bool
{
return $this->hasRole($user, self::SUPER_ADMIN_ROLE);
}
}
权限管理器的核心是 RBAC 模型 —— 用户关联角色,角色关联权限,权限控制操作。同时支持直接给用户授予权限(绕过角色),以及通配符权限(如entity.*匹配所有实体操作权限)。权限数据缓存在 Redis 中,避免每次请求都查数据库,权限变更时主动清除缓存。
数据行级权限是 RBAC 的重要补充。RBAC 控制用户能执行什么操作(如 "查看企业信息"),数据行级权限控制用户能看到哪些数据(如 "只能查看自己所属企业的信息")。系统通过getAccessibleEnterpriseIds方法获取用户可访问的企业 ID 列表,在查询时自动过滤。
权限检查通过中间件和策略类实现:
<?php
// app/Http/Middleware/PermissionMiddleware.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\Support\Auth\PermissionManager;
use Symfony\Component\HttpFoundation\Response;
class PermissionMiddleware
{
public function __construct(private PermissionManager $permissions) {}
public function handle(Request $request, Closure $next, string $permission): Response
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => '未认证'], 401);
}
if (!$this->permissions->hasPermission($user, $permission)) {
// 记录权限拒绝审计日志
audit_log('permission_denied', [
'user_id' => $user->id,
'permission' => $permission,
'path' => $request->path(),
'method' => $request->method(),
'ip' => $request->ip(),
]);
return response()->json([
'message' => '权限不足',
'required_permission' => $permission,
], 403);
}
return $next($request);
}
}
<?php
// app/Policies/EntityPolicy.php
namespace App\Policies;
use App\Models\User;
use App\Models\KnowledgeBase\Entity;
use App\Support\Auth\PermissionManager;
class EntityPolicy
{
public function __construct(private PermissionManager $permissions) {}
/**
* 查看实体
*/
public function view(User $user, Entity $entity): bool
{
// 接口权限
if (!$this->permissions->hasPermission($user, 'entity.read')) {
return false;
}
// 数据权限 - 只能查看所属企业的实体
return $this->permissions->canAccessEnterprise($user, $entity->enterprise_id);
}
/**
* 创建实体
*/
public function create(User $user, int $enterpriseId): bool
{
if (!$this->permissions->hasPermission($user, 'entity.create')) {
return false;
}
return $this->permissions->canAccessEnterprise($user, $enterpriseId);
}
/**
* 更新实体
*/
public function update(User $user, Entity $entity): bool
{
if (!$this->permissions->hasPermission($user, 'entity.update')) {
return false;
}
// 已认证的实体修改需要2FA验证
if ($entity->verified_at && !$this->requires2FA($user)) {
return false;
}
return $this->permissions->canAccessEnterprise($user, $entity->enterprise_id);
}
/**
* 删除实体
*/
public function delete(User $user, Entity $entity): bool
{
if (!$this->permissions->hasPermission($user, 'entity.delete')) {
return false;
}
// 已认证实体不可删除 只能下架
if ($entity->verified_at) {
return false;
}
return $this->permissions->canAccessEnterprise($user, $entity->enterprise_id);
}
/**
* 审核实体 (需要更高权限)
*/
public function audit(User $user, Entity $entity): bool
{
return $this->permissions->hasPermission($user, 'entity.audit');
}
private function requires2FA(User $user): bool
{
// 检查2FA会话
$sessionId = request()->header('X-2FA-Session');
if (!$sessionId) {
return false;
}
return app(\App\Support\Auth\TwoFactorAuthenticator::class)
->isVerified($user->id, $sessionId);
}
}
策略类(Policy)将权限检查逻辑从控制器中抽离,每个模型有对应的策略类,定义查看、创建、更新、删除等操作的权限规则。策略类同时检查接口权限和数据权限,并对敏感操作(如修改已认证实体)要求 2FA 验证。这种设计使得权限规则集中管理,易于审计和维护。
数据行级权限在查询层面通过全局作用域(Global Scope)自动应用:
<?php
// app/Scopes/EnterpriseScope.php
namespace App\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
use App\Support\Auth\PermissionManager;
class EnterpriseScope implements Scope
{
/**
* 应用企业数据隔离 - 自动过滤用户无权访问的数据
*/
public function apply(Builder $builder, Model $model): void
{
$user = auth()->user();
if (!$user) {
// 未登录用户不返回任何数据
$builder->whereRaw('1 = 0');
return;
}
$permissionManager = app(PermissionManager::class);
// 超级管理员不限制
if ($permissionManager->hasRole($user, 'super_admin')) {
return;
}
// 普通用户只能看到所属企业的数据
$accessibleIds = $permissionManager->getAccessibleEnterpriseIds($user);
if (empty($accessibleIds)) {
$builder->whereRaw('1 = 0'); // 没有可访问的企业
} else {
$builder->whereIn('enterprise_id', $accessibleIds);
}
}
}
全局作用域在模型启动时自动注册,所有查询都会自动加上企业 ID 过滤条件。这意味着开发者在写业务代码时不需要手动加where('enterprise_id', ...)条件,数据隔离由框架自动保证,避免了遗漏过滤条件导致的数据越权访问。这是一种 "安全默认" 的设计 —— 数据隔离是默认行为,绕过需要显式调用withoutGlobalScope。
四、数据加密与脱敏:从传输到存储
数据安全是 GEO 系统的核心关切。系统存储企业的联系电话、地址、资质证明等敏感信息,这些数据在传输、存储、展示三个环节都需要保护。
<?php
// app/Support/Security/FieldEncrypter.php
namespace App\Support\Security;
use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Facades\Cache;
class FieldEncrypter
{
/** 加密算法 */
private const CIPHER = 'aes-256-gcm';
/** 需要加密的字段配置 */
private const ENCRYPTED_FIELDS = [
'users' => ['phone', 'id_card', 'bank_account'],
'enterprises' => ['contact_phone', 'contact_email', 'business_license_no'],
'entities' => ['contact_phone', 'contact_address_detail'],
'content_chunks' => [],
];
private Encrypter $encrypter;
private KeyRotationManager $keyManager;
public function __construct(KeyRotationManager $keyManager)
{
$this->keyManager = $keyManager;
$this->encrypter = new Encrypter($this->keyManager->getCurrentKey(), self::CIPHER);
}
/**
* 加密字段值
*/
public function encrypt(string $value, string $table, string $field): string
{
if (!$this->isEncryptedField($table, $field)) {
return $value;
}
// 使用当前密钥版本加密
$keyVersion = $this->keyManager->getCurrentVersion();
$encrypted = $this->encrypter->encrypt($value);
// 前缀标记密钥版本 用于密钥轮换时识别
return "v{$keyVersion}:" . $encrypted;
}
/**
* 解密字段值
*/
public function decrypt(string $value, string $table, string $field): string
{
if (!$this->isEncryptedField($table, $field)) {
return $value;
}
// 解析密钥版本前缀
if (preg_match('/^v(\d+):(.+)$/', $value, $m)) {
$keyVersion = (int)$m[1];
$encryptedValue = $m[2];
// 使用对应版本的密钥解密
$key = $this->keyManager->getKeyByVersion($keyVersion);
$decrypter = new Encrypter($key, self::CIPHER);
return $decrypter->decrypt($encryptedValue);
}
// 无前缀 尝试用当前密钥解密 (旧数据)
return $this->encrypter->decrypt($value);
}
/**
* 批量加密模型属性
*/
public function encryptAttributes(array $attributes, string $table): array
{
foreach (self::ENCRYPTED_FIELDS[$table] ?? [] as $field) {
if (isset($attributes[$field]) && is_string($attributes[$field])) {
$attributes[$field] = $this->encrypt($attributes[$field], $table, $field);
}
}
return $attributes;
}
/**
* 批量解密模型属性
*/
public function decryptAttributes(array $attributes, string $table): array
{
foreach (self::ENCRYPTED_FIELDS[$table] ?? [] as $field) {
if (isset($attributes[$field]) && is_string($attributes[$field])) {
$attributes[$field] = $this->decrypt($attributes[$field], $table, $field);
}
}
return $attributes;
}
/**
* 判断字段是否需要加密
*/
public function isEncryptedField(string $table, string $field): bool
{
return in_array($field, self::ENCRYPTED_FIELDS[$table] ?? []);
}
/**
* 模糊搜索加密字段 (使用哈希索引)
* 加密后的数据无法直接LIKE搜索 通过维护哈希索引实现精确匹配
*/
public function buildSearchableHash(string $value, string $table, string $field): string
{
// 使用HMAC生成可搜索的哈希值
$searchKey = $this->keyManager->getSearchKey();
return hash_hmac('sha256', $value, $searchKey);
}
}
字段加密器实现了应用层的字段级加密。关键设计包括:
第一,AES-256-GCM 算法。GCM 模式提供了机密性和完整性校验(AEAD),比 CBC 模式更安全,能检测密文被篡改的情况。
第二,密钥版本前缀。每个加密值都带有密钥版本号前缀(如v2:encrypted_data),使得密钥轮换成为可能。当主密钥需要更换时,新数据用新密钥加密,旧数据仍然可以用旧密钥解密,系统可以在后台逐步将旧数据重新加密。
第三,可搜索哈希。加密后的数据无法直接用 SQL 的 LIKE 搜索,系统通过 HMAC-SHA256 为加密字段生成一个可搜索的哈希索引列,精确匹配时用哈希值查询。这是一种在加密和可搜索性之间的折中方案。
密钥管理是加密体系的核心:
<?php
// app/Support/Security/KeyRotationManager.php
namespace App\Support\Security;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class KeyRotationManager
{
/** 密钥缓存 */
private array $keyCache = [];
/**
* 获取当前加密密钥
*/
public function getCurrentKey(): string
{
$version = $this->getCurrentVersion();
return $this->getKeyByVersion($version);
}
/**
* 获取当前密钥版本号
*/
public function getCurrentVersion(): int
{
return Cache::remember('encryption:current_version', 3600, function () {
return (int)DB::table('encryption_keys')
->where('is_active', true)
->value('version') ?? 1;
});
}
/**
* 按版本获取密钥
*/
public function getKeyByVersion(int $version): string
{
if (isset($this->keyCache[$version])) {
return $this->keyCache[$version];
}
$keyRecord = DB::table('encryption_keys')
->where('version', $version)
->first();
if (!$keyRecord) {
throw new \RuntimeException("加密密钥版本不存在: {$version}");
}
// 密钥本身也是加密存储的 使用根密钥解密
$rootKey = $this->getRootKey();
$decrypted = openssl_decrypt(
$keyRecord->encrypted_key,
'aes-256-gcm',
$rootKey,
0,
$keyRecord->iv,
$keyRecord->tag
);
$this->keyCache[$version] = $decrypted;
return $decrypted;
}
/**
* 获取搜索用HMAC密钥
*/
public function getSearchKey(): string
{
return Cache::remember('encryption:search_key', 3600, function () {
return DB::table('encryption_keys')
->where('is_active', true)
->value('search_key');
});
}
/**
* 轮换密钥 - 创建新版本密钥
*/
public function rotate(): int
{
$currentVersion = $this->getCurrentVersion();
$newVersion = $currentVersion + 1;
// 生成新密钥
$newKey = random_bytes(32);
$iv = random_bytes(12);
$rootKey = $this->getRootKey();
// 用根密钥加密新密钥
$tag = '';
$encrypted = openssl_encrypt(
$newKey,
'aes-256-gcm',
$rootKey,
0,
$iv,
$tag
);
DB::transaction(function () use ($newVersion, $encrypted, $iv, $tag, $newKey) {
// 停用旧密钥
DB::table('encryption_keys')
->where('is_active', true)
->update(['is_active' => false]);
// 插入新密钥
DB::table('encryption_keys')->insert([
'version' => $newVersion,
'encrypted_key' => $encrypted,
'iv' => base64_encode($iv),
'tag' => base64_encode($tag),
'search_key' => hash_hmac('sha256', $newKey, 'geo-search-salt'),
'is_active' => true,
'created_at' => now(),
]);
});
// 清除缓存
Cache::forget('encryption:current_version');
Cache::forget('encryption:search_key');
$this->keyCache = [];
return $newVersion;
}
/**
* 获取根密钥 - 从环境变量或配置文件读取
* 根密钥不存入数据库 是整个加密体系的信任根
*/
private function getRootKey(): string
{
$rootKey = env('ENCRYPTION_ROOT_KEY');
if (!$rootKey) {
throw new \RuntimeException('未配置加密根密钥 ENCRYPTION_ROOT_KEY');
}
return base64_decode($rootKey);
}
}
密钥管理采用了分层密钥架构。根密钥(Root Key)是整个加密体系的信任根,从环境变量读取,不存入数据库。数据加密密钥(DEK)用根密钥加密后存入数据库,支持多版本和轮换。这种设计使得根密钥可以独立更换(只需要重新加密所有 DEK),而不需要重新加密所有业务数据。
敏感数据在展示时需要脱敏:
<?php
// app/Support/Security/DataMasker.php
namespace App\Support\Security;
class DataMasker
{
/**
* 手机号脱敏: 138****8000
*/
public function maskPhone(?string $phone): ?string
{
if (!$phone || strlen($phone) < 7) {
return $phone;
}
return substr($phone, 0, 3) . '****' . substr($phone, -4);
}
/**
* 邮箱脱敏: te**@example.com
*/
public function maskEmail(?string $email): ?string
{
if (!$email || strpos($email, '@') === false) {
return $email;
}
[$name, $domain] = explode('@', $email, 2);
$maskedName = strlen($name) <= 2
? $name[0] . '*'
: substr($name, 0, 2) . str_repeat('*', max(1, strlen($name) - 2));
return $maskedName . '@' . $domain;
}
/**
* 身份证号脱敏: 110***********1234
*/
public function maskIdCard(?string $idCard): ?string
{
if (!$idCard || strlen($idCard) < 10) {
return $idCard;
}
return substr($idCard, 0, 3) . str_repeat('*', strlen($idCard) - 7) . substr($idCard, -4);
}
/**
* 银行卡号脱敏: 6222 **** **** 1234
*/
public function maskBankCard(?string $card): ?string
{
if (!$card || strlen($card) < 8) {
return $card;
}
$card = preg_replace('/\s+/', '', $card);
return substr($card, 0, 4) . ' **** **** ' . substr($card, -4);
}
/**
* 地址脱敏 - 保留到区县 隐藏详细地址
*/
public function maskAddress(?string $address): ?string
{
if (!$address) {
return $address;
}
// 匹配省市区县部分
if (preg_match('/^(.+?(?:省|市|区|县))/', $address, $m)) {
return $m[1] . '***';
}
return mb_substr($address, 0, 6) . '***';
}
/**
* 姓名脱敏: 张*
*/
public function maskName(?string $name): ?string
{
if (!$name) {
return $name;
}
$len = mb_strlen($name);
if ($len <= 1) {
return $name;
}
return mb_substr($name, 0, 1) . str_repeat('*', $len - 1);
}
/**
* 根据字段类型自动脱敏
*/
public function maskByField(string $field, ?string $value): ?string
{
return match(true) {
str_contains($field, 'phone') || str_contains($field, 'mobile') => $this->maskPhone($value),
str_contains($field, 'email') => $this->maskEmail($value),
str_contains($field, 'id_card') => $this->maskIdCard($value),
str_contains($field, 'bank') => $this->maskBankCard($value),
str_contains($field, 'address') => $this->maskAddress($value),
str_contains($field, 'name') && !str_contains($field, 'company') => $this->maskName($value),
default => $value,
};
}
/**
* 批量脱敏模型属性
* @param array $attributes
* @param array $sensitiveFields 需要脱敏的字段列表
*/
public function maskAttributes(array $attributes, array $sensitiveFields): array
{
foreach ($sensitiveFields as $field) {
if (isset($attributes[$field])) {
$attributes[$field] = $this->maskByField($field, $attributes[$field]);
}
}
return $attributes;
}
}
数据脱敏器在 API 响应和日志输出时自动应用,确保敏感数据不会以明文形式出现在前端界面和日志文件中。脱敏规则按字段类型自动匹配,手机号保留前 3 后 4,邮箱保留前 2 位和域名,身份证保留前 3 后 4,地址保留到区县级别。
加密字段在 Eloquent 模型中通过访问器和修改器自动加解密:
<?php
// app/Models/Traits/Encryptable.php
namespace App\Models\Traits;
use App\Support\Security\FieldEncrypter;
trait Encryptable
{
/**
* 重写getAttribute - 读取时自动解密
*/
public function getAttribute($key)
{
$value = parent::getAttribute($key);
if ($value !== null && is_string($value) && $this->isEncryptedField($key)) {
$encrypter = app(FieldEncrypter::class);
return $encrypter->decrypt($value, $this->getTable(), $key);
}
return $value;
}
/**
* 重写setAttribute - 写入时自动加密
*/
public function setAttribute($key, $value)
{
if ($value !== null && is_string($value) && $this->isEncryptedField($key)) {
$encrypter = app(FieldEncrypter::class);
$value = $encrypter->encrypt($value, $this->getTable(), $key);
}
return parent::setAttribute($key, $value);
}
/**
* 获取需要加密的字段列表 (子类定义)
*/
protected function getEncryptedFields(): array
{
return $this->encrypted ?? [];
}
private function isEncryptedField(string $key): bool
{
return in_array($key, $this->getEncryptedFields());
}
}
<?php
// app/Models/Enterprise.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use App\Models\Traits\Encryptable;
use App\Scopes\EnterpriseScope;
class Enterprise extends Model
{
use Encryptable;
/** 需要加密的字段 */
protected $encrypted = ['contact_phone', 'contact_email', 'business_license_no'];
/** 需要脱敏的字段 (API输出时) */
protected $masked = ['contact_phone', 'contact_email'];
protected static function booted(): void
{
// 自动应用企业数据隔离作用域
// static::addGlobalScope(new EnterpriseScope());
}
/**
* 获取脱敏后的联系电话
*/
public function getMaskedContactPhoneAttribute(): ?string
{
return app(\App\Support\Security\DataMasker::class)
->maskPhone($this->attributes['contact_phone'] ?? null);
}
}
Encryptable trait 通过重写 Eloquent 的getAttribute和setAttribute方法,实现了加密字段的透明加解密。业务代码直接读写明文字符串,底层自动处理加密和解密。这种设计使得加密逻辑对业务代码完全透明,开发者不需要在每个地方手动调用加密方法。
五、请求安全防护:WAF 与注入防护
应用层的请求防护是抵御常见 Web 攻击的关键。旗引科技 GEO 系统实现了内置的 WAF(Web 应用防火墙)规则引擎,结合 Laravel 框架的防护机制,覆盖了 SQL 注入、XSS、CSRF、文件上传漏洞、暴力破解等常见攻击面。
<?php
// app/Support/Security/WAF.php
namespace App\Support\Security;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class WAF
{
/** WAF规则集 */
private const RULES = [
// SQL注入特征
'sql_injection' => [
'/\b(union\s+select|select\s+.+\s+from|insert\s+into|delete\s+from|drop\s+table|update\s+.+\s+set)\b/i',
"/(\b(or|and)\b\s+['"]?\d+['"]?\s*=\s*['"]?\d+)/i",
'/(--|#|/*|*/|;)/',
"/\b(exec|execute|sp_|xp_|declare|cast|convert)\b/i",
],
// XSS特征
'xss' => [
'/<\s*script[^>]*>/i',
'/javascript\s*:/i',
'/on\w+\s*=/i', // onerror=, onclick= 等
'/<\s*iframe[^>]*>/i',
'/<\s*img[^>]+onerror/i',
'/eval\s*(/i',
],
// 路径遍历
'path_traversal' => [
'/..//',
'/..\\/',
'/%2e%2e%2f/i',
],
// 命令注入
'command_injection' => [
'/[;|&`]\s*(ls|cat|rm|wget|curl|bash|sh|nc|python|perl)\b/i',
'/$(/',
'/`[^`]+`/',
],
// SSRF特征
'ssrf' => [
'/\b(169.254.169.254|metadata.google.internal|10.0.0.|192.168.|172.(1[6-9]|2[0-9]|3[01]).)/i',
],
];
/** 最大请求体大小 (10MB) */
private const MAX_BODY_SIZE = 10485760;
public function __construct() {}
/**
* 检查请求是否命中WAF规则
* @return array ['blocked' => bool, 'rule' => string, 'details' => array]
*/
public function inspect(Request $request): array
{
// 1. 请求体大小检查
if ($request->server('CONTENT_LENGTH') > self::MAX_BODY_SIZE) {
return $this->block('body_size_exceeded', [
'size' => $request->server('CONTENT_LENGTH'),
'limit' => self::MAX_BODY_SIZE,
]);
}
// 2. 检查请求头
$headerResult = $this->inspectHeaders($request);
if ($headerResult['blocked']) {
return $headerResult;
}
// 3. 检查URL参数
$queryResult = $this->inspectArray($request->query->all(), 'query');
if ($queryResult['blocked']) {
return $queryResult;
}
// 4. 检查POST数据
$postResult = $this->inspectArray($request->request->all(), 'post');
if ($postResult['blocked']) {
return $postResult;
}
// 5. 检查JSON body
if ($request->isJson()) {
$jsonResult = $this->inspectArray($request->json()->all(), 'json');
if ($jsonResult['blocked']) {
return $jsonResult;
}
}
// 6. 检查上传文件名
$fileResult = $this->inspectFiles($request);
if ($fileResult['blocked']) {
return $fileResult;
}
return ['blocked' => false];
}
private function inspectHeaders(Request $request): array
{
$suspiciousHeaders = ['User-Agent', 'Referer', 'X-Forwarded-For'];
foreach ($suspiciousHeaders as $header) {
$value = $request->header($header);
if ($value) {
$match = $this->matchRules($value);
if ($match) {
return $this->block($match['rule'], [
'header' => $header,
'value_preview' => substr($value, 0, 100),
]);
}
}
}
return ['blocked' => false];
}
private function inspectArray(array $data, string $source): array
{
foreach ($data as $key => $value) {
if (is_array($value)) {
$result = $this->inspectArray($value, "{$source}.{$key}");
if ($result['blocked']) {
return $result;
}
} elseif (is_string($value)) {
$match = $this->matchRules($value);
if ($match) {
return $this->block($match['rule'], [
'source' => $source,
'field' => $key,
'value_preview' => substr($value, 0, 100),
]);
}
}
}
return ['blocked' => false];
}
private function inspectFiles(Request $request): array
{
foreach ($request->allFiles() as $field => $file) {
if (is_array($file)) {
foreach ($file as $f) {
$result = $this->checkFile($f, $field);
if ($result['blocked']) return $result;
}
} else {
$result = $this->checkFile($file, $field);
if ($result['blocked']) return $result;
}
}
return ['blocked' => false];
}
private function checkFile($file, string $field): array
{
if (!$file || !$file->isValid()) {
return ['blocked' => false];
}
$filename = $file->getClientOriginalName();
$extension = strtolower($file->getClientOriginalExtension());
// 危险扩展名
$dangerousExtensions = ['php', 'phtml', 'php3', 'php4', 'php5', 'asp', 'aspx', 'jsp', 'cgi', 'pl', 'py', 'sh', 'exe', 'bat'];
if (in_array($extension, $dangerousExtensions)) {
return $this->block('dangerous_file_extension', [
'field' => $field,
'filename' => $filename,
'extension' => $extension,
]);
}
// 文件名中的路径遍历
if (preg_match('/..[/\]/', $filename)) {
return $this->block('path_traversal_filename', [
'field' => $field,
'filename' => $filename,
]);
}
// 文件大小检查
if ($file->getSize() > self::MAX_BODY_SIZE) {
return $this->block('file_size_exceeded', [
'field' => $field,
'filename' => $filename,
'size' => $file->getSize(),
]);
}
return ['blocked' => false];
}
private function matchRules(string $value): ?array
{
foreach (self::RULES as $ruleName => $patterns) {
foreach ($patterns as $pattern) {
if (preg_match($pattern, $value)) {
return ['rule' => $ruleName, 'pattern' => $pattern];
}
}
}
return null;
}
private function block(string $rule, array $details): array
{
// 记录WAF拦截日志
Cache::increment('waf:blocked:' . date('Ymd'));
Cache::increment('waf:blocked:rule:' . $rule);
return [
'blocked' => true,
'rule' => $rule,
'details' => $details,
'timestamp' => time(),
'request_id' => request()->header('X-Request-ID'),
];
}
/**
* 获取WAF统计数据
*/
public function getStats(): array
{
$today = date('Ymd');
return [
'today_blocked' => (int)Cache::get("waf:blocked:{$today}", 0),
'by_rule' => [
'sql_injection' => (int)Cache::get('waf:blocked:rule:sql_injection', 0),
'xss' => (int)Cache::get('waf:blocked:rule:xss', 0),
'path_traversal' => (int)Cache::get('waf:blocked:rule:path_traversal', 0),
'command_injection' => (int)Cache::get('waf:blocked:rule:command_injection', 0),
'ssrf' => (int)Cache::get('waf:blocked:rule:ssrf', 0),
],
];
}
}
WAF 规则引擎基于特征匹配,覆盖了 SQL 注入、XSS、路径遍历、命令注入、SSRF 五类常见攻击。检查范围包括请求头、URL 参数、POST 数据、JSON Body、上传文件。命中规则的请求被拦截并记录统计。WAF 中间件在路由处理之前执行,确保恶意请求在到达业务逻辑之前被阻断。
<?php
// app/Http/Middleware/WAFMiddleware.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\Support\Security\WAF;
use App\Support\Logging\StructuredLogger;
class WAFMiddleware
{
public function __construct(
private WAF $waf,
private StructuredLogger $logger
) {}
public function handle(Request $request, Closure $next)
{
$result = $this->waf->inspect($request);
if ($result['blocked']) {
// 记录拦截日志
$this->logger->warning('WAF拦截恶意请求', [
'rule' => $result['rule'],
'details' => $result['details'],
'client_ip' => $request->ip(),
'method' => $request->method(),
'path' => $request->path(),
'user_agent' => $request->header('User-Agent'),
]);
// IP频率记录 - 用于自动封禁
$this->recordOffense($request->ip(), $result['rule']);
return response()->json([
'message' => '请求被安全策略拦截',
'error_code' => 'SECURITY_BLOCKED',
], 403);
}
return $next($request);
}
private function recordOffense(string $ip, string $rule): void
{
$key = "waf:offense:{$ip}";
$count = \Illuminate\Support\Facades\Cache::increment($key);
if ($count === 1) {
\Illuminate\Support\Facades\Cache::put($key, 1, 3600); // 1小时窗口
}
// 1小时内命中5次WAF规则 自动封禁IP 24小时
if ($count >= 5) {
\Illuminate\Support\Facades\Cache::put("waf:banned:{$ip}", true, 86400);
}
}
}
WAF 中间件不仅拦截请求,还会记录攻击源 IP 的违规次数。1 小时内命中 5 次 WAF 规则的 IP 会被自动封禁 24 小时,这种自动封禁机制可以有效抵御自动化扫描和暴力破解攻击。
登录接口有专门的暴力破解防护:
<?php
// app/Support/Security/LoginThrottler.php
namespace App\Support\Security;
use Illuminate\Support\Facades\Cache;
class LoginThrottler
{
/** 最大尝试次数 */
private const MAX_ATTEMPTS = 5;
/** 锁定时间(秒) */
private const LOCK_DURATION = 900; // 15分钟
/** 观察窗口(秒) */
private const WINDOW = 300; // 5分钟
/**
* 记录登录失败
*/
public function recordFailure(string $username, string $ip): void
{
$this->increment("login:fail:user:{$username}");
$this->increment("login:fail:ip:{$ip}");
}
/**
* 检查是否被锁定
*/
public function isLocked(string $username, string $ip): array
{
$userLock = $this->checkLock("login:lock:user:{$username}");
$ipLock = $this->checkLock("login:lock:ip:{$ip}");
if ($userLock['locked']) {
return [
'locked' => true,
'reason' => 'username',
'remaining_seconds' => $userLock['remaining'],
];
}
if ($ipLock['locked']) {
return [
'locked' => true,
'reason' => 'ip',
'remaining_seconds' => $ipLock['remaining'],
];
}
return ['locked' => false];
}
/**
* 检查尝试次数 超过阈值则锁定
*/
public function checkAndLock(string $username, string $ip): void
{
$userAttempts = (int)Cache::get("login:fail:user:{$username}", 0);
$ipAttempts = (int)Cache::get("login:fail:ip:{$ip}", 0);
if ($userAttempts >= self::MAX_ATTEMPTS) {
Cache::put("login:lock:user:{$username}", time(), self::LOCK_DURATION);
}
if ($ipAttempts >= self::MAX_ATTEMPTS * 2) {
Cache::put("login:lock:ip:{$ip}", time(), self::LOCK_DURATION);
}
}
/**
* 登录成功后清除失败计数
*/
public function clear(string $username, string $ip): void
{
Cache::forget("login:fail:user:{$username}");
Cache::forget("login:fail:ip:{$ip}");
}
/**
* 获取剩余尝试次数
*/
public function getRemainingAttempts(string $username, string $ip): int
{
$userAttempts = (int)Cache::get("login:fail:user:{$username}", 0);
$ipAttempts = (int)Cache::get("login:fail:ip:{$ip}", 0);
$maxAttempts = max($userAttempts, $ipAttempts);
return max(0, self::MAX_ATTEMPTS - $maxAttempts);
}
private function increment(string $key): void
{
$value = Cache::increment($key);
if ($value === 1) {
Cache::put($key, 1, self::WINDOW);
}
}
private function checkLock(string $key): array
{
$lockedAt = Cache::get($key);
if (!$lockedAt) {
return ['locked' => false, 'remaining' => 0];
}
$remaining = self::LOCK_DURATION - (time() - $lockedAt);
if ($remaining <= 0) {
Cache::forget($key);
return ['locked' => false, 'remaining' => 0];
}
return ['locked' => true, 'remaining' => $remaining];
}
}
登录限流采用了用户名和 IP 双维度计数。5 分钟内同一用户名失败 5 次锁定该用户名 15 分钟,同一 IP 失败 10 次锁定该 IP15 分钟。这种双维度设计既防止了针对单个账户的暴力破解,也防止了来自同一 IP 的多账户撞库攻击。登录成功后清除失败计数,避免正常用户因为偶尔输错密码而被锁定。
六、合规审核引擎:内容安全的最后一道门
GEO 系统的核心功能是将企业信息分发到各大平台,内容合规是不可逾越的红线。旗引科技 GEO 系统实现了多层合规审核引擎,包括违禁词检测、内容分类审核、跨行业合规校验、人工审核流程,确保发布的内容符合各行业监管要求。
<?php
// app/Services/Compliance/ComplianceEngine.php
namespace App\Services\Compliance;
use App\Support\Cache\MultiLevelCache;
use Illuminate\Support\Facades\DB;
class ComplianceEngine
{
/** 审核结果 */
public const STATUS_PASS = 'pass';
public const STATUS_WARNING = 'warning';
public const STATUS_REJECT = 'reject';
public const STATUS_MANUAL = 'manual_review';
/** 违禁词匹配模式 */
private const MATCH_EXACT = 'exact'; // 精确匹配
private const MATCH_REGEX = 'regex'; // 正则匹配
private const MATCH_FUZZY = 'fuzzy'; // 模糊匹配 (编辑距离)
private MultiLevelCache $cache;
private array $industryModules = [];
public function __construct(MultiLevelCache $cache)
{
$this->cache = $cache;
$this->registerIndustryModules();
}
/**
* 注册行业合规模块
*/
private function registerIndustryModules(): void
{
$this->industryModules = [
'finance' => app(FinanceComplianceModule::class),
'education' => app(EducationComplianceModule::class),
'medical' => app(MedicalComplianceModule::class),
'legal' => app(LegalComplianceModule::class),
'general' => app(GeneralComplianceModule::class),
];
}
/**
* 执行内容合规审核
*/
public function audit(string $content, string $industry = 'general', array $context = []): ComplianceResult
{
$result = new ComplianceResult();
// 1. 通用违禁词检测
$generalResult = $this->checkForbiddenWords($content, 'general');
$result->merge($generalResult);
// 2. 行业专属违禁词检测
if ($industry !== 'general' && isset($this->industryModules[$industry])) {
$industryResult = $this->industryModules[$industry]->audit($content, $context);
$result->merge($industryResult);
}
// 3. 跨行业合规校验
$crossIndustryResult = $this->crossIndustryCheck($content, $industry, $context);
$result->merge($crossIndustryResult);
// 4. 敏感个人信息检测
$piiResult = $this->checkPII($content);
$result->merge($piiResult);
// 5. 极端言论检测
$extremeResult = $this->checkExtremeSpeech($content);
$result->merge($extremeResult);
// 决定最终状态
$result->finalize();
// 记录审核日志
$this->logAudit($content, $industry, $result);
return $result;
}
/**
* 违禁词检测 - 支持多种匹配模式
*/
public function checkForbiddenWords(string $content, string $category): ComplianceResult
{
$result = new ComplianceResult();
$words = $this->getForbiddenWords($category);
foreach ($words as $word) {
$matched = false;
$matchPosition = -1;
switch ($word['match_mode']) {
case self::MATCH_EXACT:
$position = mb_strpos($content, $word['word']);
if ($position !== false) {
$matched = true;
$matchPosition = $position;
}
break;
case self::MATCH_REGEX:
if (preg_match($word['word'], $content, $matches, PREG_OFFSET_CAPTURE)) {
$matched = true;
$matchPosition = $matches[0][1];
}
break;
case self::MATCH_FUZZY:
// 模糊匹配: 编辑距离小于阈值
$matched = $this->fuzzyMatch($content, $word['word'], $word['fuzzy_threshold'] ?? 2);
break;
}
if ($matched) {
$violation = new ComplianceViolation(
$word['word'],
$word['severity'],
$word['category'],
$matchPosition,
$word['suggestion'] ?? null
);
$result->addViolation($violation);
}
}
return $result;
}
/**
* 模糊匹配 - 使用编辑距离检测变体
* 例如 "最+好" "zui好" 等变体
*/
private function fuzzyMatch(string $content, string $word, int $threshold): bool
{
$wordLen = mb_strlen($word);
$contentLen = mb_strlen($content);
// 滑动窗口检查
for ($i = 0; $i <= $contentLen - $wordLen; $i++) {
$substring = mb_substr($content, $i, $wordLen);
$distance = $this->levenshteinUtf8($substring, $word);
if ($distance <= $threshold) {
return true;
}
}
return false;
}
/**
* UTF-8安全的编辑距离计算
*/
private function levenshteinUtf8(string $a, string $b): int
{
$aChars = preg_split('//u', $a, -1, PREG_SPLIT_NO_EMPTY);
$bChars = preg_split('//u', $b, -1, PREG_SPLIT_NO_EMPTY);
$m = count($aChars);
$n = count($bChars);
$dp = array_fill(0, $m + 1, array_fill(0, $n + 1, 0));
for ($i = 0; $i <= $m; $i++) $dp[$i][0] = $i;
for ($j = 0; $j <= $n; $j++) $dp[0][$j] = $j;
for ($i = 1; $i <= $m; $i++) {
for ($j = 1; $j <= $n; $j++) {
$cost = $aChars[$i-1] === $bChars[$j-1] ? 0 : 1;
$dp[$i][$j] = min(
$dp[$i-1][$j] + 1,
$dp[$i][$j-1] + 1,
$dp[$i-1][$j-1] + $cost
);
}
}
return $dp[$m][$n];
}
/**
* 跨行业合规校验
* 检查内容是否包含不属于当前行业的敏感表述
*/
private function crossIndustryCheck(string $content, string $industry, array $context): ComplianceResult
{
$result = new ComplianceResult();
// 例如: 非金融行业内容中出现收益率、保本等金融术语
$industryTerms = $this->getIndustrySpecificTerms();
foreach ($industryTerms as $termIndustry => $terms) {
if ($termIndustry === $industry) continue;
foreach ($terms as $term) {
if (mb_strpos($content, $term['word']) !== false) {
$result->addViolation(new ComplianceViolation(
$term['word'],
'warning',
'cross_industry',
-1,
"检测到{$termIndustry}行业专属术语,非该行业使用需谨慎"
));
}
}
}
return $result;
}
/**
* 敏感个人信息检测
*/
private function checkPII(string $content): ComplianceResult
{
$result = new ComplianceResult();
// 身份证号
if (preg_match('/\b[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]\b/', $content)) {
$result->addViolation(new ComplianceViolation('身份证号', 'warning', 'pii', -1, '内容中包含身份证号,建议脱敏处理'));
}
// 银行卡号
if (preg_match('/\b\d{16,19}\b/', $content)) {
$result->addViolation(new ComplianceViolation('银行卡号', 'warning', 'pii', -1, '内容中包含疑似银行卡号'));
}
return $result;
}
/**
* 极端言论检测 (简化版)
*/
private function checkExtremeSpeech(string $content): ComplianceResult
{
$result = new ComplianceResult();
// 实际实现会使用更复杂的NLP模型或第三方审核API
return $result;
}
/**
* 获取违禁词列表 (带缓存)
*/
private function getForbiddenWords(string $category): array
{
return $this->cache->remember("compliance:words:{$category}", 3600, function () use ($category) {
return DB::table('compliance_forbidden_words')
->where('category', $category)
->where('status', 'active')
->get(['word', 'match_mode', 'severity', 'category', 'fuzzy_threshold', 'suggestion'])
->toArray();
});
}
private function getIndustrySpecificTerms(): array
{
return $this->cache->remember('compliance:industry_terms', 3600, function () {
$terms = [];
$records = DB::table('compliance_industry_terms')
->where('status', 'active')
->get();
foreach ($records as $record) {
$terms[$record->industry][] = (array)$record;
}
return $terms;
});
}
private function logAudit(string $content, string $industry, ComplianceResult $result): void
{
DB::table('compliance_audit_logs')->insert([
'content_hash' => hash('sha256', $content),
'content_preview' => mb_substr($content, 0, 200),
'industry' => $industry,
'status' => $result->getStatus(),
'violation_count' => $result->getViolationCount(),
'violations' => json_encode($result->getViolations()),
'created_at' => now(),
]);
}
}
合规审核引擎采用了模块化设计,通用违禁词检测和行业专属检测分离。每个行业(金融、教育、医疗、法律)有独立的合规模块,内置该行业的敏感词库和审核规则。跨行业校验检查内容是否误用了其他行业的专属术语(如非金融行业使用 "保本保息" 等金融术语)。
违禁词匹配支持三种模式:精确匹配(直接字符串包含)、正则匹配(用于检测变体和模式化内容)、模糊匹配(基于编辑距离,检测 "最 + 好""zui 好 " 等刻意规避的变体)。模糊匹配是合规引擎的关键能力 —— 很多违规内容会通过加符号、谐音、拆字等方式规避精确匹配,模糊匹配可以识别这些变体。
<?php
// app/Services/Compliance/ComplianceResult.php
namespace App\Services\Compliance;
class ComplianceResult
{
private array $violations = [];
private string $status = self::STATUS_PASS;
public const STATUS_PASS = 'pass';
public const STATUS_WARNING = 'warning';
public const STATUS_REJECT = 'reject';
public const STATUS_MANUAL = 'manual_review';
public function addViolation(ComplianceViolation $violation): void
{
$this->violations[] = $violation;
}
public function merge(ComplianceResult $other): void
{
$this->violations = array_merge($this->violations, $other->getViolations());
}
/**
* 根据违规情况决定最终审核状态
*/
public function finalize(): void
{
$hasReject = false;
$hasWarning = false;
$hasManual = false;
foreach ($this->violations as $v) {
match ($v->getSeverity()) {
'high' => $hasReject = true,
'medium' => $hasManual = true,
'low' => $hasWarning = true,
default => null,
};
}
if ($hasReject) {
$this->status = self::STATUS_REJECT;
} elseif ($hasManual) {
$this->status = self::STATUS_MANUAL;
} elseif ($hasWarning) {
$this->status = self::STATUS_WARNING;
} else {
$this->status = self::STATUS_PASS;
}
}
public function getStatus(): string { return $this->status; }
public function getViolations(): array { return $this->violations; }
public function getViolationCount(): int { return count($this->violations); }
public function isPassed(): bool { return $this->status === self::STATUS_PASS; }
public function isRejected(): bool { return $this->status === self::STATUS_REJECT; }
public function requiresManualReview(): bool { return $this->status === self::STATUS_MANUAL; }
}
审核结果分为四个等级:通过(无违规)、警告(低风险违规,可发布但需关注)、人工审核(中风险违规,需人工确认)、拒绝(高风险违规,禁止发布)。这种分级处理既保证了严重违规内容被拦截,又避免了过度审核影响正常内容的发布效率。
行业合规模块以金融行业为例:
<?php
// app/Services/Compliance/Modules/FinanceComplianceModule.php
namespace App\Services\Compliance\Modules;
use App\Services\Compliance\ComplianceResult;
use App\Services\Compliance\ComplianceViolation;
class FinanceComplianceModule implements IndustryComplianceModule
{
/** 金融行业高风险违禁词 */
private const HIGH_RISK_WORDS = [
'保本保息', '零风险', '稳赚不赔', '包赚', '一夜暴富',
'内幕消息', '必涨', '涨停', '推荐股票', '代客理财',
];
/** 金融行业中风险词 (需资质) */
private const MEDIUM_RISK_WORDS = [
'收益率', '年化收益', '分红', '基金', '股票', '期货',
'外汇', '虚拟货币', '数字货币', 'ICO', '众筹',
];
/** 金融行业误导性表述模式 */
private const MISLEADING_PATTERNS = [
'/年?化?收益率?\s*(高达|超过|不低于)\s*\d+%/i',
'/(保本|保底|零风险)\s*(收益|回报|投资)/i',
'/(稳赚|包赚|必赚)\s*(不赔|无风险)?/i',
];
public function audit(string $content, array $context = []): ComplianceResult
{
$result = new ComplianceResult();
// 高风险词检测
foreach (self::HIGH_RISK_WORDS as $word) {
if (mb_strpos($content, $word) !== false) {
$result->addViolation(new ComplianceViolation(
$word, 'high', 'finance_forbidden',
mb_strpos($content, $word),
'金融行业禁止使用绝对化收益承诺表述'
));
}
}
// 中风险词检测 (需确认是否有相应资质)
foreach (self::MEDIUM_RISK_WORDS as $word) {
if (mb_strpos($content, $word) !== false) {
$hasQualification = $context['has_financial_license'] ?? false;
if (!$hasQualification) {
$result->addViolation(new ComplianceViolation(
$word, 'medium', 'finance_licensed',
mb_strpos($content, $word),
"使用'{$word}'需具备相应金融资质,请确认资质有效性"
));
}
}
}
// 误导性表述模式检测
foreach (self::MISLEADING_PATTERNS as $pattern) {
if (preg_match($pattern, $content, $matches)) {
$result->addViolation(new ComplianceViolation(
$matches[0], 'high', 'finance_misleading',
-1,
'检测到误导性收益表述,违反金融广告合规要求'
));
}
}
return $result;
}
}
金融行业模块内置了金融监管要求的违禁词库,包括绝对化收益承诺(保本保息、零风险)、无资质金融业务(代客理财、推荐股票)、误导性表述模式(收益率高达 X%)。中风险词会检查企业是否具备相应金融资质,有资质的可以使用,无资质的需要人工审核。这种基于资质的差异化审核,既保证了合规性,又不会对合规企业造成过度限制。
内容发布前的审核流程:
<?php
// app/Services/Distribution/ContentPublisher.php (审核部分)
public function publish(int $contentId, array $channels): array
{
$content = ContentChunk::findOrFail($contentId);
$enterprise = $content->enterprise;
// 1. 合规审核
$auditResult = $this->complianceEngine->audit(
$content->chunk_text,
$enterprise->industry ?? 'general',
[
'has_financial_license' => $enterprise->has_financial_license,
'has_medical_license' => $enterprise->has_medical_license,
'has_education_license' => $enterprise->has_education_license,
]
);
if ($auditResult->isRejected()) {
return [
'success' => false,
'status' => 'rejected',
'reason' => '内容未通过合规审核',
'violations' => $auditResult->getViolations(),
];
}
if ($auditResult->requiresManualReview()) {
// 进入人工审核队列
$this->manualReviewQueue->push($content, $auditResult);
return [
'success' => false,
'status' => 'pending_manual_review',
'reason' => '内容需人工审核',
];
}
// 2. 实名认证检查
if (!$enterprise->realname_verified || !$enterprise->enterprise_verified) {
return [
'success' => false,
'status' => 'verification_required',
'reason' => '企业需完成实名认证和企业认证后才能发布内容',
];
}
// 3. 审核通过 执行发布
return $this->doPublish($content, $channels);
}
内容发布前必须经过三道关卡:合规审核(违禁词和行业规则)、双认证检查(实名认证加企业认证)、人工审核(中风险内容)。只有全部通过的内容才能进入发布流程。这种 "先审后发" 的机制从源头防止了违规内容的流出,是 GEO 系统合规经营的核心保障。
七、审计日志:全程可追溯
审计日志是安全架构的最后一道防线,也是事后追溯和合规检查的重要依据。旗引科技 GEO 系统实现了全面的审计日志体系,覆盖登录登出、权限变更、数据操作、内容发布、系统配置等所有敏感操作。
<?php
// app/Support/Audit/AuditLogger.php
namespace App\Support\Audit;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Request;
class AuditLogger
{
/** 审计事件类型 */
public const EVENT_LOGIN = 'login';
public const EVENT_LOGIN_FAILED = 'login_failed';
public const EVENT_LOGOUT = 'logout';
public const EVENT_PERMISSION_CHANGE = 'permission_change';
public const EVENT_DATA_CREATE = 'data_create';
public const EVENT_DATA_UPDATE = 'data_update';
public const EVENT_DATA_DELETE = 'data_delete';
public const EVENT_CONTENT_PUBLISH = 'content_publish';
public const EVENT_CONTENT_REJECT = 'content_reject';
public const EVENT_CONFIG_CHANGE = 'config_change';
public const EVENT_API_KEY_CREATE = 'api_key_create';
public const EVENT_API_KEY_REVOKE = 'api_key_revoke';
public const EVENT_2FA_ENABLE = '2fa_enable';
public const EVENT_2FA_DISABLE = '2fa_disable';
public const EVENT_PASSWORD_CHANGE = 'password_change';
public const EVENT_DATA_EXPORT = 'data_export';
/**
* 记录审计日志
*/
public function log(string $eventType, array $details = [], ?int $userId = null): void
{
$userId = $userId ?? Auth::id();
DB::table('audit_logs')->insert([
'event_id' => bin2hex(random_bytes(16)),
'event_type' => $eventType,
'user_id' => $userId,
'enterprise_id' => Auth::user()?->enterprise_id ?? null,
'ip_address' => Request::ip(),
'user_agent' => Request::header('User-Agent'),
'request_method' => Request::method(),
'request_path' => Request::path(),
'request_id' => Request::header('X-Request-ID'),
'details' => json_encode($this->sanitizeDetails($details)),
'created_at' => now(),
]);
}
/**
* 记录数据变更 (包含变更前后的值)
*/
public function logDataChange(
string $action,
string $table,
int $recordId,
?array $before = null,
?array $after = null,
array $changedFields = []
): void {
$this->log(match($action) {
'create' => self::EVENT_DATA_CREATE,
'update' => self::EVENT_DATA_UPDATE,
'delete' => self::EVENT_DATA_DELETE,
default => $action,
}, [
'table' => $table,
'record_id' => $recordId,
'before' => $before ? $this->maskSensitiveData($before, $table) : null,
'after' => $after ? $this->maskSensitiveData($after, $table) : null,
'changed_fields' => $changedFields,
]);
}
/**
* 记录登录事件
*/
public function logLogin(int $userId, bool $success, string $failureReason = null): void
{
$this->log($success ? self::EVENT_LOGIN : self::EVENT_LOGIN_FAILED, [
'success' => $success,
'failure_reason' => $failureReason,
'login_method' => Request::header('X-Login-Method', 'password'),
], $userId);
}
/**
* 记录权限变更
*/
public function logPermissionChange(int $targetUserId, string $action, array $permissions): void
{
$this->log(self::EVENT_PERMISSION_CHANGE, [
'target_user_id' => $targetUserId,
'action' => $action, // grant / revoke
'permissions' => $permissions,
'operator_id' => Auth::id(),
]);
}
/**
* 记录数据导出 (敏感操作)
*/
public function logDataExport(string $dataType, array $filters, int $recordCount): void
{
$this->log(self::EVENT_DATA_EXPORT, [
'data_type' => $dataType,
'filters' => $filters,
'record_count' => $recordCount,
'export_time' => now()->toDateTimeString(),
]);
}
/**
* 查询审计日志 (支持多维度筛选)
*/
public function query(array $filters = [], int $page = 1, int $perPage = 50): array
{
$query = DB::table('audit_logs');
if (!empty($filters['event_type'])) {
$query->whereIn('event_type', (array)$filters['event_type']);
}
if (!empty($filters['user_id'])) {
$query->where('user_id', $filters['user_id']);
}
if (!empty($filters['enterprise_id'])) {
$query->where('enterprise_id', $filters['enterprise_id']);
}
if (!empty($filters['ip_address'])) {
$query->where('ip_address', $filters['ip_address']);
}
if (!empty($filters['start_date'])) {
$query->where('created_at', '>=', $filters['start_date']);
}
if (!empty($filters['end_date'])) {
$query->where('created_at', '<=', $filters['end_date']);
}
$total = $query->count();
$logs = $query->orderByDesc('created_at')
->offset(($page - 1) * $perPage)
->limit($perPage)
->get();
return [
'data' => $logs,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
];
}
/**
* 脱敏审计日志中的敏感数据
*/
private function maskSensitiveData(array $data, string $table): array
{
$masker = app(\App\Support\Security\DataMasker::class);
$sensitiveFields = config("audit.masked_fields.{$table}", []);
foreach ($sensitiveFields as $field) {
if (isset($data[$field]) && is_string($data[$field])) {
$data[$field] = $masker->maskByField($field, $data[$field]);
}
}
// 密码字段永远不记录
unset($data['password'], $data['password_hash'], $data['remember_token']);
return $data;
}
/**
* 清理过大的details
*/
private function sanitizeDetails(array $details): array
{
foreach ($details as $key => $value) {
if (is_string($value) && strlen($value) > 5000) {
$details[$key] = substr($value, 0, 5000) . '...[truncated]';
}
if (is_array($value) && count($value) > 100) {
$details[$key] = array_slice($value, 0, 100);
$details[$key]['_truncated'] = true;
}
}
return $details;
}
}
审计日志记录了完整的操作上下文:事件类型、操作用户、所属企业、IP 地址、User-Agent、请求方法、请求路径、请求 ID、操作详情。数据变更日志还记录了变更前后的值和变更字段。这些信息使得任何敏感操作都可以被完整追溯,满足等保测评和内部审计的要求。
审计日志中的敏感数据会自动脱敏,密码字段永远不记录。这确保了审计日志本身不会成为敏感数据泄露的来源。
数据变更通过模型观察者自动记录:
<?php
// app/Observers/AuditObserver.php
namespace App\Observers;
use App\Support\Audit\AuditLogger;
use Illuminate\Database\Eloquent\Model;
class AuditObserver
{
public function __construct(private AuditLogger $audit) {}
/**
* 创建后记录
*/
public function created(Model $model): void
{
$this->audit->logDataChange(
'create',
$model->getTable(),
$model->getKey(),
null,
$model->getAttributes(),
array_keys($model->getAttributes())
);
}
/**
* 更新后记录
*/
public function updated(Model $model): void
{
$changed = $model->getDirty();
$original = [];
foreach (array_keys($changed) as $field) {
$original[$field] = $model->getOriginal($field);
}
$this->audit->logDataChange(
'update',
$model->getTable(),
$model->getKey(),
$original,
$changed,
array_keys($changed)
);
}
/**
* 删除前记录
*/
public function deleting(Model $model): void
{
$this->audit->logDataChange(
'delete',
$model->getTable(),
$model->getKey(),
$model->getAttributes(),
null,
[]
);
}
}
模型观察者(Observer)在模型的创建、更新、删除事件触发时自动记录审计日志,不需要业务代码手动调用。这种 AOP 风格的设计确保了所有数据变更都有审计记录,不会因为开发者遗漏而丢失。需要审计的模型在启动时注册观察者即可:
<?php
// app/Providers/AppServiceProvider.php
public function boot(): void
{
// 注册需要审计的模型
Entity::observe(AuditObserver::class);
Enterprise::observe(AuditObserver::class);
User::observe(AuditObserver::class);
ContentChunk::observe(AuditObserver::class);
DistributionTask::observe(AuditObserver::class);
}
登录异常检测是审计日志的主动应用:
<?php
// app/Support/Security/LoginAnomalyDetector.php
namespace App\Support\Security;
use Illuminate\Support\Facades\DB;
use App\Support\Audit\AuditLogger;
class LoginAnomalyDetector
{
/** 异常检测规则 */
private const RULES = [
'impossible_travel' => [
'name' => '不可能旅行',
'description' => '短时间内从相距很远的地点登录',
'threshold_km' => 500,
'threshold_minutes' => 60,
],
'new_device' => [
'name' => '新设备登录',
'description' => '使用从未登录过的设备登录',
],
'new_location' => [
'name' => '新地点登录',
'description' => '从从未登录过的城市登录',
],
'brute_force' => [
'name' => '暴力破解',
'description' => '短时间内多次登录失败',
'threshold' => 5,
'window_minutes' => 10,
],
];
public function __construct(private AuditLogger $audit) {}
/**
* 检测登录异常
*/
public function detect(int $userId, string $ip, string $userAgent): array
{
$anomalies = [];
// 1. 不可能旅行检测
$travelAnomaly = $this->detectImpossibleTravel($userId, $ip);
if ($travelAnomaly) {
$anomalies[] = $travelAnomaly;
}
// 2. 新设备检测
$deviceAnomaly = $this->detectNewDevice($userId, $userAgent);
if ($deviceAnomaly) {
$anomalies[] = $deviceAnomaly;
}
// 3. 新地点检测
$locationAnomaly = $this->detectNewLocation($userId, $ip);
if ($locationAnomaly) {
$anomalies[] = $locationAnomaly;
}
// 4. 暴力破解检测
$bruteForceAnomaly = $this->detectBruteForce($userId, $ip);
if ($bruteForceAnomaly) {
$anomalies[] = $bruteForceAnomaly;
}
// 记录异常
if (!empty($anomalies)) {
$this->audit->log('login_anomaly_detected', [
'user_id' => $userId,
'ip' => $ip,
'anomalies' => $anomalies,
'risk_level' => $this->calculateRiskLevel($anomalies),
]);
}
return $anomalies;
}
private function detectImpossibleTravel(int $userId, string $currentIp): ?array
{
// 获取上一次登录记录
$lastLogin = DB::table('audit_logs')
->where('user_id', $userId)
->where('event_type', 'login')
->orderByDesc('created_at')
->first();
if (!$lastLogin) {
return null;
}
$timeDiff = time() - strtotime($lastLogin->created_at);
if ($timeDiff > self::RULES['impossible_travel']['threshold_minutes'] * 60) {
return null; // 时间间隔太长 不检测
}
// 计算IP地理位置距离 (简化实现 实际使用IP地理库)
$distance = $this->estimateDistance($lastLogin->ip_address, $currentIp);
$requiredSpeed = $distance / ($timeDiff / 3600); // km/h
if ($requiredSpeed > 1000) { // 超过1000km/h 判定为不可能旅行
return [
'type' => 'impossible_travel',
'severity' => 'high',
'message' => "检测到不可能旅行: {$timeDiff/60}分钟内移动约{$distance}公里",
'last_ip' => $lastLogin->ip_address,
'current_ip' => $currentIp,
'estimated_speed_kmh' => round($requiredSpeed),
];
}
return null;
}
private function detectNewDevice(int $userId, string $userAgent): ?array
{
$uaHash = md5($userAgent);
$exists = DB::table('audit_logs')
->where('user_id', $userId)
->where('event_type', 'login')
->where('user_agent_hash', $uaHash)
->exists();
if (!$exists) {
return [
'type' => 'new_device',
'severity' => 'medium',
'message' => '检测到新设备登录',
'user_agent' => substr($userAgent, 0, 100),
];
}
return null;
}
private function detectNewLocation(int $userId, string $ip): ?array
{
// 简化实现: 检查IP前缀是否出现过
$ipPrefix = substr($ip, 0, strrpos($ip, '.'));
$exists = DB::table('audit_logs')
->where('user_id', $userId)
->where('event_type', 'login')
->where('ip_address', 'like', "{$ipPrefix}.%")
->exists();
if (!$exists) {
return [
'type' => 'new_location',
'severity' => 'low',
'message' => '检测到新登录地点',
'ip' => $ip,
];
}
return null;
}
private function detectBruteForce(int $userId, string $ip): ?array
{
$windowStart = now()->subMinutes(self::RULES['brute_force']['window_minutes']);
$failCount = DB::table('audit_logs')
->where('user_id', $userId)
->where('event_type', 'login_failed')
->where('ip_address', $ip)
->where('created_at', '>=', $windowStart)
->count();
if ($failCount >= self::RULES['brute_force']['threshold']) {
return [
'type' => 'brute_force',
'severity' => 'high',
'message' => "检测到暴力破解: {$failCount}次失败尝试",
'ip' => $ip,
];
}
return null;
}
private function calculateRiskLevel(array $anomalies): string
{
$hasHigh = false;
$hasMedium = false;
foreach ($anomalies as $a) {
if ($a['severity'] === 'high') $hasHigh = true;
if ($a['severity'] === 'medium') $hasMedium = true;
}
if ($hasHigh) return 'high';
if ($hasMedium) return 'medium';
return 'low';
}
private function estimateDistance(string $ip1, string $ip2): float
{
// 简化实现: 根据IP前缀估算距离
// 实际应使用MaxMind等IP地理库
return 1000; // 占位值
}
}
登录异常检测器基于审计日志数据,实时分析每次登录的风险。不可能旅行检测通过比较两次登录的 IP 地理位置和时间间隔,判断是否存在账号被盗用的可能(例如 10 分钟内从北京和广州分别登录,物理上不可能)。新设备和新地点检测识别用户习惯的变化。暴力破解检测统计短时间内的失败次数。检测到的异常会记录到审计日志,并根据风险级别触发告警或要求额外验证。
八、安全架构的工程启示
完整拆解旗引科技 GEO 系统的安全架构后,可以总结出几个对企业级 PHP 系统有普遍参考价值的安全工程原则。
第一,纵深防御优于单点防护。没有任何单一安全措施是绝对可靠的 ——WAF 可能被绕过,认证可能被窃取,加密密钥可能泄露。纵深防御在网络层、应用层、数据层、业务层分别部署控制,使得攻击者需要突破多层防线才能造成实质损害。每一层的设计都假设其他层可能已经失效。
第二,安全默认优于可选配置。数据隔离通过全局作用域自动应用,加密通过模型 trait 自动处理,审计通过观察者自动记录,WAF 通过中间件自动执行。这些安全机制是默认开启的,不需要开发者手动调用。"安全默认" 的设计避免了人为遗漏导致的安全漏洞,也降低了开发者的安全心智负担。
第三,最小权限是权限设计的黄金法则。RBAC 模型确保用户只拥有完成工作所需的最小权限,数据行级权限确保用户只能访问所属企业的数据,超级管理员角色严格控制数量。权限变更有审计日志,敏感操作需要 2FA 验证。这种设计将内部人员的误操作和恶意操作的影响范围降到最低。
第四,加密体系需要支持密钥轮换。静态加密密钥一旦泄露就是灾难。分层密钥架构(根密钥加密数据密钥)、密钥版本前缀、后台轮换任务,这些机制使得密钥更换成为常规操作而不是紧急事件。对于需要通过等保测评的系统,密钥轮换能力是硬性要求。
第五,合规审核是内容型系统的生命线。GEO 系统的核心价值是企业信息的权威分发,如果分发了违规内容,不仅企业客户受损,平台本身也会面临监管风险。多层合规审核(通用违禁词、行业专属、跨行业校验、人工审核)和 "先审后发" 机制,是系统能够长期合规运营的基础。模糊匹配能力则是应对刻意规避行为的关键技术。
第六,审计日志是安全的最后一道防线。事前防护不可能做到百分之百,事后追溯能力决定了安全事件发生后的响应速度和责任认定。完整的审计日志包含操作上下文、变更前后值、IP 和设备信息,支持多维度查询和异常检测。审计日志本身的安全(脱敏、防篡改、保留期限)同样重要。
第七,源码部署的数据安全是政企客户的核心关切。数据不出域、代码可审计、密钥自管理,这三点是私有化部署的安全基石。旗引科技 GEO 系统的前后端开源版本将所有数据处理限制在企业自有服务器内,加密根密钥由企业自行配置,旗引科技无法访问客户数据。这种设计完全适配政企数字化项目的安全选型标准。
这套安全架构的整体特征是:以纵深防御为设计理念,以安全默认为实现原则,以 RBAC 加数据权限为授权模型,以分层密钥加密为数据保护,以 WAF 加限流为请求防护,以多层合规审核为内容把关,以全程审计日志为追溯手段。它不是简单地套用几个安全库,而是从认证、授权、加密、防护、合规、审计六个维度构建的完整安全工程体系。对于存储企业敏感信息、对接外部平台的 GEO 系统,这套架构的严谨程度直接决定了系统的可信度。对于评估源码部署方案的企业,安全架构的可审计性 —— 能否读懂代码、能否验证机制、能否自主控制密钥 —— 比功能列表更能反映系统的长期安全价值。安全不是一次性的配置,而是贯穿系统设计、开发、部署、运营全生命周期的持续工程。
