一 先说说想法。
1 用jwt 来存用户信息. 登录后,生成jwt token ,然后 redis->set('uid:pc:'+ uid, token)
2 得到token 后,解出数据, 然后取 redis->get('uid:pc:'+ uid ) 得到对应的值。 和jwt 一致,则代表登录成功。
二 找资源。
github.com/hyperf-ext/… 进一步发现,github.com/hyperf-ext 里面有不少好东西。 于是计划用 hyperf-ext/auth 来做 auth
看了一下,觉得没必要用auth ,还得知道中间件啥的, 等以后hyperf 熟了后再折腾, 用个jwt 来生成token 即可。
三 安装 jwt
果然坑多,
composer require hyperf-ext/jwt
Problem 1
- hyperf-ext/jwt[v2.0.0, ..., v2.0.1] require hyperf/cache ^2.0 -> found hyperf/cache[v2.0.0, ..., 2.2.x-dev] but it conflicts with your root compose
r.json require (~3.0.0).
- hyperf-ext/jwt[v2.0.2, ..., v2.0.3] require hyperf/cache ~2.0.0 -> found hyperf/cache[v2.0.0, ..., 2.0.x-dev] but it conflicts with your root compo
ser.json require (~3.0.0).
- hyperf-ext/jwt[v2.1.0, ..., v2.1.3] require hyperf/cache ~2.1.0 -> found hyperf/cache[v2.1.0-beta1, v2.1.0, v2.1.8, 2.1.x-dev] but it conflicts wit
h your root composer.json require (~3.0.0).
- hyperf-ext/jwt v2.2.0 requires hyperf/cache ~2.2.0 -> found hyperf/cache[v2.2.0-beta1, ..., 2.2.x-dev] but it conflicts with your root composer.jso
n require (~3.0.0).
- hyperf-ext/jwt[dev-master, v2.2.1] require hyperf/cache ^2.1 -> found hyperf/cache[v2.1.0-beta1, ..., 2.2.x-dev] but it conflicts with your root co
mposer.json require (~3.0.0).
- Root composer.json requires hyperf-ext/jwt * -> satisfiable by hyperf-ext/jwt[dev-master, v2.0.0, ..., v2.2.1, 9999999-dev].
Use the option --with-all-dependencies (-W) to allow upgrades, downgrades and removals for packages currently locked to specific versions.
You can also try re-running composer require with an explicit version constraint, e.g. "composer require hyperf-ext/jwt:*" to figure out if any version i
s installable, or "composer require hyperf-ext/jwt:^2.1" if you know which you need.
github 上单独找了一个。 (7k stars ,586 forks) github.com/lcobucci/jw…
文档翻了一下,还算好用。(就我个人而言,更喜欢简单的 encode ,decode 函数。) lcobucci-jwt.readthedocs.io/en/latest/q…
安装一下。
composer require lcobucci/jwt
Info from https://repo.packagist.org: #StandWithUkraine
Cannot use lcobucci/jwt's latest version 5.0.0 as it requires php ~8.1.0 || ~8.2.0 which is not satisfied by your platform.
./composer.json has been updated
Running composer update lcobucci/jwt
Loading composer repositories with package information
Updating dependencies
Lock file operations: 4 installs, 0 updates, 0 removals
- Locking lcobucci/clock (2.2.0)
- Locking lcobucci/jwt (4.3.0)
- Locking psr/clock (1.0.0)
- Locking stella-maris/clock (0.1.7)
Writing lock file
Installing dependencies from lock file (including require-dev)
Package operations: 4 installs, 0 updates, 0 removals
- Downloading psr/clock (1.0.0)
- Downloading stella-maris/clock (0.1.7)
- Downloading lcobucci/clock (2.2.0)
- Downloading lcobucci/jwt (4.3.0)
- Installing psr/clock (1.0.0): Extracting archive
- Installing stella-maris/clock (0.1.7): Extracting archive
- Installing lcobucci/clock (2.2.0): Extracting archive
- Installing lcobucci/jwt (4.3.0): Extracting archive
Generating optimized autoload files
> rm -rf runtime/container
71 packages you are using are looking for funding.
Use the `composer fund` command to find out more!
No security vulnerability advisories found
Using version ^4.3 for lcobucci/jwt
用法
<?php
declare(strict_types=1);
use Lcobucci\JWT\Encoding\ChainedFormatter;
use Lcobucci\JWT\Encoding\JoseEncoder;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Token\Builder;
require 'vendor/autoload.php';
$tokenBuilder = (new Builder(new JoseEncoder(), ChainedFormatter::default()));
$algorithm = new Sha256();
$signingKey = InMemory::plainText(random_bytes(32));
$now = new DateTimeImmutable();
$token = $tokenBuilder
// Configures the issuer (iss claim)
->issuedBy('http://example.com')
// Configures the audience (aud claim)
->permittedFor('http://example.org')
// Configures the id (jti claim)
->identifiedBy('4f1g23a12aa')
// Configures the time that the token was issue (iat claim)
->issuedAt($now)
// Configures the time that the token can be used (nbf claim)
->canOnlyBeUsedAfter($now->modify('+1 minute'))
// Configures the expiration time of the token (exp claim)
->expiresAt($now->modify('+1 hour'))
// Configures a new claim, called "uid"
->withClaim('uid', 1)
// Configures a new header, called "foo"
->withHeader('foo', 'bar')
// Builds a new token
->getToken($algorithm, $signingKey);
echo $token->toString();
declare(strict_types=1);
use Lcobucci\JWT\Encoding\ChainedFormatter;
use Lcobucci\JWT\Encoding\JoseEncoder;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Token\Builder;
require 'vendor/autoload.php';
$tokenBuilder = (new Builder(new JoseEncoder(), ChainedFormatter::default()));
$algorithm = new Sha256();
$signingKey = InMemory::plainText(random_bytes(32));
$token = $tokenBuilder
->issuedBy('http://example.com')
->withClaim('uid', 1)
->withHeader('foo', 'bar')
->getToken($algorithm, $signingKey);
$token->headers(); // Retrieves the token headers
$token->claims(); // Retrieves the token claims
echo $token->headers()->get('foo'), PHP_EOL; // will print "bar"
echo $token->claims()->get('iss'), PHP_EOL; // will print "http://example.com"
echo $token->claims()->get('uid'), PHP_EOL; // will print "1"
echo $token->toString(), PHP_EOL; // The string representation of the object is a JWT string
四 先写一个jwt 的例子。
4.1 准备好jwt 的key . 准备放到配置里。
.env 文件
# jwt 相关配置
JWT_KEY=abc123456
config.php
'jwt_key' => env('JWT_KEY', 'abc123456')
4.2 配个route 单测jwt .
(或许有更好的办法实现单测,现在hyperf 还不熟悉,先直接设个route 用postman 来测) routes.php 设置
Router::get('/test/jwt', 'App\Controller\TestController::jwt');
TestController
#[Value("jwt_key")]
private $jwt_key;
public function jwt()
{
return ['jwt_key' => $this->jwt_key];
}
测试 curl 127.0.0.1:9501/test/jwt {"jwt_key":"abc123456"}bash-5.1#
测试生成一个token .
#[Value("jwt_key")]
private $jwt_key;
public function jwt()
{
$tokenBuilder = (new Builder(new JoseEncoder(), ChainedFormatter::default()));
$algorithm = new Sha256();
$signingKey = InMemory::plainText($this->jwt_key);
$now = new DateTimeImmutable();
$token = $tokenBuilder
// Configures the time that the token was issue (iat claim)
->issuedAt($now)
// Configures the expiration time of the token (exp claim)
->expiresAt($now->modify('+1 hour'))
// Configures a new claim, called "uid"
->withClaim('uid', 1)
// Builds a new token
->getToken($algorithm, $signingKey);
return ['jwt_key' => $this->jwt_key, 'token' => $token->toString()];
}
期间各种小报错,顺着排。
key 必须很长。
[ERROR] Key provided is shorter than 256 bits, only 72 bits provided[39] in /data/project/api.xuxing.tech/vendor/lcobucci/jwt/src/Signer/InvalidKeyProvided.php
#成功
curl 127.0.0.1:9501/test/jwt
{"jwt_key":"abc123456abc123456abc123456abc123456","token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2ODc3NzQyMzIuNTc4NTc3LCJleHAiOjE2ODc3Nzc4MzIuN
Tc4NTc3LCJ1aWQiOjF9.tgMVBFWNOBD54_VFTKTxeGwM301-DybYaYFincf-_fc"}
再来一个根据 token 解数据的例子。
暂停一下要接娃了 。
回来接着处理。
parse
use Lcobucci\JWT\Token\Parser;
...
$token2 = $parser->parse($tokenStr );
return ['jwt_key' => $this->jwt_key, 'token' => $token->toString()
, 'uid' => $token2->claims()->get('uid'),
'sex' => $token2->claims()->get('sex'),
'xxx' => $token2->claims()->get('xxx'),
];
五 开始设计接口.
5.1 登录
api /api/login
method:post
Content-Type application/json
# request
{
'mobile' : "13866668888",
'pwd' : "123456",
}
# response
{
'code' :0,
'msg' : 'success',
'token' : 'xxx',
'expire_at':12346 //过期时间戳
}
5.2 登出
api /api/logout?token=xxx
method:get
{
'code' :0,
'msg' : 'success'
}
5.3 authenService
//实现方法1 。
//生成密码。
encrpt($pwd);
genLoginToken($user); //生成token
isLogin($token);
getUinfo($token); //通过 token 得到用户数据
5.4 userService
getUserByMobile (通过手机号得到用户数据)
5.5 loginController
login(){
$user = $userService->getUserByMobile($mobile);
if($user['pwd'] == $authenService->encrpt($pwd)) {
$token = $authenService->genLoginToken($user);
$redisKey = "uid:pc:1";
$redis->setex(redisKey, $ttl, $token);
}
return ;
}
logout(){
list($code, $uinfo) = $authenService->getUinfo($token);
$redisKey = "uid:pc:1";
$redis->del($redisKey);
return ;
}
六 coding Service 代码
6.1 更改 ErrorCode
pp\Constants\ErrorCode.php
6.2 AuthenService
测试先行。看了一下测试相关的文档, hyperf.wiki/3.0/#/zh-cn… 启动成本过高,影响今天的结果输出。今天先简单的用postman 或curl 直接测。 后续再用phpunit route
Router::get('/test/authen', 'App\Controller\TestController::authen');
- 加密函数。(封装起来,后续有加盐类似需求时方便)
AuthenService
<?php
namespace App\Service;
use App\Constants\Enum;
use App\Constants\ErrorCode;
use Hyperf\DbConnection\Db;
use Hyperf\Di\Annotation\Inject;
class AuthenService
{
#[Inject]
protected DbHelper $dbHelper;
public function encrypt($pwd, $salt = "") {
return md5($pwd.$salt);
}
}
用postman 测了一下ok.
还是那个原则,步子要小,单步可测。开始准备测试代码.
这些步骤看着麻烦 ,习惯后很香,一般测过后,你不用太担心你的代码质量。 如同造一个汽车一样,每个零件的质量好点,一般质量就不会太差了。
这里再推荐一个小工具,Snipaste ,把要做的东西,贴图贴在桌面上。 减少记忆负担 。
大约coding 30分钟后,AuthenService 功能单测完成 AuthenService.php
<?php
namespace App\Service;
use App\Constants\Enum;
use Hyperf\Config\Annotation\Value;
use Hyperf\Di\Annotation\Inject;
use Lcobucci\JWT\Encoding\ChainedFormatter;
use Lcobucci\JWT\Encoding\JoseEncoder;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Token\Builder;
use DateTimeImmutable;
use Lcobucci\JWT\Token\Parser;
use Psr\Container\ContainerInterface;
use Lcobucci\JWT\Validation\Constraint;
class AuthenService
{
#[Inject]
protected ContainerInterface $container;
#[Inject]
protected DbHelper $dbHelper;
#[Value("jwt_key")]
private $jwt_key;
#[Value("login_ttl")]
private $login_ttl;
public function encrypt($pwd, $salt = "")
{
return md5($pwd . $salt);
}
public function genLoginToken($userDta): string
{
$tokenBuilder = (new Builder(new JoseEncoder(), ChainedFormatter::default()));
$algorithm = new Sha256();
$signingKey = InMemory::plainText($this->jwt_key);
$now = new DateTimeImmutable();
$token = $tokenBuilder
// Configures the time that the token was issue (iat claim)
->issuedAt($now)
// Configures the expiration time of the token (exp claim)
->expiresAt($now->modify('+2 hour'))
// Configures a new claim, called "uid"
->withClaim('id', $userDta['id'])
->withClaim('tname', $userDta['tname'])
->withClaim('mobile', $userDta['mobile'])
->withClaim('cur_org_id', $userDta['cur_org_id'])
->withClaim('cur_staff_id', $userDta['cur_staff_id'])
// Builds a new token
->getToken($algorithm, $signingKey);
return $token->toString();
}
public function syncRedis($userData, $token, $terminal = Enum::LOGIN_TERMINAL_PC): void
{
$ttl = intval($this->login_ttl);
$redis = $this->container->get(\Redis::class);
$redisKey = $this->getRedisLoginKey($userData['id'], $terminal);
$val = md5($token);
$redis->setex($redisKey, $ttl, $val);
}
/**
* 判断用户是否登录
* @param $tokenStr
* @param string $terminal
* @return bool
* @throws \Psr\Container\ContainerExceptionInterface
* @throws \Psr\Container\NotFoundExceptionInterface
*/
public function isLogin($tokenStr, $terminal = Enum::LOGIN_TERMINAL_PC)
{
$parser = new Parser(new JoseEncoder());
$key = InMemory::base64Encoded($this->jwt_key,);
$token = $parser->parse($tokenStr, new Constraint\SignedWith(new Sha256(), $key));
$uid = $token->claims()->get('id');
if (!$uid || $uid == null) {
return false;
}
$redisKey = $this->getRedisLoginKey($uid, $terminal);
$redis = $this->container->get(\Redis::class);
return md5($tokenStr) == $redis->get($redisKey);
}
public function getUinfo($tokenStr, $terminal = Enum::LOGIN_TERMINAL_PC)
{
if(!$this->isLogin($tokenStr, $terminal)) {
return null;
}
$parser = new Parser(new JoseEncoder());
$key = InMemory::base64Encoded($this->jwt_key);
$token = $parser->parse($tokenStr, new Constraint\SignedWith(new Sha256(), $key));
$data = $token->claims()->all();
unset($data['iat']);
unset($data['exp']);
return $data;
}
/**
* 得到登录用户的rediskey
* @param $uid
* @param string $terminal
* @return string
*/
public function getRedisLoginKey($uid, $terminal = Enum::LOGIN_TERMINAL_PC): string
{
return sprintf("uid:%s:%d", $terminal, $uid);
}
}
测试代码
/**
* 测试 authenService
* @return array[]|int[]
*/
public function authen()
{
$salt = "xxx";
$pwd = "123456";
$expect_pwd = md5("123456".$salt);
$actual_pwd = $this->authenService->encrypt($pwd, $salt);
$testData = [
"test_encrypt" => [
"expect_pwd" => $expect_pwd,
"actual_pwd" => $actual_pwd,
"test_result" => $expect_pwd == $actual_pwd
]
];
$testMobile = "13866668888";
$userData = Db::table('user')->where(
[
['mobile', '=', $testMobile],
['status', '=', Enum::USER_STATUS_OK],
]
)->first();
$token = $this->authenService->genLoginToken($userData);
//测试将token 同步至redis (单点登录用)
$testData['test_syncredis'] = [
"userData" => $userData,
"token" => $token,
"test_result" => $token != ""
];
$this->authenService->syncRedis($userData, $token);
$redis = $this->container->get(\Redis::class);
$redisLoginKey = $this->authenService->getRedisLoginKey($userData['id']);
$testData['test_syncredis'] = [
"redis_key" => $redisLoginKey,
"expect_redisval" => md5($token),
"actual_redisval" => $redis->get($redisLoginKey),
"login_ttl" => $redis->ttl($redisLoginKey),
"test_result" => md5($token) == $redis->get($redisLoginKey)
];
$testData['test_isLogin'] = [
"token" => $token,
"redis_key" => $redisLoginKey,
"redis_val" => $redis->get($redisLoginKey),
"is_login" => $this->authenService->isLogin($token)
];
$testData['test_getUinfo'] = [
"token" => $token,
"uinfo" => $this->authenService->getUinfo($token,Enum::LOGIN_TERMINAL_PC)
];
return array_merge(["code" =>ErrorCode::SUCCESS], $testData);
}
postman 测试效果如图。
6.3 userService
有了authenService 的经验后,userService 轻车熟路。
userService.php
<?php
namespace App\Service;
use App\Constants\Enum;
use Hyperf\DbConnection\Db;
use Hyperf\Di\Annotation\Inject;
class UserService
{
#[Inject]
protected DbHelper $dbHelper;
//todo ,可以考虑缓存依赖。
public function getUserByMobile($mobile)
{
$user = Db::table('user')->where(
[
['mobile', '=', $mobile],
['status', '=', Enum::USER_STATUS_OK],
]
)->first();
return $user;
}
}
TestController
/**
* 测试 authenService
* @return array[]|int[]
*/
public function userService()
{
$testMobile = "13866668888";
$testData = [
"test_getUserByMobile" => [
"mobile" => $testMobile,
"userData" => $this->userService->getUserByMobile($testMobile),
"test_result" => $this->userService->getUserByMobile($testMobile) != null
]
];
return array_merge(["code" =>ErrorCode::SUCCESS], $testData);
}
七 coding controller 代码 。
某框架的最佳实践曾说过,controller 就是个搭积木的位置 ,把请求和业务串起来即可。
快速的实现一下。
<?php
declare(strict_types=1);
/**
*
* @Author xuxing
* @description 机构申请接口. (apply_auto_audit 打开后,自动生效)
*/
namespace App\Controller;
use App\Constants\Enum;
use App\Constants\ErrorCode;
use App\Service\UserService;
use Hyperf\Di\Annotation\Inject;
class AuthenController extends AbstractController
{
#[Inject]
protected UserService $userService;
public function login()
{
$postData = $this->request->all();
$code = $this->validCc($postData);
if($code) {
return $this->code($code);
}
if(!isset($postData['mobile']) || !isset($postData['pwd'])) {
return $this->code(ErrorCode::LOGIN_LOCK_PARAMS);
}
//$mobile = $po
$mobile = $postData['mobile'];
$pwd = $postData['pwd'];
$user = $this->userService->getUserByMobile($mobile);
if($user == null) {
return $this->code(ErrorCode::LOGIN_USER_OR_PWD_ERROR);
}
if($this->authenService->encrypt($pwd) != $user['pwd']) {
return $this->code(ErrorCode::LOGIN_USER_OR_PWD_ERROR);
}
$token = $this->authenService->genLoginToken($user, Enum::LOGIN_TERMINAL_PC);
$this->authenService->syncRedis($user, $token, Enum::LOGIN_TERMINAL_PC);
return ['code' => ErrorCode::SUCCESS, "msg" => 'login success', "token" => $token];
}
public function logout()
{
$token = $this->request->query("token");
$code = $this->validLogin($token);
if($code) {
return $this->code($code);
}
$uinfo =$this->authenService->getUinfo($token, Enum::LOGIN_TERMINAL_PC);
if(!isset($uinfo['id'])) {
return $this->code(ErrorCode::LOGOUT_NO_LOGIN);
}
$this->authenService->logoutByUid($uinfo["id"]);
return $this->code(ErrorCode::SUCCESS);
}
}
通用的登录验证,将代码提取至abstractController
//注意,单一职责,这里只返回code ,如何处理,交具体的controller
public function validLogin($token)
{
$isLogin = $this->authenService->isLogin($token, Enum::LOGIN_TERMINAL_PC);
if(!$isLogin) {
return ErrorCode::NEED_LOGIN;
}
return ErrorCode::SUCCESS;
}
$code = $this->validLogin($token);
if($code) {
return $this->code($code);
}
总结:
今天实现了: 1 走通jwt 库代码 。 2 实现登录,退出登录两个接口。 3 用 validLogin($token) 实现单点登录。
Router::post('/v1/login', 'App\Controller\AuthenController::login');
Router::get('/v1/logout', 'App\Controller\AuthenController::logout');
验证登录用法
$code = $this->validLogin($token);
if($code) {
return $this->code($code);
}