first
This commit is contained in:
441
server-api/app/support/AppAuthService.php
Normal file
441
server-api/app/support/AppAuthService.php
Normal file
@@ -0,0 +1,441 @@
|
||||
<?php
|
||||
|
||||
namespace app\support;
|
||||
|
||||
use Webman\Http\Request;
|
||||
use support\think\Db;
|
||||
|
||||
class AppAuthService
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->ensurePasswordColumn();
|
||||
$this->ensureTokenTable();
|
||||
$this->ensureSmsCodeTable();
|
||||
}
|
||||
|
||||
public function sendLoginCode(string $mobile, Request $request): array
|
||||
{
|
||||
$mobile = $this->normalizeMobile($mobile);
|
||||
$user = Db::name('users')->where('mobile', $mobile)->find();
|
||||
if ($user && ($user['status'] ?? 'enabled') !== 'enabled') {
|
||||
throw new \RuntimeException('账号已停用,无法发送验证码');
|
||||
}
|
||||
|
||||
$scene = 'login';
|
||||
$now = time();
|
||||
$latest = Db::name('sms_code_logs')
|
||||
->where('mobile', $mobile)
|
||||
->where('scene', $scene)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
if ($latest) {
|
||||
$retryAt = strtotime((string)$latest['created_at']) + 60;
|
||||
if ($retryAt > $now) {
|
||||
throw new \RuntimeException(sprintf('请 %d 秒后再试', max(1, $retryAt - $now)));
|
||||
}
|
||||
}
|
||||
|
||||
$todayStart = date('Y-m-d 00:00:00');
|
||||
$todayCount = (int)Db::name('sms_code_logs')
|
||||
->where('mobile', $mobile)
|
||||
->where('scene', $scene)
|
||||
->where('created_at', '>=', $todayStart)
|
||||
->count();
|
||||
if ($todayCount >= 20) {
|
||||
throw new \RuntimeException('今日验证码发送次数已达上限,请明天再试');
|
||||
}
|
||||
|
||||
$code = (string)random_int(100000, 999999);
|
||||
$nowText = date('Y-m-d H:i:s', $now);
|
||||
$expireTime = date('Y-m-d H:i:s', $now + 300);
|
||||
|
||||
$sendResult = null;
|
||||
$sendStatus = 'failed';
|
||||
$failedReason = '';
|
||||
try {
|
||||
$sendResult = (new AppSmsService())->sendLoginCode($mobile, $code);
|
||||
$sendStatus = ($sendResult['provider'] ?? '') === 'debug' ? 'mock' : 'success';
|
||||
} catch (\Throwable $e) {
|
||||
$failedReason = $this->truncateText($e->getMessage(), 250);
|
||||
}
|
||||
|
||||
Db::name('sms_code_logs')->insert([
|
||||
'mobile' => $mobile,
|
||||
'scene' => $scene,
|
||||
'code_hash' => $this->codeHash($mobile, $scene, $code),
|
||||
'send_status' => $sendStatus,
|
||||
'provider' => $sendResult['provider'] ?? 'aliyun_sms',
|
||||
'template_code' => $this->systemConfig('sms', 'login_template_code'),
|
||||
'request_id' => $sendResult['request_id'] ?? '',
|
||||
'biz_id' => $sendResult['biz_id'] ?? '',
|
||||
'failed_reason' => $failedReason,
|
||||
'expire_time' => $expireTime,
|
||||
'used_at' => null,
|
||||
'send_ip' => $request->getRealIp(),
|
||||
'created_at' => $nowText,
|
||||
'updated_at' => $nowText,
|
||||
]);
|
||||
|
||||
if ($sendStatus === 'failed') {
|
||||
throw new \RuntimeException($failedReason ?: '验证码发送失败');
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'mobile' => $mobile,
|
||||
'scene' => $scene,
|
||||
'expire_seconds' => 300,
|
||||
'retry_after_seconds' => 60,
|
||||
];
|
||||
if (($sendResult['debug_code'] ?? null) !== null) {
|
||||
$payload['debug_code'] = $sendResult['debug_code'];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public function loginByCode(string $mobile, string $code, Request $request): array
|
||||
{
|
||||
$mobile = $this->normalizeMobile($mobile);
|
||||
$code = trim($code);
|
||||
if (!preg_match('/^\d{6}$/', $code)) {
|
||||
throw new \RuntimeException('验证码格式不正确');
|
||||
}
|
||||
|
||||
$record = Db::name('sms_code_logs')
|
||||
->where('mobile', $mobile)
|
||||
->where('scene', 'login')
|
||||
->whereIn('send_status', ['success', 'mock'])
|
||||
->whereNull('used_at')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if (!$record) {
|
||||
throw new \RuntimeException('验证码不存在或已失效');
|
||||
}
|
||||
|
||||
if (strtotime((string)$record['expire_time']) < time()) {
|
||||
throw new \RuntimeException('验证码已过期,请重新获取');
|
||||
}
|
||||
|
||||
if (!hash_equals((string)$record['code_hash'], $this->codeHash($mobile, 'login', $code))) {
|
||||
throw new \RuntimeException('验证码错误');
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
Db::name('sms_code_logs')->where('id', $record['id'])->update([
|
||||
'used_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$user = Db::name('users')->where('mobile', $mobile)->find();
|
||||
if ($user && ($user['status'] ?? 'enabled') !== 'enabled') {
|
||||
throw new \RuntimeException('账号已停用');
|
||||
}
|
||||
|
||||
if (!$user) {
|
||||
$userId = (int)Db::name('users')->insertGetId([
|
||||
'nickname' => '安心验用户' . substr($mobile, -4),
|
||||
'avatar' => '',
|
||||
'mobile' => $mobile,
|
||||
'password' => '',
|
||||
'status' => 'enabled',
|
||||
'last_login_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
} else {
|
||||
$userId = (int)$user['id'];
|
||||
}
|
||||
|
||||
$this->syncMobileAuth($userId, $mobile, $now);
|
||||
return $this->issueToken($userId, $request, 'sms_code');
|
||||
}
|
||||
|
||||
public function loginByPassword(string $mobile, string $password, Request $request): array
|
||||
{
|
||||
$mobile = $this->normalizeMobile($mobile);
|
||||
$password = trim($password);
|
||||
if ($password === '') {
|
||||
throw new \RuntimeException('密码不能为空');
|
||||
}
|
||||
|
||||
$user = Db::name('users')->where('mobile', $mobile)->find();
|
||||
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
|
||||
throw new \RuntimeException('账号不存在或已停用');
|
||||
}
|
||||
|
||||
$passwordHash = (string)($user['password'] ?? '');
|
||||
if ($passwordHash === '') {
|
||||
throw new \RuntimeException('当前账号尚未设置登录密码,请使用验证码登录');
|
||||
}
|
||||
|
||||
if (!password_verify($password, $passwordHash)) {
|
||||
throw new \RuntimeException('手机号或密码错误');
|
||||
}
|
||||
|
||||
$this->syncMobileAuth((int)$user['id'], $mobile, date('Y-m-d H:i:s'));
|
||||
return $this->issueToken((int)$user['id'], $request, 'password');
|
||||
}
|
||||
|
||||
public function logout(Request $request): void
|
||||
{
|
||||
$token = $this->extractToken($request);
|
||||
if ($token === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
Db::name('user_api_tokens')->where('token_hash', hash('sha256', $token))->delete();
|
||||
}
|
||||
|
||||
public function current(Request $request): ?array
|
||||
{
|
||||
$token = $this->extractToken($request);
|
||||
if ($token === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$record = Db::name('user_api_tokens')
|
||||
->where('token_hash', hash('sha256', $token))
|
||||
->find();
|
||||
if (!$record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!empty($record['expire_time']) && strtotime((string)$record['expire_time']) < time()) {
|
||||
Db::name('user_api_tokens')->where('id', $record['id'])->delete();
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = Db::name('users')->where('id', $record['user_id'])->find();
|
||||
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
|
||||
return null;
|
||||
}
|
||||
|
||||
Db::name('user_api_tokens')->where('id', $record['id'])->update([
|
||||
'last_active_at' => date('Y-m-d H:i:s'),
|
||||
'last_ip' => $request->getRealIp(),
|
||||
'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return $this->userInfo((int)$user['id']);
|
||||
}
|
||||
|
||||
public function savePassword(int $userId, string $currentPassword, string $newPassword): array
|
||||
{
|
||||
$user = Db::name('users')->where('id', $userId)->find();
|
||||
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
|
||||
throw new \RuntimeException('账号不存在或已停用');
|
||||
}
|
||||
|
||||
$currentHash = (string)($user['password'] ?? '');
|
||||
$hadPassword = $currentHash !== '';
|
||||
if ($currentHash !== '') {
|
||||
if ($currentPassword === '') {
|
||||
throw new \RuntimeException('请输入当前密码');
|
||||
}
|
||||
if (!password_verify($currentPassword, $currentHash)) {
|
||||
throw new \RuntimeException('当前密码错误');
|
||||
}
|
||||
}
|
||||
|
||||
$this->validateNewPassword($newPassword);
|
||||
|
||||
Db::name('users')->where('id', $userId)->update([
|
||||
'password' => password_hash($newPassword, PASSWORD_BCRYPT),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return [
|
||||
'user_id' => $userId,
|
||||
'password_set' => true,
|
||||
'had_password' => $hadPassword,
|
||||
];
|
||||
}
|
||||
|
||||
private function issueToken(int $userId, Request $request, string $authType): array
|
||||
{
|
||||
$user = Db::name('users')->where('id', $userId)->find();
|
||||
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
|
||||
throw new \RuntimeException('账号不存在或已停用');
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(24));
|
||||
$tokenHash = hash('sha256', $token);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$expireTime = date('Y-m-d H:i:s', time() + 30 * 24 * 3600);
|
||||
|
||||
Db::name('user_api_tokens')->where('user_id', $userId)->delete();
|
||||
Db::name('user_api_tokens')->insert([
|
||||
'user_id' => $userId,
|
||||
'token_hash' => $tokenHash,
|
||||
'auth_type' => $authType,
|
||||
'expire_time' => $expireTime,
|
||||
'last_active_at' => $now,
|
||||
'last_ip' => $request->getRealIp(),
|
||||
'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
Db::name('users')->where('id', $userId)->update([
|
||||
'last_login_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
return [
|
||||
'token' => $token,
|
||||
'user_info' => $this->userInfo($userId),
|
||||
];
|
||||
}
|
||||
|
||||
private function userInfo(int $userId): array
|
||||
{
|
||||
$user = Db::name('users')->where('id', $userId)->find();
|
||||
return [
|
||||
'id' => (int)($user['id'] ?? 0),
|
||||
'nickname' => $user['nickname'] ?: '安心验用户',
|
||||
'mobile' => $user['mobile'] ?? '',
|
||||
'avatar' => $user['avatar'] ?? '',
|
||||
'status' => $user['status'] ?? 'enabled',
|
||||
'password_set' => ((string)($user['password'] ?? '')) !== '',
|
||||
];
|
||||
}
|
||||
|
||||
private function syncMobileAuth(int $userId, string $mobile, string $now): void
|
||||
{
|
||||
$existing = Db::name('user_auths')
|
||||
->where('auth_type', 'mobile')
|
||||
->where('auth_key', $mobile)
|
||||
->find();
|
||||
|
||||
$payload = [
|
||||
'user_id' => $userId,
|
||||
'auth_type' => 'mobile',
|
||||
'auth_key' => $mobile,
|
||||
'auth_open_id' => '',
|
||||
'auth_union_id' => '',
|
||||
'auth_extra' => json_encode(['mobile' => $mobile], JSON_UNESCAPED_UNICODE),
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
Db::name('user_auths')->where('id', $existing['id'])->update($payload);
|
||||
return;
|
||||
}
|
||||
|
||||
$payload['created_at'] = $now;
|
||||
Db::name('user_auths')->insert($payload);
|
||||
}
|
||||
|
||||
private function normalizeMobile(string $mobile): string
|
||||
{
|
||||
$mobile = preg_replace('/\D+/', '', $mobile) ?: '';
|
||||
if (!preg_match('/^1\d{10}$/', $mobile)) {
|
||||
throw new \RuntimeException('请输入正确的手机号');
|
||||
}
|
||||
return $mobile;
|
||||
}
|
||||
|
||||
private function validateNewPassword(string $password): void
|
||||
{
|
||||
if (mb_strlen($password) < 8) {
|
||||
throw new \RuntimeException('密码长度不能少于 8 位');
|
||||
}
|
||||
if (!preg_match('/[A-Za-z]/', $password) || !preg_match('/\d/', $password)) {
|
||||
throw new \RuntimeException('密码需同时包含字母和数字');
|
||||
}
|
||||
}
|
||||
|
||||
private function codeHash(string $mobile, string $scene, string $code): string
|
||||
{
|
||||
return hash('sha256', implode('|', [$mobile, $scene, $code]));
|
||||
}
|
||||
|
||||
private function systemConfig(string $groupCode, string $configKey): string
|
||||
{
|
||||
$row = Db::name('system_configs')
|
||||
->where('config_group', $groupCode)
|
||||
->where('config_key', $configKey)
|
||||
->find();
|
||||
|
||||
return trim((string)($row['config_value'] ?? ''));
|
||||
}
|
||||
|
||||
private function extractToken(Request $request): string
|
||||
{
|
||||
$authorization = trim((string)$request->header('authorization', ''));
|
||||
if (preg_match('/^Bearer\s+(.+)$/i', $authorization, $matches)) {
|
||||
return trim($matches[1]);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function ensurePasswordColumn(): void
|
||||
{
|
||||
$column = Db::query("SHOW COLUMNS FROM users LIKE 'password'");
|
||||
if ($column) {
|
||||
return;
|
||||
}
|
||||
|
||||
Db::execute("ALTER TABLE users ADD COLUMN password VARCHAR(255) NOT NULL DEFAULT '' AFTER mobile");
|
||||
}
|
||||
|
||||
private function ensureTokenTable(): void
|
||||
{
|
||||
Db::execute(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS user_api_tokens (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL,
|
||||
auth_type VARCHAR(32) NOT NULL DEFAULT 'password',
|
||||
expire_time DATETIME NOT NULL,
|
||||
last_active_at DATETIME NULL DEFAULT NULL,
|
||||
last_ip VARCHAR(64) NOT NULL DEFAULT '',
|
||||
user_agent VARCHAR(500) NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_user_api_tokens_token_hash (token_hash),
|
||||
KEY idx_user_api_tokens_user_id (user_id),
|
||||
KEY idx_user_api_tokens_expire_time (expire_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户登录Token';
|
||||
SQL);
|
||||
}
|
||||
|
||||
private function ensureSmsCodeTable(): void
|
||||
{
|
||||
Db::execute(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS sms_code_logs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
mobile VARCHAR(32) NOT NULL,
|
||||
scene VARCHAR(32) NOT NULL DEFAULT 'login',
|
||||
code_hash VARCHAR(64) NOT NULL,
|
||||
send_status VARCHAR(32) NOT NULL DEFAULT 'success',
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'aliyun_sms',
|
||||
template_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||
request_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
biz_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
failed_reason VARCHAR(255) NOT NULL DEFAULT '',
|
||||
expire_time DATETIME NOT NULL,
|
||||
used_at DATETIME NULL DEFAULT NULL,
|
||||
send_ip VARCHAR(64) NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_sms_code_logs_mobile_scene (mobile, scene),
|
||||
KEY idx_sms_code_logs_expire_time (expire_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='短信验证码发送记录';
|
||||
SQL);
|
||||
}
|
||||
|
||||
private function truncateText(string $value, int $maxLength): string
|
||||
{
|
||||
if (mb_strlen($value) <= $maxLength) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return mb_substr($value, 0, $maxLength);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user