签入版本
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# 5.15 登录日志
|
||||
|
||||
:::tip 概述
|
||||
登录日志记录用户的登录/登出行为,包括成功和失败记录。每次登录尝试都会记录,便于安全审计和异常行为分析。与操作日志不同,登录日志由 LoginLogic 手动记录,而非中间件自动记录。
|
||||
登录日志记录用户的登录/登出行为,**成功与失败均记录**,便于安全审计和异常行为分析。与操作日志不同,登录日志由 `AuthController` 手动记录(`writeLoginLog`),而非中间件自动记录;登录日志的 `param` 只记录用户名,不涉及密码。
|
||||
:::
|
||||
|
||||
## 记录流程
|
||||
@@ -10,38 +10,41 @@
|
||||
用户提交登录
|
||||
│
|
||||
▼
|
||||
LoginLogic::login()
|
||||
AuthController::login(LoginRequest)
|
||||
│
|
||||
├─ 创建 RequestInfoService(解析 IP、UA、归属地)
|
||||
├─ LoginRequest 参数格式校验
|
||||
│
|
||||
├─ 验证验证码 CaptchaService::check()
|
||||
│ └─ 失败 → 记录登录日志(status=0, error="验证码错误")
|
||||
├─ CaptchaService::verify(key, code)
|
||||
│ └─ 失败 → 写登录日志(status=1 失败, error="验证码错误")→ 返回失败
|
||||
│
|
||||
├─ 调用 JwtService::login()
|
||||
│ ├─ 查询用户 → 不存在 → 记录登录日志(status=0, error="用户名或密码错误")
|
||||
│ ├─ 校验状态 → 禁用 → 记录登录日志(status=0, error="账号已被禁用")
|
||||
│ ├─ 校验密码 → 错误 → 记录登录日志(status=0, error="用户名或密码错误")
|
||||
├─ JwtService::login()
|
||||
│ ├─ 查询用户 → 不存在 → BizException → 写登录日志(失败, "用户名或密码错误")
|
||||
│ ├─ 校验状态 → 禁用 → BizException → 写登录日志(失败, "账号已被禁用")
|
||||
│ ├─ 校验密码 → 错误 → BizException → 写登录日志(失败, "用户名或密码错误")
|
||||
│ └─ 签发 Token → 成功
|
||||
│
|
||||
├─ 记录登录日志(status=1, 无错误)
|
||||
├─ 写登录日志(status=0 成功,含耗时/归属地/浏览器)
|
||||
│
|
||||
└─ 返回 { access_token, refresh_token, user }
|
||||
└─ 返回 { access_token, refresh_token, token_type, expires_in, user } ← raw snake_case
|
||||
```
|
||||
|
||||
:::tip 双写保险
|
||||
无论走成功分支还是异常分支,`AuthController` 都会调用 `writeLoginLog`——**登录尝试必留痕**(含失败原因),这是登录安全审计的基础。
|
||||
:::
|
||||
|
||||
### 登录/登出记录
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 登录成功 │
|
||||
│ LoginLogic::login() → 成功 → recordLoginLog(username, 1, 0) │
|
||||
│ LoginController::login() → 成功日志 │
|
||||
│ AuthController::login() 成功 → writeLoginLog(成功) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 登录失败 │
|
||||
│ LoginLogic::login() → 异常 → recordLoginLog(username, 0, 1) │
|
||||
│ LoginController::login() → 失败日志 │
|
||||
│ 登录失败(验证码/密码/状态任一失败) │
|
||||
│ AuthController::login() 异常 → writeLoginLog(失败 + error) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 登出 │
|
||||
│ LoginController::logout() → recordLoginLog(username, 2, 0) │
|
||||
│ AuthController::logout() → revokeToken() 吊销令牌 │
|
||||
│ → LoginLog::create(log_type=2) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -50,94 +53,102 @@ LoginLogic::login()
|
||||
| 字段 | 类型 | 说明 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `username` | VARCHAR(50) | 用户名(即使登录失败也记录) | admin |
|
||||
| `type` | TINYINT | 类型:1=登录 2=登出 | 1 |
|
||||
| `status` | TINYINT | 状态:0=失败 1=成功 | 1 |
|
||||
| `message` | VARCHAR(200) | 结果消息 | 登录成功 / 密码错误 |
|
||||
| `ip` | VARCHAR(50) | 客户端 IP | 192.168.1.100 |
|
||||
| `location` | VARCHAR(100) | IP 归属地 | 江苏省南京市 |
|
||||
| `os` | VARCHAR(100) | 操作系统 | Windows 10 |
|
||||
| `browser` | VARCHAR(100) | 浏览器 | Chrome 120 |
|
||||
| `create_time` | DATETIME | 登录时间 | 2026-09-22 10:30:00 |
|
||||
| `log_type` | TINYINT | 类型:1=登录 2=登出 | 1 |
|
||||
| `source` | TINYINT | 来源端:0=后台 1=前台 | 0 |
|
||||
| `method` | VARCHAR(10) | HTTP 方法 | POST |
|
||||
| `request_method` | VARCHAR(50) | 入口标识 | login |
|
||||
| `url` | VARCHAR(255) | 请求 URL | /api/login |
|
||||
| `param` | TEXT | 请求参数——**只记录用户名**(JSON) | {"username":"admin"} |
|
||||
| `result` | TEXT | 结果(JSON:code + msg) | {"code":0,"msg":"登录成功"} |
|
||||
| `status` | TINYINT | 状态:0=成功 1=失败 | 0 |
|
||||
| `error` | VARCHAR(200) | 错误信息(仅失败时写入) | 用户名或密码错误 |
|
||||
| `ip` | VARCHAR(50) | 客户端 IP(经 ProxyMiddleware 还原) | 192.168.1.100 |
|
||||
| `location` | VARCHAR(100) | IP 归属地(ip2region) | 江苏省南京市 |
|
||||
| `os` | VARCHAR(100) | 操作系统 | Windows 11 |
|
||||
| `browser` | VARCHAR(100) | 浏览器 | Chrome 128 |
|
||||
| `consume_time` | INT | 登录耗时(毫秒) | 260 |
|
||||
| `create_time` | DATETIME | 登录时间 | 2026-10-02 10:30:00 |
|
||||
|
||||
:::tip 耗时字段的用途
|
||||
登录耗时异常增大可能是 bcrypt 暴力破解尝试、数据库慢查询或 Redis 阻塞的信号——安全巡检时可按 `consume_time` 排序排查。
|
||||
:::
|
||||
|
||||
## 核心代码
|
||||
|
||||
### 登录记录
|
||||
### 登录记录(AuthController)
|
||||
|
||||
```php
|
||||
// app/logic/LoginLogic.php
|
||||
// app/Http/Controllers/Api/AuthController.php(节选)
|
||||
|
||||
public function login(string $username, string $password, string $captchaKey, string $captchaCode): array
|
||||
public function login(LoginRequest $request): JsonResponse
|
||||
{
|
||||
$requestInfo = RequestInfoService::create(request());
|
||||
// ... 参数与验证码校验 ...
|
||||
$startTime = microtime(true);
|
||||
|
||||
try {
|
||||
// 验证验证码
|
||||
if (!CaptchaService::check($captchaKey, $captchaCode)) {
|
||||
throw new \Exception('验证码错误');
|
||||
}
|
||||
|
||||
// 调用 JwtService 登录
|
||||
$result = $this->jwtService->login($username, $password);
|
||||
$this->writeLoginLog($request, $username, 'login', true, '登录成功', $startTime);
|
||||
|
||||
// 记录成功日志
|
||||
$this->recordLoginLog($username, 1, 0, '', '登录成功');
|
||||
|
||||
return $result;
|
||||
|
||||
return $this->success($result, '登录成功');
|
||||
} catch (\Exception $e) {
|
||||
// 记录失败日志
|
||||
$this->recordLoginLog($username, 1, 1, $e->getMessage(), '登录失败');
|
||||
$this->writeLoginLog($request, $username, 'login', false, $e->getMessage(), $startTime);
|
||||
|
||||
throw $e;
|
||||
return $this->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function writeLoginLog(Request $request, string $username, string $entry,
|
||||
bool $success, string $message, float $startTime): void
|
||||
{
|
||||
$duration = round((microtime(true) - $startTime) * 1000);
|
||||
$requestInfo = RequestInfoService::create($request);
|
||||
|
||||
$data = [
|
||||
'username' => $username,
|
||||
'log_type' => 1, // 1=登录
|
||||
'source' => 0, // 0=后台
|
||||
'method' => $request->method(),
|
||||
'request_method' => $entry, // login 等入口
|
||||
'url' => $request->fullUrl(),
|
||||
'param' => json_encode(['username' => $username], JSON_UNESCAPED_UNICODE), // 只记录用户名
|
||||
'result' => json_encode(['code' => $success ? 0 : 1, 'msg' => $message], JSON_UNESCAPED_UNICODE),
|
||||
'ip' => $requestInfo->getIp(),
|
||||
'location' => $requestInfo->getIpLocation(),
|
||||
'os' => $requestInfo->getOs(),
|
||||
'browser' => $requestInfo->getBrowser(),
|
||||
'consume_time' => $duration,
|
||||
'status' => $success ? 0 : 1, // 0=成功 1=失败
|
||||
'create_time' => now(),
|
||||
];
|
||||
|
||||
if (! $success) {
|
||||
$data['error'] = $message;
|
||||
}
|
||||
|
||||
LoginLog::create($data);
|
||||
}
|
||||
```
|
||||
|
||||
### 统一记录方法
|
||||
### 登出记录(AuthController::logout)
|
||||
|
||||
```php
|
||||
/**
|
||||
* 记录登录日志
|
||||
*
|
||||
* @param string $username 用户名
|
||||
* @param int $type 类型:1=登录 2=登出
|
||||
* @param int $status 状态:0=成功 1=失败
|
||||
* @param string $error 错误信息(成功时为空)
|
||||
* @param string $message 结果消息
|
||||
*/
|
||||
public function recordLoginLog(string $username, int $type, int $status, string $error = '', string $message = ''): void
|
||||
public function logout(Request $request): JsonResponse
|
||||
{
|
||||
$requestInfo = RequestInfoService::create(request());
|
||||
|
||||
try {
|
||||
LoginLog::create([
|
||||
'username' => $username,
|
||||
'type' => $type,
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'ip' => $requestInfo->getIp(),
|
||||
'location' => $requestInfo->getIpLocation(),
|
||||
'os' => $requestInfo->getOs(),
|
||||
'browser' => $requestInfo->getBrowser(),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// 日志记录失败不影响业务
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 登出记录
|
||||
|
||||
```php
|
||||
// app/controller/LoginController.php
|
||||
|
||||
#[Log('系统登录-用户登出', Log::TYPE_LOGOUT)]
|
||||
public function logout(): Json
|
||||
{
|
||||
$token = \jwt\Jwt::getTokenFromHeader();
|
||||
$token = $request->bearerToken();
|
||||
if ($token) {
|
||||
$this->logic->recordLoginLog($this->getLoginUsername(), 2, 0, '', '退出成功');
|
||||
$this->jwtService->revokeToken($token); // 吊销 refresh_token(即时失效)
|
||||
|
||||
$requestInfo = RequestInfoService::create($request);
|
||||
$userInfo = $request->attributes->get('userInfo');
|
||||
// 登出日志:log_type=2
|
||||
LoginLog::create([
|
||||
'username' => $userInfo->username ?? '',
|
||||
'log_type' => 2,
|
||||
'source' => 0,
|
||||
// ... 其余字段同登录记录 ...
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->success(null, '登出成功');
|
||||
}
|
||||
```
|
||||
@@ -146,10 +157,10 @@ public function logout(): Json
|
||||
|
||||
| 方法 | 路径 | 权限码 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| GET | `/api/login/log/page` | sys:login:log:list | 分页列表 |
|
||||
| GET | `/api/login/log/detail/:id` | sys:login:log:detail | 详情 |
|
||||
| DELETE | `/api/login/log/delete/:id` | sys:login:log:delete | 删除 |
|
||||
| DELETE | `/api/login/log/batchDelete` | sys:login:log:delete | 批量删除 |
|
||||
| GET | `/api/login/log/page` | sys:loginLog:page | 分页列表 |
|
||||
| GET | `/api/login/log/detail/{id}` | sys:loginLog:detail | 详情 |
|
||||
| DELETE | `/api/login/log/delete/{id}` | sys:loginLog:delete | 删除 |
|
||||
| DELETE | `/api/login/log/batchDelete` | sys:loginLog:batchDelete | 批量删除 |
|
||||
|
||||
## 安全分析
|
||||
|
||||
@@ -158,8 +169,8 @@ public function logout(): Json
|
||||
```sql
|
||||
-- 短时间内同一 IP 大量失败登录(5分钟内失败超过10次)
|
||||
SELECT ip, COUNT(*) as fail_count, MIN(create_time) as first_time, MAX(create_time) as last_time
|
||||
FROM think_login_log
|
||||
WHERE status = 0
|
||||
FROM laravel_login_log
|
||||
WHERE status = 1
|
||||
AND create_time > DATE_SUB(NOW(), INTERVAL 5 MINUTE)
|
||||
GROUP BY ip
|
||||
HAVING fail_count >= 10
|
||||
@@ -171,33 +182,21 @@ ORDER BY fail_count DESC;
|
||||
```sql
|
||||
-- 同一账号短时间内大量失败(可能被暴力破解)
|
||||
SELECT username, COUNT(*) as fail_count
|
||||
FROM think_login_log
|
||||
WHERE status = 0
|
||||
FROM laravel_login_log
|
||||
WHERE status = 1
|
||||
AND create_time > DATE_SUB(NOW(), INTERVAL 1 HOUR)
|
||||
GROUP BY username
|
||||
HAVING fail_count >= 5
|
||||
ORDER BY fail_count DESC;
|
||||
```
|
||||
|
||||
### 异地登录检测
|
||||
|
||||
```sql
|
||||
-- 同一用户在不同城市短时间内登录
|
||||
SELECT username, location, COUNT(*) as login_count
|
||||
FROM think_login_log
|
||||
WHERE status = 1
|
||||
AND create_time > DATE_SUB(NOW(), INTERVAL 1 DAY)
|
||||
GROUP BY username, location
|
||||
HAVING login_count >= 1;
|
||||
```
|
||||
|
||||
### 高频恶意 IP
|
||||
|
||||
```sql
|
||||
-- 统计失败登录最多的 IP(最近7天)
|
||||
SELECT ip, location, COUNT(*) as fail_count
|
||||
FROM think_login_log
|
||||
WHERE status = 0
|
||||
FROM laravel_login_log
|
||||
WHERE status = 1
|
||||
AND create_time > DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||
GROUP BY ip, location
|
||||
ORDER BY fail_count DESC
|
||||
@@ -211,25 +210,29 @@ LIMIT 20;
|
||||
SELECT
|
||||
DATE(create_time) as date,
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) as success,
|
||||
ROUND(SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) / COUNT(*) * 100, 2) as success_rate
|
||||
FROM think_login_log
|
||||
WHERE type = 1
|
||||
SUM(CASE WHEN status = 0 THEN 1 ELSE 0 END) as success,
|
||||
ROUND(SUM(CASE WHEN status = 0 THEN 1 ELSE 0 END) / COUNT(*) * 100, 2) as success_rate
|
||||
FROM laravel_login_log
|
||||
WHERE log_type = 1
|
||||
AND create_time > DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||
GROUP BY DATE(create_time)
|
||||
ORDER BY date;
|
||||
```
|
||||
|
||||
:::tip status 语义提醒
|
||||
本项目 `status`:**0=成功,1=失败**(与操作日志一致)。上面的检测 SQL 已按此约定编写。
|
||||
:::
|
||||
|
||||
## 与操作日志的区别
|
||||
|
||||
| 维度 | 登录日志 | 操作日志 |
|
||||
|------|---------|---------|
|
||||
| 表 | `think_login_log` | `think_operation_log` |
|
||||
| 记录方 | LoginLogic 手动记录 | LogMiddleware 自动记录 |
|
||||
| 记录内容 | 仅登录/登出 | 所有写操作 + 导出/导入 |
|
||||
| 触发方式 | 代码中调用 `recordLoginLog()` | `#[Log]` 注解 |
|
||||
| 表 | `laravel_login_log` | `laravel_operation_log` |
|
||||
| 记录方 | `AuthController` 手动记录 | `LogOperation` 中间件(terminate 落库) |
|
||||
| 记录内容 | 仅登录/登出 | 写操作 + 导出/导入/下载 |
|
||||
| 触发方式 | 代码中 `writeLoginLog()` | `#[OperationLog]` 注解 |
|
||||
| 用途 | 登录安全审计 | 业务操作审计 |
|
||||
| 失败也记录 | ✓(登录失败必须记录) | ✗(仅记录成功的写操作) |
|
||||
| 失败也记录 | ✓(登录失败必须记录) | ✓(响应失败状态也记录) |
|
||||
|
||||
## 日志保留策略
|
||||
|
||||
@@ -240,7 +243,7 @@ ORDER BY date;
|
||||
|
||||
```sql
|
||||
-- 示例:清理 180 天前的登录日志
|
||||
DELETE FROM think_login_log WHERE create_time < DATE_SUB(NOW(), INTERVAL 180 DAY);
|
||||
DELETE FROM laravel_login_log WHERE create_time < DATE_SUB(NOW(), INTERVAL 180 DAY);
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
@@ -251,5 +254,4 @@ DELETE FROM think_login_log WHERE create_time < DATE_SUB(NOW(), INTERVAL 180 DAY
|
||||
| 统一错误提示 | "用户名或密码错误"不区分用户不存在和密码错误 |
|
||||
| 定期分析 | 定期检查异常登录模式(暴力破解、异地登录) |
|
||||
| IP 黑名单 | 将高频恶意 IP 加入黑名单(Nginx/防火墙层) |
|
||||
| 配合验证码 | 验证码防刷 + 登录日志分析形成双重防护 |
|
||||
| 日志不可篡改 | 登录日志仅 INSERT,无 UPDATE 接口 |
|
||||
| 配合验证码 | 验证码防刷 + 登录日志分析形成双重防护(配合 IP 限流,见 [5.3 验证码机制](/zh/core/captcha.html)) |
|
||||
|
||||
Reference in New Issue
Block a user