五 【实战】Hyperf 用户身份认证(单点登录)

729 阅读4分钟

一 先说说想法。

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

image.png

看了一下,觉得没必要用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');
  1. 加密函数。(封装起来,后续有加盐类似需求时方便)
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. image.png

还是那个原则,步子要小,单步可测。开始准备测试代码.

这些步骤看着麻烦 ,习惯后很香,一般测过后,你不用太担心你的代码质量。 如同造一个汽车一样,每个零件的质量好点,一般质量就不会太差了。

这里再推荐一个小工具,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 测试效果如图。 image.png

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);

}

image.png

七 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);
 }