فهرست منبع

jpassword login

tanlie 7 ساعت پیش
والد
کامیت
67a138553e

+ 1 - 1
app/api/controller/Passport.php

@@ -28,7 +28,7 @@ use cores\exception\BaseException;
 class Passport extends Controller
 {
     /**
-     * 登录接口 (需提交手机号、短信验证码、第三方用户信息)
+     * 登录接口 (需提交手机号、登录密码、第三方用户信息)
      * @return Json
      * @throws BaseException
      * @throws \think\Exception

+ 3 - 1
app/api/model/User.php

@@ -35,7 +35,9 @@ class User extends UserModel
         'is_delete',
         'store_id',
         'create_time',
-        'update_time'
+        'update_time',
+        // 密码哈希绝不返回给客户端
+        'password',
     ];
 
     /**

+ 105 - 13
app/api/service/passport/Login.php

@@ -37,8 +37,14 @@ class Login extends BaseService
     // 用于生成token的自定义盐
     const TOKEN_SALT = 'user_salt';
 
+    // 同一手机号允许的连续登录失败次数
+    const LOGIN_FAIL_LIMIT = 5;
+
+    // 登录失败锁定时间 (秒)
+    const LOGIN_FAIL_LOCK_TIME = 600;
+
     /**
-     * 执行用户登录
+     * 执行用户登录 (手机号 + 密码)
      * @param array $data
      * @return bool
      * @throws BaseException
@@ -51,8 +57,10 @@ class Login extends BaseService
     {
         // 数据验证
         $this->validate($data);
-        // 自动登录注册
-        $this->register($data);
+        // 兜底: 第三方用户信息 (非必填)
+        $data = \array_merge(['isParty' => false, 'partyData' => []], $data);
+        // 手机号 + 密码登录 (未注册的手机号自动注册)
+        $this->loginByPassword($data['mobile'], $data['password'], $data['isParty'], $data['partyData']);
         // 保存第三方用户信息
         $this->createUserOauth($this->getUserId(), $data['isParty'], $data['partyData']);
         // 记录登录态
@@ -192,7 +200,87 @@ class Login extends BaseService
     }
 
     /**
-     * 自动登录注册
+     * 手机号 + 密码登录
+     *  1. 用户不存在: 自动注册并设置密码 (与页面文案“未注册的手机号登录后将自动注册”一致)
+     *  2. 用户存在但未设置密码: 本次输入的密码作为初始密码 (兼容历史短信登录的老用户)
+     *  3. 用户已设置密码: 校验密码, 失败累计次数并锁定
+     * @param string $mobile 手机号
+     * @param string $password 登录密码(明文)
+     * @param bool $isParty 是否存在第三方用户信息
+     * @param array $partyData 第三方用户数据
+     * @return void
+     * @throws BaseException
+     * @throws \think\Exception
+     * @throws \think\db\exception\DataNotFoundException
+     * @throws \think\db\exception\DbException
+     * @throws \think\db\exception\ModelNotFoundException
+     */
+    private function loginByPassword(string $mobile, string $password, bool $isParty, array $partyData = []): void
+    {
+        // 查询用户是否已存在
+        $userInfo = UserModel::detail(['mobile' => $mobile]);
+        // 用户不存在: 注册新用户并设置密码
+        if (empty($userInfo)) {
+            $this->createUser($mobile, $isParty, $partyData, $password);
+            return;
+        }
+        // 用户已存在但尚未设置密码: 本次输入的密码作为初始密码
+        if (!$userInfo->hasPassword()) {
+            $this->updateUser($userInfo, $isParty, $partyData, $password);
+            return;
+        }
+        // 是否已被锁定
+        $this->checkLoginFailTimes($mobile);
+        // 校验密码
+        if (!$userInfo->verifyPassword($password)) {
+            $this->recordLoginFail($mobile);
+            throwError('手机号或密码不正确');
+        }
+        // 登录成功, 清空失败记录
+        Cache::delete($this->getLoginFailCacheKey($mobile));
+        // 更新用户登录信息
+        $this->updateUser($userInfo, $isParty, $partyData);
+    }
+
+    /**
+     * 登录失败次数缓存的key
+     * @param string $mobile
+     * @return string
+     */
+    private function getLoginFailCacheKey(string $mobile): string
+    {
+        return 'login_fail_' . md5("{$this->storeId}_{$mobile}");
+    }
+
+    /**
+     * 检查登录失败次数 (超出限制则锁定)
+     * @param string $mobile
+     * @return void
+     * @throws BaseException
+     */
+    private function checkLoginFailTimes(string $mobile): void
+    {
+        $times = (int)Cache::get($this->getLoginFailCacheKey($mobile), 0);
+        if ($times >= self::LOGIN_FAIL_LIMIT) {
+            $minutes = (int)(self::LOGIN_FAIL_LOCK_TIME / 60);
+            \throwError("密码错误次数过多, 请{$minutes}分钟后再试");
+        }
+    }
+
+    /**
+     * 记录一次登录失败
+     * @param string $mobile
+     * @return void
+     */
+    private function recordLoginFail(string $mobile): void
+    {
+        $key = $this->getLoginFailCacheKey($mobile);
+        $times = (int)Cache::get($key, 0) + 1;
+        Cache::set($key, $times, self::LOGIN_FAIL_LOCK_TIME);
+    }
+
+    /**
+     * 自动登录注册 (第三方/授权手机号登录, 无需密码)
      * @param array $data
      * @throws BaseException
      * @throws \think\Exception
@@ -218,13 +306,14 @@ class Login extends BaseService
      * @param string $mobile 手机号
      * @param bool $isParty 是否存在第三方用户信息
      * @param array $partyData 用户信息(第三方)
+     * @param string $password 登录密码(明文, 为空则不设置)
      * @return void
      * @throws \think\Exception
      * @throws \think\db\exception\DataNotFoundException
      * @throws \think\db\exception\DbException
      * @throws \think\db\exception\ModelNotFoundException
      */
-    private function createUser(string $mobile, bool $isParty, array $partyData = []): void
+    private function createUser(string $mobile, bool $isParty, array $partyData = [], string $password = ''): void
     {
         // 用户信息
         $data = [
@@ -234,6 +323,10 @@ class Login extends BaseService
             'last_login_time' => \time(),
             'store_id' => $this->storeId
         ];
+        // 登录密码 (模型修改器会自动使用 password_hash 加密)
+        if ($password !== '') {
+            $data['password'] = $password;
+        }
         // 写入用户信息(第三方)
         if ($isParty === true && !empty($partyData)) {
             $partyUserInfo = PartyService::partyUserInfo($partyData, true);
@@ -259,14 +352,19 @@ class Login extends BaseService
      * @param UserModel $userInfo
      * @param bool $isParty 是否存在第三方用户信息
      * @param array $partyData 用户信息(第三方)
+     * @param string $password 登录密码(明文, 为空则不更新)
      */
-    private function updateUser(UserModel $userInfo, bool $isParty, array $partyData = []): void
+    private function updateUser(UserModel $userInfo, bool $isParty, array $partyData = [], string $password = ''): void
     {
         // 用户信息
         $data = [
             'last_login_time' => \time(),
             'store_id' => $this->storeId
         ];
+        // 设置/更新登录密码 (模型修改器会自动使用 password_hash 加密)
+        if ($password !== '') {
+            $data['password'] = $password;
+        }
         // 写入用户信息(第三方)
         // 如果不需要每次登录都更新微信用户头像昵称, 下面几行代码可以屏蔽掉
 //        if ($isParty === true && !empty($partyData)) {
@@ -310,17 +408,11 @@ class Login extends BaseService
      */
     private function validate(array $data): void
     {
-        // 数据验证
+        // 数据验证 (手机号 + 登录密码)
         $validate = new ValidateLogin;
         if (!$validate->check($data)) {
             throwError($validate->getError());
         }
-        // 验证短信验证码是否匹配
-        // try {
-        //    CaptchaApi::checkSms($data['smsCode'], $data['mobile']);
-        //} catch (\Exception $e) {
-        //    throwError($e->getMessage() ?: '短信验证码不正确');
-        // }
     }
 
     /**

+ 8 - 6
app/api/validate/passport/Login.php

@@ -15,7 +15,7 @@ namespace app\api\validate\passport;
 use think\Validate;
 
 /**
- * 验证类:发送短信验证码
+ * 验证类:用户登录 (手机号 + 密码)
  * Class Login
  * @package app\api\validate\passport
  */
@@ -26,10 +26,10 @@ class Login extends Validate
      * @var array
      */
     protected $rule = [
-        // 短信验证码 (用户输入)
-        'smsCode' => ['require'],
         // 用户手机号
-        'mobile' => ['require'],
+        'mobile' => ['require', 'mobile'],
+        // 登录密码
+        'password' => ['require', 'length' => '6,32'],
     ];
 
     /**
@@ -37,7 +37,9 @@ class Login extends Validate
      * @var string[]
      */
     protected $message  =   [
-        'smsCode.require' => '短信验证码不能为空',
         'mobile.require' => '手机号不能为空',
+        'mobile.mobile' => '手机号格式不正确',
+        'password.require' => '登录密码不能为空',
+        'password.length' => '登录密码长度必须在6~32位之间',
     ];
-}
+}

+ 40 - 0
app/common/model/User.php

@@ -82,6 +82,46 @@ class User extends BaseModel
         return $this->gender[$value];
     }
 
+    /**
+     * 修改器:登录密码
+     * 注意:写入时自动使用 password_hash(bcrypt) 加密,数据库中不保存明文密码
+     * @param mixed $value 明文密码
+     * @return string
+     */
+    public function setPasswordAttr($value): string
+    {
+        $value = (string)$value;
+        // 空密码不做加密 (避免生成无效哈希)
+        if ($value === '') {
+            return '';
+        }
+        return password_hash($value, PASSWORD_BCRYPT);
+    }
+
+    /**
+     * 校验登录密码
+     * @param string $password 用户输入的明文密码
+     * @return bool
+     */
+    public function verifyPassword(string $password): bool
+    {
+        // 未设置密码的账号一律校验失败
+        $hash = (string)$this->getData('password');
+        if ($hash === '') {
+            return false;
+        }
+        return password_verify($password, $hash);
+    }
+
+    /**
+     * 是否已设置登录密码
+     * @return bool
+     */
+    public function hasPassword(): bool
+    {
+        return (string)$this->getData('password') !== '';
+    }
+
     /**
      * 获取用户信息
      * @param $where

+ 1 - 0
public/install/data/install.sql

@@ -6443,6 +6443,7 @@ DROP TABLE IF EXISTS `yoshop_user`;
 CREATE TABLE `yoshop_user` (
   `user_id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '用户ID',
   `mobile` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '用户手机号',
+  `password` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '登录密码(password_hash生成的哈希值)',
   `nick_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '用户昵称',
   `avatar_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '头像文件ID',
   `gender` tinyint(3) unsigned NOT NULL DEFAULT '0' COMMENT '性别',

+ 8 - 0
数据库修改记录/feature-password.sql

@@ -0,0 +1,8 @@
+
+## 本文件是 feature/password 分支的数据库修改记录
+## 说明:用户登录方式由「短信验证码」改为「手机号 + 密码」时需执行本文件的sql内容
+
+
+# 用户记录表 - 登录密码 (bcrypt 哈希,不存明文)
+ALTER TABLE `yoshop_user`
+ADD COLUMN `password` varchar(255) NOT NULL DEFAULT '' COMMENT '登录密码(password_hash生成的哈希值)' AFTER `mobile`;