diff --git a/.env.example b/.env.example index 85f7a425..e1f3dcad 100644 --- a/.env.example +++ b/.env.example @@ -67,3 +67,6 @@ ALIYUN_OSS_STS_HOST = ALIYUN_SMS_ACCESS_ID= ALIYUN_SMS_ACCESS_KEY= ALIYUN_SMS_SIGN_NAME= + +WECHAT_MINI_PROGRAM_APPID= +WECHAT_MINI_PROGRAM_SECRET= diff --git a/app/Endpoint/Api/Http/Controllers/Auth/SocialiteAuthController.php b/app/Endpoint/Api/Http/Controllers/Auth/SocialiteAuthController.php new file mode 100644 index 00000000..b63dad84 --- /dev/null +++ b/app/Endpoint/Api/Http/Controllers/Auth/SocialiteAuthController.php @@ -0,0 +1,334 @@ +validate([ + 'code' => ['bail', 'required', 'string'], + ]); + $code = $input['code']; + + //获取第三方用户信息 + $socialiteUser = $this->getSocialiteUserByCode($provider, $code); + + //通过第三方用户信息登录已绑定账号 + $token = $this->loginUser([ + 'socialite_type'=>$provider, + 'socialite_id'=>$socialiteUser?->id, + ], $request); + + return response()->json([ + 'token' => $token?->plainTextToken, + ]); + } + + public function codeBindUser($provider, Request $request) + { + $type = $request->input('type', 'default'); + + $rules = [ + 'code' => ['bail', 'required', 'string'], + 'inviter_code' => ['bail', 'nullable', 'string'], + ]; + switch ($type) { + case 'default'://手机号+密码 + $rules = array_merge($rules, [ + 'phone' => ['bail', 'required', 'string'], + 'password' => ['bail', 'required', 'string'], + ]); + break; + case 'sms_code'://手机号+验证码 + $rules = array_merge($rules, [ + 'phone' => ['bail', 'required', new PhoneNumberRule()], + 'verify_code' => ['bail', 'required', 'string'], + ]); + break; + case 'wechat_mini'://微信小程序解密手机号 + $rules = array_merge($rules, [ + 'data' => ['bail', 'required', 'string'], + 'iv' => ['bail', 'required', 'string'], + ]); + break; + default://默认手机号+密码 + $rules = array_merge($rules, [ + 'phone' => ['bail', 'required', 'string'], + 'password' => ['bail', 'required', 'string'], + ]); + break; + } + + $input = $request->validate($rules); + $code = $input['code']; + + //获取第三方用户信息 + $socialiteUser = $this->getSocialiteUserByCode($provider, $code); + + //绑定用户,并返回token + $token = $this->bindUser([ + 'socialite_type'=>$provider, + 'socialite_id'=>$socialiteUser?->id, + ], $type ?? 'default', $request); + + return response()->json([ + 'token' => $token?->plainTextToken, + ]); + } + + /** + * [目前支持:微信小程序] + */ + protected function getSocialiteUserByCode($provider, $code) + { + //获取第三方用户信息 + $user = null; + $config = config('socialite', []); + $socialite = new SocialiteManager($config); + switch ($provider) { + case 'wechat-mini'://微信小程序 + $user = $socialite->create('wehcat-mini')->userFromCode($code); + break; + default: + throw new BizException(404); + } + return $user; + } + + /** + * 第三方登录现有绑定的用户 + * + * @param [array] $socialite + * @param [Request] $request + */ + protected function loginUser(array $socialite, Request $request) + { + $token = null; + $socialiteUser = SocialiteUser::firstOrCreate($socialite); + $user = $socialiteUser->user; + if ($user) { + $user->last_login_at = now(); + $user->last_login_ip = $request->realIp(); + $user->save(); + // 获取登录设备 + $device = $request->header('client-app', Device::UNIAPP); + + switch ($device) { + case Device::MERCHANT: + if ($user->userInfo?->agent_level < UserInfo::AGENT_LEVEL_VIP) { + throw new BizException('账户没有权限'); + } + + // 清理此用户的商户端令牌 + $user->tokens()->where('name', $device)->delete(); + // 颁发新的商户端令牌 + $token = $user->createToken($device); + break; + case Device::DEALER: + if (!$user->isDealer()) { + throw new BizException('账户没有权限'); + } + + // 清理此用户的商户端令牌 + $user->tokens()->where('name', $device)->delete(); + // 颁发新的商户端令牌 + $token = $user->createToken($device); + break; + default: + $device = Device::UNIAPP; + // 清理此用户的商城端令牌 + $user->tokens()->where('name', $device)->delete(); + // 颁发新的商城端令牌 + $token = $user->createToken($device, ['mall']); + break; + } + } + return $token; + } + + protected function bindUser(array $socialite, string $type, Request $request) + { + $token = null; + $socialiteUser = SocialiteUser::firstOrCreate($socialite); + $user = null; + $input = $request->input(); + switch ($type) { + case 'default'://手机号+密码 + $user = User::where('phone', $input['phone'])->first(); + //手机号不存在,或者密码错误 + if (! $user?->verifyPassword($input['password'])) { + throw new BizException(__('Incorrect account or password')); + } + break; + case 'sms_code'://手机号+验证码 + app(SmsCodeService::class)->validate( + $input['phone'], + SmsCode::TYPE_REGISTER, + $input['verify_code'] + ); + $user = User::where('phone', $input['phone'])->first(); + break; + case 'wechat_mini'://微信小程序解密手机号 + //解密失败 + $app = EasyWeChatFactory::miniProgram([ + 'app_id' => config('wechat.mini_program.default.app_id', ''), + 'secret' => config('wechat.mini_program.default.secret', ''), + + // 下面为可选项 + // 指定 API 调用返回结果的类型:array(default)/collection/object/raw/自定义类名 + 'response_type' => 'array', + + 'log' => [ + 'level' => 'debug', + 'file' => storage_path('logs/wechat-mini.log'), + ], + ]); + $session = Cache::get($socialite['socialite_id']); + try { + $decryptedData = $app->encryptor->decryptData($session, $input['iv'], $input['data']); + } catch (\EasyWeChat\Kernel\Exceptions\DecryptException $e) { + return $this->error('系统错误, 请重新进入小程序'); + } + $phone = data_get($decryptedData, 'phoneNumber'); + //解密成功,$user + $user = User::where('phone', $phone)->first(); + break; + } + + //走登录逻辑 + if ($user) { + $user->last_login_at = now(); + $user->last_login_ip = $request->realIp(); + $user->save(); + + // 获取登录设备 + $device = $request->header('client-app', Device::UNIAPP); + + switch ($device) { + case Device::MERCHANT: + if ($user->userInfo?->agent_level < UserInfo::AGENT_LEVEL_VIP) { + throw new BizException('账户没有权限'); + } + + // 清理此用户的商户端令牌 + $user->tokens()->where('name', $device)->delete(); + // 颁发新的商户端令牌 + $token = $user->createToken($device); + break; + case Device::DEALER: + if (!$user->isDealer()) { + throw new BizException('账户没有权限'); + } + + // 清理此用户的商户端令牌 + $user->tokens()->where('name', $device)->delete(); + // 颁发新的商户端令牌 + $token = $user->createToken($device); + break; + default: + $device = Device::UNIAPP; + // 清理此用户的商城端令牌 + $user->tokens()->where('name', $device)->delete(); + // 颁发新的商城端令牌 + $token = $user->createToken($device, ['mall']); + break; + } + } else {//走注册逻辑 + $time = now(); + $ip = $request->realIp(); + $inviter = $this->findUserByCode((string) Arr::get($input, 'code')); + try { + DB::beginTransaction(); + + $user = User::create( + array_merge($input, [ + 'phone_verified_at' => $time, + 'register_ip' => $ip, + 'last_login_at' => $time, + 'last_login_ip' => $ip, + ]), + $inviter + ); + + DB::commit(); + } catch (Throwable $e) { + DB::rollBack(); + + report($e); + + throw new BizException(__('Registration failed, please try again')); + } + + // 获取登录设备 + $device = $request->header('client-app', Device::UNIAPP); + + switch ($device) { + case Device::DEALER: + $token = $user->createToken(Device::DEALER); + break; + default: + $token = $user->createToken(Device::UNIAPP, ['mall']); + break; + } + } + //解绑以前的关系 + SocialiteUser::where('user_id', $user->id)->update([ + 'user_id' => null, + ]); + //绑定用户和三方信息关系 + $socialiteUser->update([ + 'user_id' => $user->id, + ]); + + return $token; + } + + /** + * 通过邀请码搜索用户 + * + * @param string $code + * @return \App\Models\User|null + * + * @throws \App\Exceptions\BizException + */ + protected function findUserByCode(string $code): ?User + { + if ($code === '') { + return null; + } + + $user = User::when(PhoneNumber::validate($code), function ($query) use ($code) { + $query->where('phone', $code); + }, function ($query) use ($code) { + $query->whereRelation('userInfo', 'code', $code); + })->first(); + + if ($user === null) { + throw new BizException(__('Inviter does not exist')); + } + + return $user; + } +} diff --git a/app/Endpoint/Api/Http/Controllers/WechatMiniController.php b/app/Endpoint/Api/Http/Controllers/WechatMiniController.php new file mode 100644 index 00000000..ef2c0364 --- /dev/null +++ b/app/Endpoint/Api/Http/Controllers/WechatMiniController.php @@ -0,0 +1,12 @@ +'socialite', + ], function () { + Route::post('code-auth/{provider}', [Auth\SocialiteAuthController::class, 'codeAuth']); + Route::post('code-bind-user/{provider}', [Auth\SocialiteAuthController::class, 'codeBindUser']); + }); + Route::middleware(['auth:api'])->group(function () { // 我的信息 Route::get('me', [UserController::class, 'show']); diff --git a/app/Models/SocialiteUser.php b/app/Models/SocialiteUser.php new file mode 100644 index 00000000..3c030195 --- /dev/null +++ b/app/Models/SocialiteUser.php @@ -0,0 +1,22 @@ +belongsTo(User::class); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 3bf420c6..a29f94ec 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -9,6 +9,7 @@ use EasyWeChat\Payment\Application as EasyWeChatPaymentApplication; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\ServiceProvider; use Overtrue\EasySms\EasySms; use Symfony\Component\HttpFoundation\HeaderUtils; diff --git a/app/Providers/Socialite/WechatMini.php b/app/Providers/Socialite/WechatMini.php new file mode 100644 index 00000000..16da36cd --- /dev/null +++ b/app/Providers/Socialite/WechatMini.php @@ -0,0 +1,76 @@ + $this->config->get('client_id'), + 'secret' => $this->config->get('client_secret'), + + // 下面为可选项 + // 指定 API 调用返回结果的类型:array(default)/collection/object/raw/自定义类名 + 'response_type' => 'array', + + 'log' => [ + 'level' => 'debug', + 'file' => storage_path('logs/wechat-mini.log'), + ], + ]); + $result = $app->auth->session($code); + if ($result) { + if (data_get($result, 'errcode')) { + throw new BizException(data_get($result, 'errmsg')); + } + //缓存微信小程序会话密钥(48小时) + Cache::put($result['openid'], $result['session_key'], 48 * 60 * 60); + + return $this->mapUserToObject([ + 'openid'=>$result['openid'], + // 'unionid'=>$result['unionid'], + ]); + } else { + throw new BizException('解析失败'); + } + } + + protected function getAuthUrl(): string + { + return ''; + } + + protected function getTokenUrl(): string + { + return ''; + } + + protected function getUserByToken(string $token): array + { + return []; + } + + protected function mapUserToObject(array $user): User + { + return new User([ + 'id' => $user['openid'] ?? null, + 'name' => $user['nickname'] ?? null, + 'nickname' => $user['nickname'] ?? null, + 'avatar' => $user['headimgurl'] ?? null, + 'email' => null, + ]); + } +} diff --git a/composer.json b/composer.json index e0a990dd..d831fe6c 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "laravel/sanctum": "^2.12", "laravel/tinker": "^2.5", "overtrue/easy-sms": "^2.0", + "overtrue/socialite": "*", "simplesoftwareio/simple-qrcode": "^4.2", "tucker-eric/eloquentfilter": "^3.0", "w7corp/easywechat": "^5.10" diff --git a/composer.lock b/composer.lock index 0a534fbe..ef988496 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f53d3bea81e27c131da9f29019c886a1", + "content-hash": "5ea856539b7c9cb2093859052437846e", "packages": [ { "name": "adbario/php-dot-notation", diff --git a/config/socialite.php b/config/socialite.php new file mode 100644 index 00000000..7e528f19 --- /dev/null +++ b/config/socialite.php @@ -0,0 +1,12 @@ +[ + 'provider' => WechatMini::class, + 'client_id' => env('WECHAT_MINI_PROGRAM_APPID', ''), + 'client_secret' => env('WECHAT_MINI_PROGRAM_SECRET', ''), + 'redirect' =>'', + ], +]; diff --git a/config/wechat.php b/config/wechat.php index ea02f988..6dd2a912 100644 --- a/config/wechat.php +++ b/config/wechat.php @@ -1,6 +1,54 @@ [ + 'default' => [ + 'app_id' => env('WECHAT_OFFICIAL_ACCOUNT_APPID', 'your-app-id'), // AppID + 'secret' => env('WECHAT_OFFICIAL_ACCOUNT_SECRET', 'your-app-secret'), // AppSecret + 'token' => env('WECHAT_OFFICIAL_ACCOUNT_TOKEN', 'your-token'), // Token + 'aes_key' => env('WECHAT_OFFICIAL_ACCOUNT_AES_KEY', ''), // EncodingAESKey + + /* + * OAuth 配置 + * + * scopes:公众平台(snsapi_userinfo / snsapi_base),开放平台:snsapi_login + * callback:OAuth授权完成后的回调页地址(如果使用中间件,则随便填写。。。) + * enforce_https:是否强制使用 HTTPS 跳转 + */ + 'oauth' => [ + 'scopes' => array_map('trim', explode(',', env('WECHAT_OFFICIAL_ACCOUNT_OAUTH_SCOPES', 'snsapi_userinfo'))), + 'callback' => env('WECHAT_OFFICIAL_ACCOUNT_OAUTH_CALLBACK', '/examples/oauth_callback.php'), + 'enforce_https' => true, + ], + ], + ], + + /* + * 开放平台第三方平台 + */ + // 'open_platform' => [ + // 'default' => [ + // 'app_id' => env('WECHAT_OPEN_PLATFORM_APPID', ''), + // 'secret' => env('WECHAT_OPEN_PLATFORM_SECRET', ''), + // 'token' => env('WECHAT_OPEN_PLATFORM_TOKEN', ''), + // 'aes_key' => env('WECHAT_OPEN_PLATFORM_AES_KEY', ''), + // ], + // ], + + /* + * 小程序 + */ + 'mini_program' => [ + 'default' => [ + 'app_id' => env('WECHAT_MINI_PROGRAM_APPID', ''), + 'secret' => env('WECHAT_MINI_PROGRAM_SECRET', ''), + 'token' => env('WECHAT_MINI_PROGRAM_TOKEN', ''), + 'aes_key' => env('WECHAT_MINI_PROGRAM_AES_KEY', ''), + ], + ], 'payment' => [ 'default' => [ 'sandbox' => env('WECHAT_PAYMENT_SANDBOX', false), diff --git a/database/migrations/2022_02_22_142257_create_socialite_users_table.php b/database/migrations/2022_02_22_142257_create_socialite_users_table.php new file mode 100644 index 00000000..d28a02fc --- /dev/null +++ b/database/migrations/2022_02_22_142257_create_socialite_users_table.php @@ -0,0 +1,37 @@ +id(); + $table->string('socialite_type')->comment('平台'); + $table->string('socialite_id')->comment('三方唯一ID'); + $table->unsignedBigInteger('user_id')->nullable()->comment('绑定的用户ID'); + $table->timestamps(); + + $table->index(['socialite_type', 'socialite_id']); + $table->index('user_id'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('socialite_users'); + } +}