注册登录请求中RSA加密,PHP服务器和Android客户端实现

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/kbkaaa/article/details/78656611

前言

客户端利用Http协议进行注册和登录等操作时,如果不做特殊处理,请求中携带的密码等敏感信息是明文传输的,有可能会被截获。解决这个问题最好的方法当然是使用Https协议,但是Https协议需要像权威机构申请证书才能保证足够的安全性,在没有证书的情况下,可以考虑自己来实现加密解密处理。

我们现在的场景只考虑在Http请求中加密,Http响应中没有敏感信息,暂时不考虑加密。首先考虑下对称加密的方式,这种方式客户端和服务器保存了一份相同的密钥,客户端加密和服务器解密是都需要用到,但是客户端程序有被破解的可能性,密钥有可能被泄露,攻击者拿到密钥再去截获请求报文,就可以解密出敏感信息来。因此最后选择了非对称加密RSA算法,客户端使用公钥加密,服务端再利用私钥解密,这样客户端只需要保存公钥了,即使泄露了也没法用来加密。可以实现敏感信息和密钥都处于相对安全的状态。
Github地址:
服务器:https://github.com/zhongchenyu/jokes-laravel
Android: https://github.com/zhongchenyu/jokes

PHP服务端实现

1. 生成密钥

可以利用 openssl 命令来生成RSA的密钥,一般linux系统都预装了。另外项目用的是 Laravel 框架,可以利用框架生成命令,将创建密钥的命令封装起来。
首先在项目路径下执行命令 php artisan make:command GenerateRSAKey
然后编辑GenerateRSAKey类的handle函数:

 public function handle()
    {
        //
      $keyDir = 'sec';
      if(!is_dir($keyDir)) mkdir($keyDir);
      echo getcwd() . "\n";
      chdir($keyDir);
      echo getcwd() . "\n";
      //生成原始 RSA私钥文件 rsa_private_key.pem
      shell_exec('openssl genrsa -out rsa_private_key.pem 1024');
      //将原始 RSA私钥转换为 pkcs8格式
      shell_exec('openssl pkcs8 -topk8 -inform PEM -in rsa_private_key.pem -outform PEM -nocrypt -out private_key.pem');
      //生成RSA公钥 rsa_public_key.pem
      shell_exec('openssl rsa -in rsa_private_key.pem -pubout -out rsa_public_key.pem');
      chdir('..');
      echo getcwd() . "\n";
      return;
    }

执行命令后就会在项目的 sec 目录下生成公钥和私钥文件了。

2. 加密解密方法封装

创建一个RSAUtils类来专门处理加密和解密,实际上服务器只需要利用私钥解密就够了,不过为了以后的可扩展性,把所有的加解密方法都先写了:

class RsaUtils {

  public static function enPublic($data)
  {
    $path = base_path();
    $publicKey = openssl_get_publickey(file_get_contents($path.'/sec/rsa_public_key.pem'));
    openssl_public_encrypt($data,$encrypted,$publicKey);
    $base64Encoded = base64_encode($encrypted);
    return $base64Encoded;
  }

  public static function dePrivate($data)
  {
    $path = base_path();
    $privateKey = openssl_get_privatekey(file_get_contents($path.'/sec/rsa_private_key.pem'));
    openssl_private_decrypt(base64_decode($data), $decrypted, $privateKey);

    return $decrypted;
  }

  public static function enPrivate($data) {
    $path = base_path();
    $privateKey = openssl_get_privatekey(file_get_contents($path.'/sec/rsa_private_key.pem'));
    openssl_private_encrypt($data, $encrypted, $privateKey);
    $base64Encoded = base64_encode($encrypted);
    return $base64Encoded;
  }

  public static function dePublic($data) {
    $path = base_path();
    $publicKey = openssl_get_publickey(file_get_contents($path.'/sec/rsa_public_key.pem'));
    openssl_public_decrypt(base64_decode($data), $decrypted, $publicKey);
    return $decrypted;
  }

}

以私钥解密的方法dePrivate()来说明下,其余的类似。

$path = base_path();
$privateKey = openssl_get_privatekey(file_get_contents($path.'/sec/rsa_private_key.pem'));

首先通过 file_get_contents() 读取密钥文件的内容,这里要注意PHP 的 file_get_contents()参数只能是绝对路径,所以先要获取项目路径,补充到密钥相对路径前面。
openssl_get_privatekey()则从字符串中获取密钥,如果密钥格式不合法,则会出错。

openssl_private_decrypt(base64_decode($data), $decrypted, $privateKey);

考虑网络中的数据经过base64编码的情况,这里首先对$data 进行base64解密,客户端也要配合进行base64编码。
然后用openssl_private_decrypt() 函数将base64解密后的$data, 利用 $privateKey 进行RSA私钥解密,解密后的数据存在$decrypted 中并返回。

3. 测试加解密接口

为了验证下加密解密方法是否可行,写了一个测试用的接口,对请求数据加密再解密:

public function rsaTest(Request $request)
  {
    $data = $request->get('data','abcdef');
    echo 'base64:'.base64_encode($data).'<br>';
    echo $data . '<br>';
    echo 'encrypted by public key<br>';
    $encrypted = RsaUtils::enPublic($data);
    echo $encrypted . '<br>';
    $decrypted = RsaUtils::dePrivate($encrypted);
    echo $decrypted . '<br>';
    echo 'encrypted by private key<br>';
    $encrypted = RsaUtils::enPrivate($data);
    echo $encrypted . '<br>';
    $decrypted = RsaUtils::dePublic($encrypted);
    echo $decrypted . '<br>';
  }

访问结果,可以看出加密解密没有问题:

base64:YWJjZGVm
abcdef
encrypted by public key
VnW8RNMCoqVzDUuvjwwmNAv4MX5kpbIvCgaPdQNE+xeJO4wBaD7PHpMKv/Ts7ry8ANg3uDnh10owalPQLy4d8RcX3g30z/Npf3RB1zFGtKB06YlN/O6zWgFkBr6X1EBubc7xmsoAeXBPn06/pjBn3xiYHWYlj7U5V7chaC0gh6k=
abcdef
encrypted by private key
b0KVZcXPOWaZtLpN5MAjV87ikMynEV4j96aehypzJVwSZUTf+gIY4zQf4/KJnxPxleK4sHOKXhiaQ/QdrsSA1tmnKv8YMW7s1iAlreYB9KjfVgNI2agPhYA5pISXPpUv3BByo9wNWc2Ef5/5w6/Hzs30jrp1wwgCbZcu/jkPf8Q=
abcdef

4. 加密的注册和登录接口:

只对密码进行加密处理。
注册接口:

public function encryptedRegister(Request $request)
  {
    $this->validator($request->all())->validate();
    $params = $request->all();
    $password = RsaUtils::dePrivate($params['password']);
    if($password == null) {
      return response()->json(['message' => 'Encryption error'], 400);
    }
    $params['password'] = $password;

    $user  = $this->create($params);
    $token = JWTAuth::fromUser($user);
    return ["token" => $token];
  }

登录接口:

public function encryptedAuthenticate(Request $request)
  {
    $credentials['email'] = $request->get('email');
    $credentials['password'] = RsaUtils::dePrivate( $request->get('password'));
    try {
      // attempt to verify the credentials and create a token for the user
      if (! $token = JWTAuth::attempt($credentials)) {
        return response()->json(['error' => 'invalid_credentials'], 401);
      }
    } catch (JWTException $e) {
      // something went wrong whilst attempting to encode the token
      return response()->json(['error' => 'could_not_create_token'], 500);
    }
    $user = User::where('email', $credentials['email'])->first();
    $userTransform = new UserTransformer();
    return ['user'=> $userTransform->transform($user), 'token' => $token];
  }

关键的一步,向将请求参数中的 password 进行解密:
$password = RsaUtils::dePrivate($params['password']);
解密之后的处理和没有加密机制时基本一致。

注册时要注意增加判断,如果$password 没有经过正确的加密,这是解密返回的是null,必须返回错误:

if($password == null) {
      return response()->json(['message' => 'Encryption error'], 400);
    }

否则可能会创建一个密码为 null 的用户,以后凡是用任何一未正确加密的密码都可以登录了。

5. 接口测试用例

因为项目托管在 Github 上,可以对接 Travis 进行自动化测试。
首先编辑.travis.yml 增加一条生成密钥的命令- php artisan rsa:generate

language: php
php:
  - 7.1
service:
  - mysql
before_script:
  - composer install
  - composer dump-autoload
  - cp .env.travis .env
  - php artisan jwt:generate
  - php artisan key:generate
  - php artisan vendor:publish
  - mysql -e 'CREATE DATABASE IF NOT EXISTS jokes ;'
  - php artisan migrate
  - php artisan rsa:generate
script: phpunit

测试用例:

class EncryptedLoginTest extends TestCase {
  use DatabaseTransactions;

  public function testEncryptedLogin()
  {
    $user              = User::create([
      'name'     => 'TestUser12345',
      'email'    => '[email protected]',
      'password' => bcrypt('123456'),
    ]);
    $userId            = $user->id;
    $encryptedPassword = RsaUtils::enPublic('123456');
    $response          = $this->json('POST', '/api/encrypted_login', [
      'name'     => 'TestUser12345',
      'email'    => '[email protected]',
      'password' => $encryptedPassword
    ]);
    $response->assertStatus(200)->assertJsonStructure(['token'])
    ->assertJson(['user' => ['id'=>$userId, 'name'=>'TestUser12345', 'email' =>'[email protected]']]);
  }
}

这样执行 git push 到Github后将自动执行环境部署和测试,通过:

$ phpunit
PHPUnit 6.4.4 by Sebastian Bergmann and contributors.
............                                                      12 / 12 (100%)
Time: 3.08 seconds, Memory: 28.00MB
OK (12 tests, 22 assertions)
Generating code coverage report in Clover XML format ... done
The command "phpunit" exited with 0.
Done. Your build exited with 0.

服务器搞定,下面开始客户端编码。

Android 客户端

1. 加解密工具代码

密钥获取:

private static PublicKey getPublicKey(String publicKeyString) throws Exception{
    byte[] keyBytes = Base64.decode(publicKeyString.getBytes(), Base64.DEFAULT);
    X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
    KeyFactory keyFactory = KeyFactory.getInstance("RSA");
    return keyFactory.generatePublic(keySpec);
  }

  private static PrivateKey getPrivateKey(String privateKey) throws Exception {
    byte[] keyBytes = Base64.decode(privateKey.getBytes(), Base64.DEFAULT);
    PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
    KeyFactory keyFactory = KeyFactory.getInstance("RSA");
    return keyFactory.generatePrivate(keySpec);
  }

加密和解密:

private static byte[] encrypted(byte[] content, PublicKey publicKey) throws Exception {
    Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
    cipher.init(Cipher.ENCRYPT_MODE, publicKey);
    return cipher.doFinal(content);
  }

  private static byte[] decrypt(byte[] content, PrivateKey privateKey) throws Exception{
    Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
    cipher.init(Cipher.DECRYPT_MODE, privateKey);
    return cipher.doFinal(content);
  }

最终要用到的公开接口,利用公钥加密,并进行base64编码:

public static String base64Encrypted(String data)  {
    byte[] encryptedBytes = {};
    try {
      PublicKey publicKey=getPublicKey(publicKeyString);
      encryptedBytes = encrypted(data.getBytes(), publicKey);
    } catch (Exception e) {
      e.printStackTrace();
    }
    return Base64.encodeToString(encryptedBytes, Base64.NO_WRAP);
  }

这里讲下中间遇到的几个坑,要注意下。客户端和服务器,一个加密一个解密,要能对接起来,除了要求用相同的算法,配套的公钥密钥,编码方式等,还要注意算法和编码中用到的一些细节参数,否则可能出现客户端加密,发送到服务端无法解密的情况。主要有这些:
Base64编码换行的问题
PHP里用base64_decode($data)base64_encode($data) 进行Base64编解码,不需要额外参数。
Java中可以使用 java.util.Base64 库的 Base64.getEncoder().encode(data)Base64.getDecoder().decode(data) 进行编解码,也不需要额外参数,并且是可以和PHP对接的,即一方编码,另一方解密没有问题。
Android虽然用的是Java 语言,但是却没有java.util.Base64 库,但是有专门的 android.util.Base64 库,使用函数 Base64.encode(byte[] bytes, int flag),有一个参数flag,使用默认的flag:Base64.DEFAULT 是无法和PHP的base64对接的,必须用Base64.NO_WRAP,因为 Android 的默认方式,会在编码过长是添加换行符,而PHP和Java则不会,因此需要使用Base64.NO_WRAP。

RSA分组和填充方式
PHP中用 openssl_private_decrypt($data, $decrypted, $privateKey) 不需要额外参数, Java中RSA 默认就是RSA/ECB/PKCS1Padding,但Android的话虽然用的和Java是一个库,但是必须制定RSA/ECB/PKCS1Padding

Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");

其中RSA是加密算法,RSA是块算法,可以有不同的分组方式,ECB是其中最简单的一种,PKCS1Padding则是填充方式,这里需要指定。

2. 请求处理

public void register(String name, String email, String password) {
    mName = name;
    mEmail = email;
    mPassword = RSAUtil.base64Encrypted(password);
    start(REGISTER);
  }

  public void login(String email, String password) {
    mEmail = email;
    mPassword = RSAUtil.base64Encrypted(password);
    start(LOGIN);
  }

发送注册和登录前先将password加密,其余处理和不加密一样。

看下登录log,password被加密了:

11-26 17:04:21.891 4103-4185/chenyu.jokes D/OkHttp: email=zcy.gr%40qq.com&password=yJppgVBAbnG6yt8gIcTAKQ%2FRP7MdBU4Pg21b8V9KIys%2B6c8mJAUSAsBDu6bjqKLG****************

猜你喜欢

转载自blog.csdn.net/kbkaaa/article/details/78656611