Skip to content

Laravel框架Encrypter接口之Java实现

约 1886 字大约 6 分钟

PHPLaravelEncrypter

2022-02-10

前言

去年开始负责一个项目,使用用 PHP 语言,Laravel 框架开发,其中登录认证使用了 Laravel 内置的 Encrypter 接口中的 encryptdecrypt 方法,用 Java 重构就一定绕不开这两个关键方法。

1. PHP 运行环境配置

为了可以运行 PHP 代码验证,我们先安装 PHP 运行环境。

  1. 进如 PHP 官网下载 PHP 7.4.27 版本,有线程不安全和线程安全两种版本,只是简单跑一下 PHP 代码用哪种版本都行,这里出于习惯性下载线程安全版,点击 zip[24.96MB] 下载得到一个压缩包,选择合适的地方解压,将 PHP 目录配置到环境变量。
  2. 找到 php.ini-development 文件,拷贝一份并重命名为 php.ini 然后打开编辑。
    • 搜索 date.timezone 删掉前面的分号 ;,并将值设置为 Asia/Shanghai
    • 搜索 extension_dirextension=mysqliextension=opensslextension=pdo_mysql 删掉前面的分号 ;
  3. 打开 cmd 窗口输入 php -v,可以看到 PHP 版本即安装成功。

2. 创建 Java 工程

创建 maven 工程并引入如下依赖:

<dependencies>
   <dependency>
      <groupId>cn.hutool</groupId>
      <artifactId>hutool-all</artifactId>
      <version>5.7.20</version>
   </dependency>
   <!-- php 对象序列化与反序列化 -->
   <dependency>
      <groupId>org.sction</groupId>
      <artifactId>phprpc</artifactId>
      <version>3.0.2</version>
   </dependency>
   <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <version>1.18.20</version>
   </dependency>
   <dependency>
      <groupId>com.alibaba</groupId>
      <artifactId>fastjson</artifactId>
      <version>1.2.78</version>
   </dependency>
</dependencies>

我们先看一下 Encrypter.php 文件:

<?php

namespace Contracts\Encryption;

interface Encrypter
{
    /**
     * Encrypt the given value.
     *
     * @param string $value
     * @param bool $serialize
     * @return string
     */
    public function encrypt($value, $serialize = true);

    /**
     * Decrypt the given value.
     *
     * @param string $payload
     * @param bool $unserialize
     * @return string
     */
    public function decrypt($payload, $unserialize = true);
}

再看看实现类:

<?php

namespace Encryption;

use Contracts\Encryption\DecryptException;
use Contracts\Encryption\Encrypter as EncrypterContract;
use Contracts\Encryption\EncryptException;
use RuntimeException;

class Encrypter implements EncrypterContract
{
    /**
     * The encryption key.
     *
     * @var string
     */
    protected $key;

    /**
     * The algorithm used for encryption.
     *
     * @var string
     */
    protected $cipher;

    /**
     * Create a new encrypter instance.
     *
     * @param string $key
     * @param string $cipher
     * @return void
     *
     * @throws \RuntimeException
     */
    public function __construct($key, $cipher = 'AES-128-CBC')
    {
        $key = (string)$key;

        if (static::supported($key, $cipher)) {
            $this->key = $key;
            $this->cipher = $cipher;
        } else {
            throw new RuntimeException('The only supported ciphers are AES-128-CBC and AES-256-CBC with the correct key lengths.');
        }
    }

    /**
     * Determine if the given key and cipher combination is valid.
     *
     * @param string $key
     * @param string $cipher
     * @return bool
     */
    public static function supported($key, $cipher)
    {
        $length = mb_strlen($key, '8bit');

        return ($cipher === 'AES-128-CBC' && $length === 16) ||
            ($cipher === 'AES-256-CBC' && $length === 32);
    }

    /**
     * Create a new encryption key for the given cipher.
     *
     * @param string $cipher
     * @return string
     */
    public static function generateKey($cipher)
    {
        return random_bytes($cipher == 'AES-128-CBC' ? 16 : 32);
    }

    /**
     * Encrypt the given value.
     *
     * @param mixed $value
     * @param bool $serialize
     * @return string
     *
     * @throws \Illuminate\Contracts\Encryption\EncryptException
     */
    public function encrypt($value, $serialize = true)
    {
        $iv = random_bytes(openssl_cipher_iv_length($this->cipher));

        // First we will encrypt the value using OpenSSL. After this is encrypted we
        // will proceed to calculating a MAC for the encrypted value so that this
        // value can be verified later as not having been changed by the users.
        $value = \openssl_encrypt(
            $serialize ? serialize($value) : $value,
            $this->cipher, $this->key, 0, $iv
        );

        if ($value === false) {
            throw new EncryptException('Could not encrypt the data.');
        }

        // Once we get the encrypted value we'll go ahead and base64_encode the input
        // vector and create the MAC for the encrypted value so we can then verify
        // its authenticity. Then, we'll JSON the data into the "payload" array.
        $mac = $this->hash($iv = base64_encode($iv), $value);

        $json = json_encode(compact('iv', 'value', 'mac'));

        if (json_last_error() !== JSON_ERROR_NONE) {
            throw new EncryptException('Could not encrypt the data.');
        }

        return base64_encode($json);
    }

    /**
     * Encrypt a string without serialization.
     *
     * @param string $value
     * @return string
     */
    public function encryptString($value)
    {
        return $this->encrypt($value, false);
    }

    /**
     * Decrypt the given value.
     *
     * @param mixed $payload
     * @param bool $unserialize
     * @return string
     *
     * @throws \Illuminate\Contracts\Encryption\DecryptException
     */
    public function decrypt($payload, $unserialize = true)
    {
        $payload = $this->getJsonPayload($payload);

        $iv = base64_decode($payload['iv']);

        // Here we will decrypt the value. If we are able to successfully decrypt it
        // we will then unserialize it and return it out to the caller. If we are
        // unable to decrypt this value we will throw out an exception message.
        $decrypted = \openssl_decrypt(
            $payload['value'], $this->cipher, $this->key, 0, $iv
        );

        if ($decrypted === false) {
            throw new DecryptException('Could not decrypt the data.');
        }

        return $unserialize ? unserialize($decrypted) : $decrypted;
    }

    /**
     * Decrypt the given string without unserialization.
     *
     * @param string $payload
     * @return string
     */
    public function decryptString($payload)
    {
        return $this->decrypt($payload, false);
    }

    /**
     * Create a MAC for the given value.
     *
     * @param string $iv
     * @param mixed $value
     * @return string
     */
    protected function hash($iv, $value)
    {
        return hash_hmac('sha256', $iv . $value, $this->key);
    }

    /**
     * Get the JSON array from the given payload.
     *
     * @param string $payload
     * @return array
     *
     * @throws \Illuminate\Contracts\Encryption\DecryptException
     */
    protected function getJsonPayload($payload)
    {
        $payload = json_decode(base64_decode($payload), true);

        // If the payload is not valid JSON or does not have the proper keys set we will
        // assume it is invalid and bail out of the routine since we will not be able
        // to decrypt the given value. We'll also check the MAC for this encryption.
        if (!$this->validPayload($payload)) {
            throw new DecryptException('The payload is invalid.');
        }

        if (!$this->validMac($payload)) {
            throw new DecryptException('The MAC is invalid.');
        }

        return $payload;
    }

    /**
     * Verify that the encryption payload is valid.
     *
     * @param mixed $payload
     * @return bool
     */
    protected function validPayload($payload)
    {
        return is_array($payload) && isset($payload['iv'], $payload['value'], $payload['mac']) &&
            strlen(base64_decode($payload['iv'], true)) === openssl_cipher_iv_length($this->cipher);
    }

    /**
     * Determine if the MAC for the given payload is valid.
     *
     * @param array $payload
     * @return bool
     */
    protected function validMac(array $payload)
    {
        $calculated = $this->calculateMac($payload, $bytes = random_bytes(16));

        return hash_equals(
            hash_hmac('sha256', $payload['mac'], $bytes, true), $calculated
        );
    }

    /**
     * Calculate the hash of the given payload.
     *
     * @param array $payload
     * @param string $bytes
     * @return string
     */
    protected function calculateMac($payload, $bytes)
    {
        return hash_hmac(
            'sha256', $this->hash($payload['iv'], $payload['value']), $bytes, true
        );
    }

    /**
     * Get the encryption key.
     *
     * @return string
     */
    public function getKey()
    {
        return $this->key;
    }
}

java 代码如下:

package com.mayee;

import cn.hutool.core.codec.Base64;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.crypto.Mode;
import cn.hutool.crypto.Padding;
import cn.hutool.crypto.digest.HMac;
import cn.hutool.crypto.digest.HmacAlgorithm;
import cn.hutool.crypto.symmetric.AES;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.phprpc.util.PHPSerializer;

import java.lang.reflect.InvocationTargetException;
import java.util.Objects;

/**
 * @program: xgc-shumei
 * @description: 重构 php 的 laravel 框架中 Encrypter 类的 encrypt 和 decrypt 方法。
 * 该类中默认的加/解密方式为 AES-128-CBC,可通过外部文件 app.php 配置其加密方式和秘钥,这里有外部配置 AES-256-CBC。
 * AES-256-CBC 对应秘钥字节长度为 32,偏移量直接长度为 16。
 * @author: Bobby.Ma
 * @create: 2022-02-10 12:16
 **/
@Slf4j
public class Encrypter {

    /**
     * 秘钥
     */
    private final String key = "YqrxNCQvKu5hnHu1qrEb1EzmSagWSzEM12aBASV+kG4=";

    /*
        php 在外部需要配置加密模式 AES-128-CBC 或 AES-256-CBC,并且只支持这两种模式。
        但 java 无需指定模式,会根据秘钥长度来自动判断。
     */


    /**
     * 加密给定的文本
     *
     * @param value 原文对象
     * @return 密文
     */
    public String encrypt(Object value) {
        byte[] iv = RandomUtil.randomBytes(16);
        byte[] serialize;
        PHPSerializer ps = new PHPSerializer();
        try {
            serialize = ps.serialize(value);
        } catch (IllegalAccessException | InvocationTargetException e) {
            log.error("Could not serialize the data: {}", e.getMessage());
            return null;
        }
        AES aes = new AES(Mode.CBC, Padding.PKCS5Padding, Base64.decode(key), iv);
        String encrypt = aes.encryptBase64(serialize);
        String mac = this.hash(Base64.encode(iv), encrypt);
        JSONObject json = new JSONObject(true);
        json.put("iv", Base64.encode(iv));
        json.put("value", encrypt);
        json.put("mac", mac);
        return Base64.encode(json.toJSONString());
    }

    /**
     * 解密给定的文本
     *
     * @param payload 密文
     * @return 原文
     */
    public <T> T decrypt(String payload, Class<T> type) {
        JSONObject payloadObj = this.getJsonPayload(payload);
        byte[] iv = Base64.decode(payloadObj.getString("iv"));
        AES aes = new AES(Mode.CBC, Padding.PKCS5Padding, Base64.decode(key), iv);
        byte[] decrypt = aes.decrypt(payloadObj.getString("value"));
        PHPSerializer ps = new PHPSerializer();
        try {
            return (T) ps.unserialize(decrypt, type);
        } catch (IllegalAccessException | InvocationTargetException e) {
            log.error("Could not unserialize the data: {}", e.getMessage());
        }
        return null;
    }

    private JSONObject getJsonPayload(String payload) {
        JSONObject payloadObj = JSON.parseObject(Base64.decodeStr(payload));
        if (!this.validPayload(payloadObj)) {
            throw new RuntimeException("The payload is invalid.");
        }
        if (!this.validMac(payloadObj)) {
            throw new RuntimeException("The MAC is invalid.");
        }
        return payloadObj;
    }

    /**
     * 验证 payload 是否有效
     *
     * @param payloadObj
     * @return
     */
    private boolean validPayload(JSONObject payloadObj) {
        if (Objects.nonNull(payloadObj)) {
            return payloadObj.containsKey("iv") && payloadObj.containsKey("value") && payloadObj.containsKey("mac") &&
                    Base64.decode(payloadObj.getString("iv")).length == 16;
        }
        return false;
    }

    private boolean validMac(JSONObject payloadObj) {
        byte[] bytes = RandomUtil.randomBytes(16);
        String calculated = this.calculateMac(payloadObj, bytes);
        HMac mac = new HMac(HmacAlgorithm.HmacSHA256, bytes);
        return Objects.equals(mac.digestHex(payloadObj.getString("mac")), calculated);
    }

    private String calculateMac(JSONObject payloadObj, byte[] bytes) {
        HMac mac = new HMac(HmacAlgorithm.HmacSHA256, bytes);
        return mac.digestHex(this.hash(payloadObj.getString("iv"), payloadObj.getString("value")));
    }

    private String hash(String iv, String value) {
        HMac mac = new HMac(HmacAlgorithm.HmacSHA256, Base64.decode(key));
        return mac.digestHex(iv + value);
    }
}

3. 验证

  • Java 版:
public static void main(String[] args) {
        User origin = new User();
        origin.setName("bobby");
        origin.setId(1);
        origin.setAge(26);
        Encrypter encrypter = new Encrypter();
        String encrypt = encrypter.encrypt(origin);
        System.out.println("密文:" + encrypt);
        User user = encrypter.decrypt(encrypt, User.class);
        System.out.println("原文:" + JSON.toJSONString(user));
    }

输出:

密文:eyJpdiI6InE0ZG1pSDNNYmQ0VVBvSm5vTzhueGc9PSIsInZhbHVlIjoiZE9VK05sVVdwd2x4Z3kvcmgvOXJlMjBXdC9PdmFLaHNUdVZZMjF5QzFoVUtjS09Ud3lBaWhOZmJhSm11ME9PU2hBd3BMOXBFZk9ZMkUySnNhelVMTFdzVjcrSUM0Z1laQVY5KzFoRkhaczA9IiwibWFjIjoiMjE3NzNlN2IwY2VjNjMxMzBlNmI5MWMyYjhkNjAwZTQxMmExYzM1MDg0OTc5YzcwNGRmZDBmZmUxMTdmMzgxNyJ9
原文:{"age":26,"id":1,"name":"bobby"}
  • PHP 版:
<?php
spl_autoload_register(function ($class_name) {
    require_once $class_name . '.php';
});

use Encryption\Encrypter;

$key = 'YqrxNCQvKu5hnHu1qrEb1EzmSagWSzEM12aBASV+kG4=';
$arr = [
    'name' => 'bobby',
    'id' => 1,
    'age' => 26
];
$origin = json_encode($arr);

$cipher = new Encrypter(base64_decode($key), 'AES-256-CBC');
$enc_str = $cipher->encrypt($origin);
echo '密文:' . $enc_str . PHP_EOL;
$dec_str = $cipher->decrypt($enc_str . PHP_EOL);
echo '原文:' . $dec_str . PHP_EOL;

输出:

密文:eyJpdiI6Ik5jeURsVGVaQnZQSytoMGlWTG16NWc9PSIsInZhbHVlIjoiU3VSRnpGTVNaOGROZzNQM2hQbzNiUys2WjNVU3JOMytQb0xJek1DWjZmXC9nVldDQ05sUmJYd3VOVGVSZEhpNmQiLCJtYWMiOiI0MjE4MTZmNGJkMzdlNmI0MTc1MjZlYmRmNTI3NDY3NzVlYTliNmY2NDNlN2Q5N2FmMWQ0ZGZhZjEyNDVlMjlmIn0=
原文:{"name":"bobby","id":1,"age":26}

注:Java 可以解密 PHP 的密文,但 PHP 解密 Java 的密文会提示文件不存在,这是因为 PHP 在解密后反序列化为对象时找不到文件,如果只是对字符串操作则可以相互加解密。


Tip:本文完整示例代码已上传至 GitHub 。代码中php 包下的文件拷贝置 PhpStorm 中可运行验证。