發(fā)布于:2021-02-18 00:02:20
0
242
0
從頭開始為您的Web應用程序構建用戶身份驗證系統(tǒng)可能是一項非常棘手的工作。一開始看起來很簡單,但是有很多細節(jié)你必須考慮-正確散列密碼,保護用戶會話,提供一種重置忘記密碼的方法。大多數(shù)現(xiàn)代框架都提供了處理所有這些問題的樣板代碼,但即使您沒有使用框架,也不要絕望。在本文中,我將向您展示如何從頭開始構建一個PHP應用程序(只需依賴一個外部庫DotEnv,這樣我們就可以將機密存儲在代碼庫外部的.env文件中)。該應用程序?qū)⒗肙kta簡單的OAuth2.0API提供用戶登錄/注銷、新用戶注冊和“忘記密碼”表單。
所有你需要遵循的教程是一個Okta開發(fā)人員帳戶(你可以免費創(chuàng)建一個),PHP和Composer。
為什么使用Okta進行身份驗證?
Okta使身份管理比以前更簡單、更安全、更可擴展。Okta是一個API服務,允許您創(chuàng)建、編輯和安全地存儲用戶帳戶和用戶帳戶數(shù)據(jù),并將它們與一個或多個應用程序連接。我們的API使您能夠:
對您的用戶進行身份驗證和授權
存儲有關用戶的數(shù)據(jù)
執(zhí)行基于密碼的社交登錄
使用多因素身份驗證保護您的應用程序
等等!有關更多信息,請查看我們的產(chǎn)品文檔
注冊一個永久免費的開發(fā)人員帳戶,完成后,回來學習如何從頭開始使用用戶身份驗證構建PHP應用程序。
創(chuàng)建一個免費的Okta開發(fā)者帳戶
在繼續(xù)之前,您需要登錄到您的Okta帳戶(或者免費創(chuàng)建一個新帳戶)并創(chuàng)建一個OAuth應用程序。您需要為應用程序獲取客戶機ID和客戶機機密,還需要一個API令牌,以便應用程序可以遠程注冊用戶。
以下是逐步說明:
轉(zhuǎn)到“應用程序”菜單項并單擊“添加應用程序”按鈕:
選擇Web并單擊Next。
輸入標題,然后設置http://localhost:8080/作為基本URI和登錄重定向URI,然后單擊“完成”。您可以保留其余的設置:
從應用程序設置復制客戶端ID和客戶端機密。
轉(zhuǎn)到API>;令牌,然后單擊創(chuàng)建令牌:
為您的令牌輸入一個標題,并確保復制令牌的值并安全地存儲它。你只能看到它一次-如果你丟失了它,你需要創(chuàng)建一個新的令牌。
注意你的主要組織URL,你也需要這個。
創(chuàng)建項目骨架
首先在頂層目錄中創(chuàng)建一個/src目錄和一個簡單的composer.json
文件,其中只有一個依賴項:DotEnv庫,它允許我們將Okta身份驗證詳細信息保存在代碼庫外部的.env
文件中:
composer.json
{
"require": {
"vlucas/phpdotenv": "^2.4"
},
"autoload": {
"psr-4": {
"Src\": "src/"
}
}
}
我們還配置了一個PSR-4自動加載程序,它將自動在/src
目錄中查找PHP類。
我們現(xiàn)在可以安裝依賴項:
composer install
我們有一個/vendor
目錄,并且安裝了DotEnv依賴項(我們也可以使用autoloader從/src
加載類,而不需要調(diào)用include()
)。
讓我們?yōu)槲覀兊捻椖縿?chuàng)建一個.gitignore
文件,其中有兩行,因此/vendor
目錄和本地.env
文件將被忽略:
/vendor
.env
接下來,我們將為Okta身份驗證變量創(chuàng)建一個.env.example
文件:
CLIENT_ID=
CLIENT_SECRET=
REDIRECT_URI=http://localhost:8080/
METADATA_URL=https://{yourOktaDomain}/oauth2/default/.well-known/oauth-authorization-server
API_URL_BASE=https://{yourOktaDomain}/api/v1/
API_TOKEN=
以及一個.env
文件,我們將在其中填寫來自Okta帳戶的實際詳細信息(Git將忽略它,這樣它就不會出現(xiàn)在我們的存儲庫中)。
項目將具有以下目錄結構(您現(xiàn)在可以創(chuàng)建其余文件):
/public/index.php
/src
/controllers
/services
/views
bootstrap.php
.env
.env.example
/public/index.php
文件是我們的簡單前端控制器。它加載bootstrap.php
腳本,然后處理傳入的HTTP請求,將其委托給控制器。以下是初始版本:
/public/index.php
<?php
require('../bootstrap.php');
// view data
$data = null;
view('home', $data);
現(xiàn)在,它只是加載沒有數(shù)據(jù)的“home”視圖。
bootstrap.php
腳本啟動自動加載,初始化我們的依賴項(在本例中只有DotEnv),啟動會話并提供用于加載視圖文件的助手函數(shù)view()
(我們已經(jīng)在/public/index.php
中使用了它)。以下是bootstrap.php
文件的完整版本:
bootstrap.php
<?php
require 'vendor/autoload.php';
use DotenvDotenv;
$dotenv = new DotEnv( __DIR__ );
$dotenv->load();
session_start();
function view($title, $data = null)
{
$filename = __DIR__. '/src/views/' . $title . '.php';
if (file_exists($filename)) {
include($filename);
} else {
throw new Exception('View ' . $title . ' not found!');
}
}
/src/controllers
目錄保存我們的控制器類。/src/services
目錄保存服務層的類。/src/views
目錄保存我們的視圖(我們在這個項目中使用簡單的PHP視圖,沒有模板系統(tǒng))。
讓我們開始構建視圖:
/src/views/home.php
<?php view('header', $data); ?>
<section class="hero">
<div class="hero-body">
<div class="container">
<?php
if (isset($data['thank_you'])) {
?>
<div class="notification is-info">
<?php
echo $data['thank_you'];
?>
</div>
<?php
}
?>
<?php
if (isset($data['loginError'])) {
?>
<div class="notification is-danger">
<?php
echo $data['loginError'];
?>
</div>
<?php
}
?>
<?php
if (isset($_SESSION['username'])) {
?>
<p class="subtitle is-4">
This is some great content for logged in users
<p>
<?php
} else {
?>
<p class="subtitle is-4">
You need to login to access the content!
</p>
<?php
}
?>
</div>
</div>
</section>
<?php view('footer'); ?>
homepage視圖加載頁眉和頁腳,并且能夠顯示通知消息和錯誤消息。它還根據(jù)用戶是否登錄(通過檢查$_SESSION['username']
確定)顯示不同的內(nèi)容。
以下是頁眉和頁腳視圖的完整版本:
/src/views/header.php
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="PHP Login App bd-index-custom-example">
<title>Core PHP + Okta Login Example </title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.2/css/bulma.min.css">
</head>
<body>
<nav id="navbar" class="navbar has-shadow is-spaced">
<div>
<div>
<h1>Core PHP + Okta Login Example</h1>
<?php
if (isset($_SESSION['username'])) {
?>
<p>
Logged in as <?php echo $_SESSION['username'] ?>
</p>
<p><a href="/?logout">Log Out</a></p>
<?php
} else {
?>
<p>Not logged in</p>
<p><a href="/?login">Log In</a> | <a href="/?forgot">Forgot Password</a> | <a href="/?register">Register</a></p>
<?php
}
?>
</div>
</div>
</nav>
/src/views/footer.php
</body>
</html>
頭部加載bulmacss框架,如果有登錄用戶,則顯示用戶名和注銷鏈接;如果沒有登錄用戶,則顯示登錄/忘記密碼/注冊鏈接。
啟動內(nèi)置PHP服務器:
php -S 127.0.0.1:8080 -t public
加載時http://localhost:8080,您應該看到應用程序:
實施Okta登錄/注銷
Okta登錄分為幾個階段:
建立登錄URL
重定向到URL
遠程執(zhí)行Okta身份驗證,然后重定向回我們的重定向URI
處理響應并授權應用程序中的用戶。
我們將public/index.php
修改為處理上面的步驟1和步驟2,并將其添加到view('home');
行的上方:
// build login URL and redirect the user
if (isset($_REQUEST['login']) && (! isset($_SESSION['username']))) {
$_SESSION['state'] = bin2hex(random_bytes(5));
$authorizeUrl = $oktaApi->buildAuthorizeUrl($_SESSION['state']);
header('Location: ' . $authorizeUrl);
die();
}
當它收到重定向回(包括來自Okta的代碼)時,還要處理步驟4:
if (isset($_GET['code'])) {
$result = $oktaApi->authorizeUser();
if (isset($result['error'])) {
$data['loginError'] = $result['errorMessage'];
}
}
我們還將添加一個非常簡單的注銷處理程序,它只是取消設置會話變量username
。
新版本如下:
/public/index.php
<?php
require('../bootstrap.php');
use SrcServicesOktaApiService;
$oktaApi = new OktaApiService;
// view data
$data = null;
// build login URL and redirect the user
if (isset($_REQUEST['login']) && (! isset($_SESSION['username']))) {
$_SESSION['state'] = bin2hex(random_bytes(5));
$authorizeUrl = $oktaApi->buildAuthorizeUrl($_SESSION['state']);
header('Location: ' . $authorizeUrl);
die();
}
// handle the redirect back
if (isset($_GET['code'])) {
$result = $oktaApi->authorizeUser();
if (isset($result['error'])) {
$data['loginError'] = $result['errorMessage'];
}
}
if (isset($_REQUEST['logout'])) {
unset($_SESSION['username']);
header('Location: /');
die();
}
view('home', $data);
我們還要構建OktaApiService
并添加所需的方法(buildAuthorizeUrl()
和authorizeUser()
):
/src/services/OktaApiService.php
<?php
namespace SrcServices;
class OktaApiService
{
private $clientId;
private $clientSecret;
private $redirectUri;
private $metadataUrl;
private $apiToken;
private $apiUrlBase;
public function __construct()
{
$this->clientId = getenv('CLIENT_ID');
$this->clientSecret = getenv('CLIENT_SECRET');
$this->redirectUri = getenv('REDIRECT_URI');
$this->metadataUrl = getenv('METADATA_URL');
$this->apiToken = getenv('API_TOKEN');
$this->apiUrlBase = getenv('API_URL_BASE');
}
public function buildAuthorizeUrl($state)
{
$metadata = $this->httpRequest($this->metadataUrl);
$url = $metadata->authorization_endpoint . '?' . http_build_query([
'response_type' => 'code',
'client_id' => $this->clientId,
'redirect_uri' => $this->redirectUri,
'state' => $state,
]);
return $url;
}
public function authorizeUser()
{
if ($_SESSION['state'] != $_GET['state']) {
$result['error'] = true;
$result['errorMessage'] = 'Authorization server returned an invalid state parameter';
return $result;
}
if (isset($_GET['error'])) {
$result['error'] = true;
$result['errorMessage'] = 'Authorization server returned an error: '.htmlspecialchars($_GET['error']);
return $result;
}
$metadata = $this->httpRequest($this->metadataUrl);
$response = $this->httpRequest($metadata->token_endpoint, [
'grant_type' => 'authorization_code',
'code' => $_GET['code'],
'redirect_uri' => $this->redirectUri,
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret
]);
if (! isset($response->access_token)) {
$result['error'] = true;
$result['errorMessage'] = 'Error fetching access token!';
return $result;
}
$_SESSION['access_token'] = $response->access_token;
$token = $this->httpRequest($metadata->introspection_endpoint, [
'token' => $response->access_token,
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret
]);
if ($token->active == 1) {
$_SESSION['username'] = $token->username;
$result['success'] = true;
return $result;
}
}
private function httpRequest($url, $params = null)
{
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
if ($params) {
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params));
}
return json_decode(curl_exec($ch));
}
}
在OktaApiService
類中發(fā)生了很多事情,讓我來解釋一下這個過程:
在構建授權URL之前,我們生成一個隨機值,用于驗證響應:
$_SESSION['state'] = bin2hex(random_bytes(5));
$authorizeUrl = $oktaApi->buildAuthorizeUrl($_SESSION['state']);
方法buildAuthorizeUrl()
使用對元數(shù)據(jù)URL的調(diào)用來獲取服務器的授權端點,然后為該端點構建查詢:
$metadata = $this->httpRequest($this->metadataUrl);
$url = $metadata->authorization_endpoint . '?' . http_build_query([
'response_type' => 'code',
'client_id' => $this->clientId,
'redirect_uri' => $this->redirectUri,
'state' => $state,
]);
當我們得到重定向回,我們收到的狀態(tài)變量,我們發(fā)送的授權重定向和代碼從Okta。當我們得到一個代碼時,我們調(diào)用authorizeUser()
方法,首先驗證狀態(tài)值是否匹配并且響應中沒有錯誤代碼:
if ($_SESSION['state'] != $_GET['state']) {
$result['error'] = true;
$result['errorMessage'] = 'Authorization server returned an invalid state parameter';
return $result;
}
if (isset($_GET['error'])) {
$result['error'] = true;
$result['errorMessage'] = 'Authorization server returned an error: '.htmlspecialchars($_GET['error']);
return $result;
}
然后使用token_endpoint
(來自元數(shù)據(jù)調(diào)用)將代碼交換為訪問令牌:
$metadata = $this->httpRequest($this->metadataUrl);
$response = $this->httpRequest($metadata->token_endpoint, [
'grant_type' => 'authorization_code',
'code' => $_GET['code'],
'redirect_uri' => $this->redirectUri,
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret
]);
if (! isset($response->access_token)) {
$result['error'] = true;
$result['errorMessage'] = 'Error fetching access token!';
return $result;
}
$_SESSION['access_token'] = $response->access_token;
之后,我們使用內(nèi)省端點來確認令牌有效且處于活動狀態(tài),并獲取新授權用戶的用戶名:
$token = $this->httpRequest($metadata->introspection_endpoint, [
'token' => $response->access_token,
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret
]);
if ($token->active == 1) {
$_SESSION['username'] = $token->username;
$result['success'] = true;
return $result;
}
通過Okta API注冊新用戶
新用戶注冊將在UserController
類中處理。前面的控制器需要三個處理器:
public/index.php
...
use SrcControllersUserController;
...
$userController = new UserController($oktaApi);
...
if (isset($_REQUEST['register'])) {
view('register');
die();
}
if (isset($_REQUEST['command']) && ($_REQUEST['command'] == 'register')) {
$userController->handleRegistrationPost();
die();
}
if (isset($_REQUEST['thankyou'])) {
$data['thank_you'] = 'Thank you for your registration!';
}
...
單擊寄存器鏈接時,第一個處理程序只加載register
視圖:
/src/views/register.php
<?php view('header', $data); ?>
<section class="hero">
<div class="hero-body">
<div class="container">
<form method="post" action="/">
<?php
if ($data && $data['errors']) {
?>
<div class="notification is-danger">
<?php
echo "Errors:";
echo $data['errorMessage'];
?>
</div>
<?php
}
?>
<div class="field">
<label class="label">First Name</label>
<div class="control">
<input class="input" name="first_name" type="text" value="<?php if ($data) { echo $data['input']['first_name']; } ?>">
</div>
</div>
<div class="field">
<label class="label">Last Name</label>
<div class="control">
<input class="input" name="last_name" type="text" value="<?php if ($data) { echo $data['input']['last_name']; } ?>">
</div>
</div>
<div class="field">
<label class="label">Email</label>
<div class="control">
<input class="input" name="email" type="email" value="<?php if ($data) { echo $data['input']['email']; } ?>">
</div>
</div>
<div class="field">
<label class="label">Password</label>
<div class="control">
<input class="input" name="password" type="password" value="">
</div>
</div>
<div class="field">
<label class="label">Repeat Password</label>
<div class="control">
<input class="input" name="repeat_password" type="password" value="">
</div>
</div>
<input type="hidden" name="command" value="register">
<div class="control">
<button class="button is-link">Register</button>
<a class="button is-link" href="/">Cancel</a>
</div>
</form>
</div>
</div>
</section>
<?php view('footer'); ?>
提交表單時,第二個處理程序?qū)⑽薪o用戶控制器:
/src/controllers/UserController.php
<?php
namespace SrcControllers;
use SrcServicesOktaApiService;
class UserController
{
private $errors = null;
private $errorMessage = null;
public function __construct(OktaApiService $oktaApi)
{
$this->oktaApi = $oktaApi;
}
public function handleRegistrationPost()
{
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$input = [
'first_name' => $_POST['first_name'],
'last_name' => $_POST['last_name'],
'email' => $_POST['email'],
'password' => $_POST['password'],
'repeat_password' => $_POST['repeat_password'],
];
// local form validation
$this->validateRegistrationForm($input);
if ($this->errors) {
$viewData = [
'input' => $input,
'errors' => $this->errors,
'errorMessage' => $this->errorMessage
];
view('register', $viewData);
return true;
}
// if local validation passes, attempt to register the user
// via the Okta API
$result = $this->oktaApi->registerUser($input);
$result = json_decode($result, true);
if (isset($result['errorCode'])) {
$viewData = [
'input' => $input,
'errors' => true,
'errorMessage' => '<br>(Okta) ' . $result['errorCauses'][0]['errorSummary']
];
view('register', $viewData);
return true;
}
header('Location: /?thankyou');
return true;
}
header('HTTP/1.0 405 Method Not Allowed');
die();
}
private function validateRegistrationForm($input)
{
$errorMessage = '';
$errors = false;
// validate field lengths
if (strlen($input['first_name']) > 50) {
$errorMessage .= "<br>'First Name' is too long (50 characters max)!";
$errors = true;
}
if (strlen($input['last_name']) > 50) {
$errorMessage .= "<br>'Last Name' is too long (50 characters max)!";
$errors = true;
}
if (strlen($input['email']) > 100) {
$errorMessage .= "<br>'Email' is too long (100 characters max)!";
$errors = true;
}
if (strlen($input['password']) > 72) {
$errorMessage .= "<br>'Password' is too long (72 characters max)!";
$errors = true;
}
if (strlen($input['password']) < 8) {
$errorMessage .= "<br>'Password' is too short (8 characters min)!";
$errors = true;
}
// validate field contents
if (empty($input['first_name'])) {
$errorMessage .= "<br>'First Name' is required!";
$errors = true;
}
if (empty($input['last_name'])) {
$errorMessage .= "<br>'Last Name' is required!";
$errors = true;
}
if (empty($input['email'])) {
$errorMessage .= "<br>'Email' is required!";
$errors = true;
} else if (! filter_var($input['email'], FILTER_VALIDATE_EMAIL)) {
$errorMessage .= "<br>Invalid email!";
$errors = true;
}
if (empty($input['password'])) {
$errorMessage .= "<br>'Password' is required!";
$errors = true;
}
if (empty($input['repeat_password'])) {
$errorMessage .= "<br>'Repeat Password' is required!";
$errors = true;
}
if ($input['password'] !== $input['repeat_password']) {
$errorMessage .= "<br>Passwords do not match!";
$errors = true;
}
$this->errors = $errors;
$this->errorMessage = $errorMessage;
}
}
我們還需要將新方法registerUser()
添加到OktaApiService
類:
/src/services/OktaApiService.php
...
public function registerUser($input)
{
$data['profile'] = [
'firstName' => $input['first_name'],
'lastName' => $input['last_name'],
'email' => $input['email'],
'login' => $input['email']
];
$data['credentials'] = [
'password' => [
'value' => $input['password']
]
];
$data = json_encode($data);
$ch = curl_init($this->apiUrlBase . 'users');
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST");
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Accept: application/json',
'Content-Type: application/json',
'Content-Length: ' . strlen($data),
'Authorization: SSWS ' . $this->apiToken
]);
return curl_exec($ch);
}
...
注冊成功后,第三個處理程序只在儀表板上顯示一條消息“感謝您的注冊”。
表單如下所示,代碼包括驗證和錯誤處理(屏幕截圖顯示提交空表單后的輸出):
如果轉(zhuǎn)到用戶>人員,您可以在Okta管理面板中看到新用戶。
通過Okta API忘記密碼
“忘記密碼”功能將遵循相同的模式:
/public/index.php
中的新處理程序:
...
if (isset($_REQUEST['forgot'])) {
view('forgot');
die();
}
if (isset($_REQUEST['command']) && ($_REQUEST['command'] == 'forgot_password')) {
$userController->handleForgotPasswordPost();
die();
}
if (isset($_REQUEST['password_reset'])) {
$data['thank_you'] = 'You should receive an email with password reset instructions';
}
...
單擊忘記密碼鏈接時,第一個處理程序加載forgot
視圖:
/src/views/forgot.php
<?php view('header', $data); ?>
<section class="hero">
<div class="hero-body">
<div class="container">
<form method="post" action="/">
<?php
if ($data && $data['errors']) {
?>
<div class="notification is-danger">
<?php
echo "Errors:";
echo $data['errorMessage'];
?>
</div>
<?php
}
?>
<div class="field">
<label class="label">Email</label>
<div class="control">
<input class="input" name="email" type="email" value="<?php if ($data) { echo $data['input']['email']; } ?>">
</div>
</div>
<input type="hidden" name="command" value="forgot_password">
<div class="control">
<button class="button is-link">Reset Password</button>
<a class="button is-link" href="/">Cancel</a>
</div>
</form>
</div>
</div>
</section>
<?php view('footer'); ?>
提交表單時,第二個處理程序?qū)⑽薪o用戶控制器:
/src/controllers/UserController.php
... public function handleForgotPasswordPost() { if ($_SERVER['REQUEST_METHOD'] === 'POST') { $input = [ 'email' => $_POST['email'] ]; // validate the email address if (empty($input['email']) || strlen($input['email']) > 100 || (! filter_var($input['email'], FILTER_VALIDATE_EMAIL))) { $viewData = [ 'input' => $input, 'errors' => true, 'errorMessage' => '
Invalid email!' ]; view('forgot', $viewData); return true; } // search for this user via the OktaApi $result = $this->oktaApi->findUser($input); $result = json_decode($result, true); if (! isset($result[0]['id'])) { $viewData = [ 'input' => $input, 'errors' => true, 'errorMessage' => '
User not found!' ]; view('forgot', $viewData); return true; } // attempt to send a reset link to this user $userId = $result[0]['id']; $result = $this->oktaApi->resetPassword($userId); header('Location: /?password_reset'); return true; } header('HTTP/1.0 405 Method Not Allowed'); die(); } ...
控制器使用來自OktaApiService
的兩種新方法:findUser()
和resetPassword()
:
/src/services/OktaApiService.php
...
public function findUser($input)
{
$url = $this->apiUrlBase . 'users?q=' . urlencode($input['email']) . '&limit=1';
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Accept: application/json',
'Content-Type: application/json',
'Authorization: SSWS ' . $this->apiToken
]);
return curl_exec($ch);
}
public function resetPassword($userId)
{
$url = $this->apiUrlBase . 'users/' . $userId . '/lifecycle/reset_password';
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST");
curl_setopt($ch, CURLOPT_POSTFIELDS, []);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Accept: application/json',
'Content-Type: application/json',
'Authorization: SSWS ' . $this->apiToken
]);
return curl_exec($ch);
}
...
第三個處理程序在觸發(fā)重置過程后在儀表板上顯示一條消息。
申請已經(jīng)完成了。您可以在Okta授權服務器中注冊新用戶,在Web應用程序中對其進行授權,并遠程觸發(fā)“重置密碼”例程。
像往常一樣,如果你有任何問題,評論,或?qū)@篇文章的關注,請隨時在下面留下評論。