前言

最近把小站的登录页面给重构了,之前的安全性存在很大问题,基本处于裸奔的状态,特此记录一下过程。

先说一下网站后端语言是php,为什么用php呢,因为php是世界上最好的语言吗,可能吧,不过最大的原因是因为我的网站托管在虚拟主机上,目前来说,几乎所有厂商的虚拟主机都只支持php,不过本文所涉及到的php代码都十分简单,跟js没啥区别。

本次规划的登录方式有三种,密码登录、手机验证码登录、第三方登录,接下来就一一来看一下。

界面

登录界面通常来说都比较简单,无非是几个输入框,对于笔者这种一线搬砖码农来说不过是三下两除二的事情,直接看最终效果:

前言-LMLPHP

Element UI和浓浓的QQ空间风交杂在一起有没有。

行为验证

现在大多数网站登录前一般都会先进行人机验证,从最早的输入各种各样字符验证码,到现在越来越流行的滑动拼图验证、文字点选验证、无感验证等等,阿里云、网易、腾讯等等大厂都有提供行为验证服务。

行为验证一般由前端和后端配合进行验证,单纯的前端验证并不安全,可以绕过,所以前端验证通过后会生成token等标识,传给后端,后端再调用服务商对应的接口来验证。

行为验证的原理可能涉及到机器学习什么的,已经超出笔者的能力范围,但作为使用方来说,具体使用方式一般服务商都会有详细的例子和示例代码,在此不赘述。

密码登录

密码登录是最传统最历史悠久的登录方式了,注册的时候把账号密码保存到数据库,登录的时候再进行比对,基本原则是不能明文传输、不能明文保存。

具体实现上,首先对密码设定要求,暂定规则是长度八位到十六位,需要至少包含大小写字母和数字,可包含部分特殊字符:$@$!%*#_~?&,前后端都进行校验。

网站支持https的话可以不用考虑传输问题,但是我的虚拟主机并不支持,所以需要手动进行加密传输。

后端接收到密码解密后再进行不可逆的加密存储。

密码规则验证

直接通过正则表达式校验即可,上述提到的密码规则的其中一个正则表达式实现:/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])[a-zA-Z0-9$@!.%*#_~?&]{8,16}$/,前面三个括号都是(?=p)的模式,p是一个子模式,?=用来匹配符合p模式之前的位置,整体含义是匹配以任意字符加小写字母任意字符加大写字母任意字符加数字开头的八位以上的包含数字大小写字母的字符串,其中的.*是必要的,否则上面的正则匹配不了任何字符,因为不可能有一个字符串能同时以大小写字母及数字开头。

加密传输

常用的加密方式有这几种:MD5、对称加密和非对称加密,在这个场景下MD5不合适,因为它是把字符进行不可逆的编码,那传给服务端也解不开,再加上它并不安全,很多人也不认为它是一种加密算法;对称加密的话加密和解密用的是同一个秘钥,这意味着前端代码里也得内置这个秘钥,那只要打开源码就能看到了所以也不安全,就只能选择非对称加密了。

非对称加密有公钥和私钥两个秘钥,加密和解密分别选择一个,其中一个加密的数据只能使用另外一个秘钥来加密,这样在前端就可以使用公钥来加密,后端使用私钥解密,公钥就算被发现了没有私钥也没用,目前最知名也最重要的就是RSA加密算法了,详细了解可参考阮大神的文章:http://www.ruanyifeng.com/blog/2013/06/rsa_algorithm_part_one.html

RSA加密安全的代价之一就是慢,比对称加密慢非常多,所以一般都是和对称加密结合进行使用,比如https协议,传输的信息使用对称加密算法进行加密,对称加密的秘钥使用非对称加密方式来加密进行传输。另外,RSA加密的数据大小不能超过秘钥长度,比如你的秘钥长度为1024位,那么所加密的数据最大不能超过1024/8=128字节,首先来按登录场景来简单计算一下。

前言-LMLPHP

以上面百科上的utf8编码转换表来写一个简单的计算字符字节数的方法如下:

function strLen (str) {
    let len = 0
    for(let i = 0; i < str.length; i++) {
        let code = str.charCodeAt(i)
        if (code <= 0x007f) {
            len += 1
        } else if (code <= 0x07ff) {
            len += 2
        } else if (code <= 0xffff) {
            len += 3
        } else {
            len += 4
        }
    }
    return len
}

账号为手机号,也就是11个数字,字节大小计算出来为:11;密码以最长16位计算出来字节大小约为:16,都远小于128字节,所以可以直接使用RSA来进行加密,速度的话此处也可以忽略不计。

前端可以使用jsencrypt这个库来进行rsa加密。在此之前需要先生成公钥和私钥,这个可以使用openssl命令行工具,openssl是一个开源的软件工具包,用来实现TLS(传输层安全协议),同时包含了主要的加密算法、常用的密钥和证书封装管理等功能。

生成私钥:

openssl genrsa -out lx_rsa_1024_priv.pem 1024

查看上一步生成的私钥:

cat lx_rsa_1024_priv.pem

获取上述私钥的公钥:

openssl rsa -pubout -in lx_rsa_1024_priv.pem -out lx_rsa_1024_pub.pem

查看上一步生成的公钥:

cat lx_rsa_1024_pub.pem

保存好私钥和公钥,接下来前端使用公钥来加密,安装jsencrypt

npm i jsencrypt

加密代码:

import Jsencrypt from 'jsencrypt';

const rsa_pub = 'xxx'// 公钥
const password = 'xxx'

encrypt.setPublicKey(rsa_pub)
let encryptedPassword = encrypt.encrypt(password)

然后把加密后的账号和密码发送到后端,后端进行解密,php解密代码如下:

<?php

function decryptRSA($str)
{
    // 读取私钥
    $private_key = openssl_pkey_get_private(RSA_PRIVATE);
    if (!$private_key) {
        return '私钥有误';
    }
    // 解密
    $return_de = openssl_private_decrypt(base64_decode($str), $decrypted, $private_key);
    if (!$return_de) {
        return ('解密失败');
    }
    return $decrypted;
}

解密的时候要先使用base64_decode来进行解码的原因是RSA加密后是二进制数据,不适合http传输,一般都会使用base64转成字符串,从jsencrypt的源码里也能看出:

public encrypt(str:string) {
    // Return the encrypted string.
    try {
        return hex2b64(this.getKey().encrypt(str));
    } catch (ex) {
        return false;
    }
}

php解密得到账号密码后就可以去数据库进行比对,这里就需要先讨论一下密码是如何加密存储的。

密码存储

我们经常会听到某某公司的数据库泄漏了的消息,数据库泄漏最可怕的是什么,除了用户个人信息之外就是密码了,因为现在的各种网站APP实在是太多了,每个都要设置密码,所以大多数人都是一个密码走天下,那么如果密码被别人获取了是很可怕的事情,所以密码存储一定是不可逆的。

最简单的是直接对密码使用md5加密,但是常用密码很容易就被反向查询出来了,稍微进阶一点的是把密码和一个复杂的随机字符串,俗称盐先拼接起来,再进行md5,这样反向查询出来的概率就比较低了,但是如果盐也被窃取了,那人家同样也可以先加盐再进行反向查询,所以为了增加破解难度,每个密码的盐值都是不一样的,盐值和密码通常是存储在一起的。但是以现在计算机的计算能力来说破解起来还是比较容易的,所以又出现了一种叫PBKDF2的方法,简单说来就是进行N次md5,次数越多,破解的耗时也越久,当破解一个密码都需要耗时很久,那么总的代价会是巨大的。还有一种是bcrypt算法,可以通过参数调整计算强度,被认为是比PBKDF2更安全的。

以上这些php都有内置函数可以支持,但是限于我所用的php版本PBKDF2bcrypt函数都不支持,所以只能选择自己实现一个简单的PBKDF2方法。

使用PBKDF2算法一般都会选择使用sha系列hash算法,本文选择sha1,hash它个1000次。

<?php

// 生成随机字符
function randomStr($len){
    $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_ []{}<>~`+=,.;:/?|';
    $charsLen = strlen($chars) - 1;
    $str = '';
    for($i = 0; $i < $len; $i++) {
        $str .= $chars[mt_rand(0,$charsLen)];
    }
    return $str;
}

php生成盐应该有更安全的方法,但是搜索了一圈,都没找都合适的方法,所以只能这样简单写一个。

接下来要实现的是PBKDF2方法,基本逻辑是原始密码和盐进行hash,将得到的hash值再和原始密码进行hash,这样循环hash,直到你需要的次数。

<?php

function PBKDF2HASH($password, $salt, $count)
{
    $curSalt = $salt;
    for($i = 0; $i < $count; $i++) {
        $curSalt = sha1($password . $curSalt);
    }
    return $curSalt;
}

之后再把生成的hash值和盐值一同保存到数据库,登录时再把盐值取出来进行上述的hash操作,比对最后生成的值是否一致即可。

维持登录状态

登录成功后需要保持登录状态,因为http是无状态协议,所以催生了cookie的诞生,cookie就是一段文本,保持在客户端本地,每次发送http请求时客户端都会把它带到请求头里,这样服务端就可以通过cookie来判断本次会话用户的信息。

一般登录成功后服务端会设置一个只允许http访问的cookie,内容一般是一个id,然后把用户信息和这个id关联起来,这些数据可以保持在内存里(通常使用redis数据库)或者持久化到MySql等数据库,下次请求时根据这id来判断有没有登录信息。

php里使用session变量可以很容易实现这个需求:

<?php

session_start();

$_SESSION['uid'] = xxx;

使用session_start注册一个新会话或者重用现有会话,然后给超级全局变量$_SESSION设置一个键值,具体要保存什么数据因你而定,我这里只保存一个用户id,用户其他的信息根据id再去数据库里查询。

设置完后下次收到请求时获取和退出登录时的销毁也很简单:

<?php

session_start();
// 获取
$uid = $_SESSION['uid'];
// 销毁
$_SESSION['uid'] = null;
session_destroy();// 通常来说不需要调用这个方法

当然上述是最简单的方式,缺点也很明显,浏览器关闭或者一段时间后就需要重新登录,另外对单点登录也不太友好。

要想让登录更持久可以设置cookie的有效期和session过期时间长一点:

<?php

// 设置session_id的cookie,五个参数:过期时间,单位s、路径path、域domain、是否仅在https时可用、是否httponly
session_set_cookie_params(3 * 3600, '/', '.lxqnsys.com', false, true);
// 设置session生存时间
ini_set("session.gc_maxlifetime", 3 * 3600);

session_start();
// ...

但是过期时间设置的太久是一件又风险的事情,所以最好还是考虑使用其他方式。

另一种维持登录状态的方式是使用JWT(json web token),这种方式简单来说就是登录成功后把认证信息都返回给客户端,由客户端进行存储,每次http请求时也带上,服务端不需要存储任何数据,而是从中取出需要的东西,当然,这个token是有生成规则的,分三部分组成,伪代码如下:

// 元信息
const header = base64UrlEncode({
    "alg": "HS256",
    "typ": "JWT"
}
// 内容主体
const payload = base64UrlEncode({
    // 可以选用预定义字段,也可以添加自定义字段
})
// 签名,用来检查数据是否被篡改了,secret是秘钥,不能泄露
const signature = HMACSHA256(`${header}.${payload}`, secret)
// 组成最终的token
const token = `${header}.${payload}.${signature}`

可以看到生成的token是没有加密的,所以不能放敏感信息,硬要放的话需要对token再做一层加密。

更多详细信息可参考:http://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html

短信登录

短信登录也是现在很普及的一种登录方式,有些网站甚至只支持短信登录,因为发短信是要钱的,所以一定需要做一些限制措施,图形验证之类的是肯定要的,另外还要限制发送频率,比如1分钟或2分钟之内只能发送一条,以及同一个手机号一天之内只能发送多少条。

验证码和时间限制也可以使用session来保存:

<?php

// 保存
$sessionArray = array();
$sessionArray['phoneNumber'] = $phoneNumber;
$sessionArray['code'] = $code;
$sessionArray['lastTime'] = time();
$_SESSION['verificationCode'] = $sessionArray;

再次收到请求时从session取出来判断手机号、验证码、时间是否都正确合法。至于限制手机号一天发送的量因为服务商自带就有这个功能,所以就不自己做了。

第三方登录

最后一种要实现的方式是第三方登录,这也是目前很流行的一种登录方式,这种方式的好处是你不需要向当前网站提供第三方网站的账号和密码就可以获取到第三方网站里的一些用户信息,这样在当前网站就可以不用通过麻烦的注册来创建账号及登录,但是有少数网站你选择了第三方登录以及登录成功后还立马要让你填手机号密码什么的再注册一遍,不讲武德,简直智障,我就是图方便才登录第三方账号,完了你还要我注册,说白了就是想要我手机号,如果不是什么非必须的网站,一般到这一步我就跟它说再见了。

第三方登录简单来说就是先跳转去登录第三方网站,登录成功后会把一些信息如用户唯一的id、昵称、头像什么的返回给当前网站,当前网站可以根据这些信息来创建新账号或者完成登录,这其中涉及到的是一个叫做OAuth 2.0的协议,这个协议有点长,里面规定了四种实现方式,有兴趣的可以自行百度阅读,反正我从来没有读完过。不过目前各大网站的接入方式都是基本一致的,总结如下:

1.去第三方网站的开放平台注册账号,填写应用信息,填写回调地址,获取一下app keyapp secret

2.在你的网站上点击第三方网站的图标或按钮后跳转到第三方提供的登录地址,带上app key以及上一步填写的回调地址,登录成功后回跳转回回调地址页面,并带上一个code

3.通过上一步获取到的code去请求第三方提供的接口获取令牌

4.通过上一步获取到的令牌再去请求第三方提供的接口获取用户信息

接下来我们以掘金上的第三方登录github账号来实现一下。

第一步去github上注册应用https://github.com/settings/applications/new

前言-LMLPHP

最后一个要输入的就是我们的回调地址。

第二步在我们的网站上添加第三方登录的按钮,一般都是使用对方的logo

前言-LMLPHP

点击后跳转到github的登录地址,掘金上点击后会弹出一个小窗口:

前言-LMLPHP

这可以使用window.open方法,不过有一些需要注意的点,如果只是简单的使用:

let url = `https://github.com/login/oauth/authorize?client_id=xxx&redirect_uri=http://xxx.com/`;
window.open(url)

默认下是直接新开一个tab,而不是以小窗口的形式打开,想要以小窗口打开的话第三个参数不能为空,也就是你要设置一下新开窗口的样式:

window.open(url, '_blank', 'width=600, height=600')

但是经测试,浏览器全屏的情况下一般仍然是新开一个tab,并且各个浏览器的效果可能都不一样,所以不要期待能有一致的效果了。

看一下掘金登录时小窗口上的地址信息:

https://github.com/login?client_id=60483ab971aa5416e000&return_to=/login/oauth/authorize?client_id=60483ab971aa5416e000&redirect_uri=https://juejin.cn/passport/auth/login_success&scope=user:email&state=4b4b89193gASoVCgoVPZIGM4MDY0MzZmNjJlNDlhMTc1NjBmNjg1MDU3MWUxNWM2oU6-aHR0cHM6Ly9qdWVqaW4uY24vb2F1dGgtcmVzdWx0oVYBoUkAoUQAoUHRCjChTdEKMKFIqWp1ZWppbi5jbqFSBKJQTNEEFaZBQ1RJT06goUyyaHR0cHM6Ly9qdWVqaW4uY24voVTZIDEwNDlkOTIyYTE1YjUyOTdkMTA5NTk5M2UxZThiM2EwoVcAoUYAolNBAKFVww==

可以看到掘金的回调地址为:https://juejin.cn/passport/auth/login_success,另外还有几个参数,scope参数表示要求的授权范围,这里表示掘金除了基础信息外还想获取用户的电子邮件地址,state是一个字符串,最后会原封不动的传回给你,可以用来判断是否被修改了,更多信息可参考github的开发文档:https://docs.github.com/cn/developers/apps/authorizing-oauth-apps

如果用户登录成功就会重定向到回调地址,但是问题来了,回调地址只能填写一个,但是在掘金的任何页面都可以进行登录,而且登录成功后会自动刷新当前页面。

首先点击了第三方登录按钮后掘金会在localStorage上存储当前的登录发起页面的地址:

前言-LMLPHP

其次是监听子窗口的关闭,关闭了当前页面就进行刷新:

this.windowObj = window.open(url, '_blank', 'width=600, height=600')
this.onCloseCheck()

onCloseCheck() {
    if (!this.windowObj) {
        return
    }
    clearTimeout(this.closeCheckTimer)
    this.closeCheckTimer = setTimeout(() => {
        if(this.windowObj.closed) {
            location.reload()
            clearTimeout(this.closeCheckTimer)
            this.windowObj = null
        } else {
            this.onCloseCheck()
        }
    }, 500);
}

这样看起来这个存储的url似乎并没有什么用,的确,扒了一下小窗口页面的源码发现了下面的这段代码:

前言-LMLPHP

可以发现存储的这个url只在微信环境下才用的到。但是如果你的登录页是y

在回调地址页面获取到返回的code之后需要换取令牌,通过后端请求对应接口:

<?php

$code = $_POST['code'];
$data = array(
    'client_id' => 'xxx',
    'client_secret' => 'xxx',
    'code' => $code,
    'redirect_uri' => 'xxx'
);
// post为一个发送post请求的方法,不是php的内置函数
post('https://github.com/login/oauth/access_token', $data);

获取到令牌就可以再去请求获取用户信息:

<?php

$header = array('Authorization: token ' . $access_token, 'User-Agent: 理想青年实验室');
// get为一个发送get请求的方法,不是php的内置函数
get('https://api.github.com/user', $header);

获取到用户信息就可以根据里面的用户唯一的id字段的值来创建账号、关联账号以及进行登录。

总结

本文简单记录了一下一个常见登录页面的一些知识点,存在错误或安全问题的话还请指出,登录可以说的东西还有很多,比如如何实现免登录、扫码登录、单点登录、app客户端等的登录等等,因为目前没有相关实践,所以也无从介绍,各位有兴趣可以自行了解,再会。

08-03 18:07