如何设计安全的用户登录功能

2017/03/06 15:17
阅读数 6.2K

构建安全的网站用户登录认证体系

一、设计数据结构

1、用户数据和登录认证信息数据分开保存。
因为登录认证方式多种多样,方便日后扩展。

2、按照登录认证方式分表保存认证信息。
认证方式可能有用户名(或者邮箱、手机)+密码;手机+动态验证吗;第三方登陆(OAuth、OpenID)等。每种登录方式的处理逻辑都不一样,数据结构可能也不同。

3、不保存明文密码,密码加盐储存。
当然这完全取决于你的职业道德。

二、维护登录会话

一般使用SESSION来保存当前登录会话状态,如果认为SESSION不够安全,可以采取一些额外的防护。比如:SESSION绑定IP;使用HTTPS协议;在COOKIE中增加一次性令牌等措施。

注:所谓一次性令牌就是在用户COOKIE中保存一个随机字符串,当用户进行一次操作请求时就更新这个一次性令牌。服务器会为每个SESSION维护一个一次性令牌,当服务器接收到操作请求时都要验证一下发送请求的客户端浏览器COOKIE中的一次性令牌是否合法。如果发生SESSION被盗用,则盗用者和合法用户中必然会有一人被踢出会话。另外,如果将一次性令牌加入用户请求的URL参数中,还能防止CSRF攻击。

三、记住密码

一般采用在浏览器COOKIE中保存登录令牌的方式来实现“记住密码”功能。一个用户可以同时存在多个不同的登录令牌来满足用户在不同设备上同时登陆的需求。依据登录令牌来恢复用户的登录会话。

流程如下:
1、登录时用户选择“记住密码”则生成一个登录令牌分别保存在浏览器COOKIE和服务器数据库中。
2、用户下一次访问网站时,如果COOKIE中存在登录令牌则尝试验证令牌并恢复登录会话。
3、验证成功后需要更新这个登录令牌,使得旧的令牌失效。

为了增加登录令牌的安全性,可以和SESSION一样,使用一次性令牌。即每次用户和服务器交互时都更新一下登录令牌,以降低令牌被盗用的风险。

另外,登录令牌一般情况下都是绑定平台的。所以可以在生成登录令牌的同时提取用户的“浏览器指纹”,当用户下次使用这个登陆令牌恢复会话时,可以验证用户“浏览器指纹”是否匹配来降低令牌被盗用的风险。“浏览器指纹”包括但不限于:User-Agent、Accept-Language等具有标识性的请求头部,显示器分辨率、客户端时间和时区、浏览器插件信息(如Flash版本号)、Flash Cookie、HTML5 本地储存数据等。

四、密码找回

密码找回就是密码重置,因为网站不应该明文保存用户密码,所以就谈不上找回密码了。

1、安全问答不安全
使用社会工程学方法很容易就能猜解到用户的问题答案。

2、认邮箱(手机)还是认人?
一般网站都使用给注册邮箱发送密码重置链接来重置密码,这意味着一旦邮箱被盗,所有使用该邮箱注册的网站账户全部沦陷。对网站来说,最好不要暴露用户的注册邮箱;对用户来说,最好不要用一个邮箱注册所有的网站(至少SNS账户和支付网站账户的邮箱应该分开)。

3、使用多重验证的方式来重置密码
注意是“多重”,而不是“多种”。

五、防止密码刺探

1、CAPTCHA(验证码)

2、账户异常
特定账户登录失败事件密集发生时——邮件或短信提醒用户,为该账户设置最短登录尝试间隔。

3、IP异常
特定IP登录失败事件密集发生时——Ban IP

------------------------------------------------------

如何设计安全的用户登录功能

1)在cookie中,保存三个东西——用户名,登录序列,登录token。
用户名:明文存放。
登录序列:一个被MD5散列过的随机数,仅当强制用户输入口令时更新(如:用户修改了口令)。
登录token:一个被MD5散列过的随机数,仅一个登录session内有效,新的登录session会更新它。

2)上述三个东西会存在服务器上,服务器的验证用户需要验证客户端cookie里的这三个事。

3)这样的效果,

a)登录token是单实例登录。意思就是一个用户只能有一个登录实例。

b)登录序列是用来做盗用行为检测的。如果用户的cookie被盗后,盗用者使用这个cookie访问网站时,我们的系统是以为是合法用户,然后更新“登录token”,而真正的用户回来访问时,系统发现只有“用户名”和“登录序列”相同,但是“登录token”不对,这样的话,系统就知道,这个用户可能出现了被盗用的情况,于是,系统可以清除并更改登录序列 和 登录token,这样就可以令所有的cookie失效,并要求用户输入口令。并给警告用户系统安全。

4)当然,上述这样的设计还是会有一些问题,比如:同一用户的不同设备登录,甚至在同一个设备上使用不同的浏览器保登录。一个设备会让另一个设备的登录token和登录序列失效,从而让其它设备和浏览器需要重新登录,并会造成cookie被盗用的假象。所以,你在服务器服还需要考虑- IP 地址,

a)如果以口令方式登录,我们无需更新服务器的“登录序列”和 “登录token”(但需要更新cookie)。因为我们认为口令只有真正的用户知道。

b)如果 IP相同 ,那么,我们无需更新服务器的“登录序列”和 “登录token”(但需要更新cookie)。因为我们认为是同一用户有同一IP(当然,同一个局域网里也有同一IP,但我们认为这个局域网是用户可以控制的。网吧内并不推荐使用这一功能)。

c)如果(IP不同 &&没有用口令登录),那么,“登录token”就会在多个IP间发生变化(登录token在两个或多个ip间被来来回回的变换),当在一定时间内达到一定次数后,系统才会真正觉得被盗用的可能性很高,此时系统在后台清除“登录序列”和“登录token“,让Cookie失效,强制用户输入口令(或是要求用户更改口令),以保证多台设备上的cookie一致。

不要让cookie有权限访问所有的操作。否则就是XSS攻击,这个功能请参看新浪微博的XSS攻击。下面的这些功能一定要用户输入口令:
1)修改口令。

2)修改电子邮件。(电子邮件通过用来找回用户密码)

3)用户的隐私信息。

4)用户消费功能。

权衡Cookie的过期时间。如果是永不过期,会有很不错的用户体验,但是这也会让用户很快就忘了登录密码。如果设置上过期期限,比如2周,一个月,那么可能会好一点,但是2周和一个月后,用户依然会忘了密码。尤其是用户在一些公共电脑上,如果保存了永久cookie的话,等于泄露了帐号。所以,对于cookie的过期时间我们还需要权衡。
找回口令的功能

找回口令的功能一定要提供。但是很多朋友并不知道怎么来设计这个功能。我们有很多找回口令的设计,下面我逐个点评一下。

千万不要使用安全问答。事实证明,这个环节很烦人,而且用户并不能很好的设置安全问答。什么,我的生日啊,我母亲的生日,等等。因为今天的互联网和以前不一样了,因为SNS,今天的互联比以前更真实了,我可以上facebook,开心,人人网,LinkedIn查到你的很多的真实的信息。通过这些信息我可以使用安全问答来重设你的口令。这里需要说一下 Facebook,Facebook的安全问答很强大,还要你通过照片认人,呵呵。
不要重置用户的密码。因为这有可能让用户的密码遭到恶意攻击。当然,你要发个邮件给用户让其确认,用户点击邮件中的一个链接,你再重置。我并不推荐这样的方法,因为用户一般都会用笔记下来这个很难记的口令,然后登录系统,因为登录系统时使用了“记住密码”的功能,所以导致用户不会去修改密码,从而要么导到被写下来的密码被人盗取,要么又忘记了密码。
好一点的做法——通过邮件自行重置。当用户申请找回口令功能的时候,系统生成一个MD5唯一的随机字串(可通过UID+IP+timestamp+随机数),放在数据库中,然后设置上时限(比如1小时内),给用户发一个邮件,这个连接中包含那个MD5的字串的链接,用户通过点击那个链接来自己重新设置新的口令。
更好一点的做法——多重认证。比如:通过手机+邮件的方式让用户输入验证码。手机+邮件可能还不把握,因为手机要能会丢了,而我的手机可以访问我的邮箱。所以,使用U盾,SecureID(一个会变化的6位数token),或是通过人工的方式核实用户身份。当然,这主要看你的系统的安全级别了。
口令探测防守

使用验证码。验证码是后台随机产生的一个短暂的验证码,这个验证码一般是一个计算机很难识别的图片。这样就可以防止以程序的方式来尝试用户的口令。事实证明,这是最简单也最有效的方式。当然,总是让用户输入那些肉眼都看不清的验证码的用户体验不好,所以,可以折中一下。比如Google,当他发现一个IP地址发出大量的搜索后,其会要求你输入验证码。当他发现同一个IP注册了3个以上的gmail邮箱后,他需要给你发短信方式或是电话方式的验证码。
用户口令失败次数。调置口令失败的上限,如果失败过多,则把帐号锁了,需要用户以找回口令的方式来重新激活帐号。但是,这个功能可能会被恶意人使用。最好的方法是,增加其尝试的时间成本(以前的这篇文章说过一个增加时间成本的解密算法)。如,两次口令尝试的间隔是5秒钟。三次以上错误,帐号被临时锁上30秒,5次以上帐号被锁1分钟,10次以上错误帐号被锁4小时……
系统全局防守。上述的防守只针对某一个别用户。恶意者们深知这一点,所以,他们一般会动用“僵尸网络”轮着尝试一堆用户的口令,所以上述的那种方法可能还不够好。我们需要在系统全局域上监控所有的口令失败的次数。当然,这个需要我们平时没有受到攻击时的数据做为支持。比如你的系统,平均每天有5000次的口令错误的事件,那么你可以认为,当口令错误大幅超过这个数后,而且时间相对集中,就说明有黑客攻击。这个时候你怎么办?一般最常见使用的方法是让所有的用户输错口令后再次尝试的时间成本增加。
最后,再说一下,关于用户登录,使用第三方的 OAuth 和 OpenID 也不失为一个很不错的选择。

打造一个安全的用户名密码登陆系统

很多的网络应用都有基于用户名密码的登录功能,而绝大多数的登录都毫无安全性可言,不夸张的说,大多数的程序员根本不知道怎样去保证用户名和密码的安全。

安全的标准

要想一个登录系统安全,至少要保证以下几个方面。

原始密码的安全

很多人对于用户的原始密码安全,还停留在不被非法第三方获取的层面上,但实际上,原始 密码的最大威胁,往往来自于系统的开发人员和服务器的管理人员。这些人可能是有意收集,也可能是无意泄露,往往是用户原始密码的泄露的罪魁祸首。在构建登 录系统的时候,应该从根本上避免,做到只有用户自己和键盘记录器才知道原始密码。

那如何做到这一点呢?首先一点就是一定要在客户端进行密码加密,这可以使得后端拿到的密码已经是加过密的,一来服务器接触不到原始密码,二来就算通信被监听,第三方就算拿到了可以用来登录的客户端加密密文,也无法获知用户的原始密码。

哈希:不可逆加密

密码加密不同于普通的加密,一是内容重要,二是密码的验证根本不需要原文,要检查一个密码是否正确,只需要看它加密的结果与正确的密码加密的结果是否一致即可。确定了这两点,对于加密的方法,就只要求同一个字符串加密后会得到同样的密文。哈希完全满足了这一要求。

在哈希算法中,首选是 SHA2 系列,虽然安全由于 SHA1 的原因而被质疑,但至少目前还没有证明有什么纰漏。MD5 由于用得太多,而且彩虹表实在过于泛滥,并不推荐使用。

另外一个问题,哈希一遍是不是就够了呢?当然不,不仅要多次哈希,而且还要与用户名一类的数据混加,比如,可以使用下面的方式来在客户端加密原始密码:

1
2
3
sha256(
  sha265(sha265(password)) + sha265(username)
)

这样,不仅可以增加密文反推原文的难度,还加入用户名,使得就算密码相同,不同用户的密文也完全不一样。

在客户端的加密,基本上也就只能到这一步了,因为一个最主要的问题是,客户端的加密算法是公开的。

盐:混入随机数据

虽然在客户端对密码进行了加密,但无论是算法,还是混入的用户名,都是公开了的。剩下的加密,就需要留给后端了。

由于对同一字符串进行哈希的结果是恒定的,所以知道了算法和密文,理论上是可以反推出密码的,反推的难度取决于用户原始密码的复杂度。那如何才能够让反推的难度指数级增大呢?答案是在原始密码密文的基础之上,再加入一个随机字符串,从而达到让用户的密码更复杂的效果。这个随机字符串,便是盐。

后端获取到客户端传来的密码之后,再通过加盐哈希进行再加密。比如像下面这样:

1
2
3
sha256(
  sha256(username + sha256(password + salt)) + salt + sha256(username + salt)
)

注意,盐的保存非常关键,务必将它与用户信息分开存放。

密文和盐的更新与不可追溯

现在密码已经分别在客户端和后端多次哈希,还加了盐,好像已经很安全了。但其实,我们还可以更安全。那就是经常变更盐,让用户信息表中的密文字段值也经常变化。这样,除非同时拿到用户信息和盐,否则依然无效。

那什么时候变更盐和密文呢?由于后端是不存储客户端哈希的密文的,所以只有在登录的时候,才能够进行盐和密文的修改。

用户名本身可以加密吗?

这个想法好像有点不靠谱,但实际上,用户名如果只是作为单纯的登录凭证,其实是可以像密码一样加密的。因为无论是注册、登录还是找回密码,都不需要用户名的原文。但注意,用户名只能哈希,不能加盐,否则就没什么依据去找盐了。

用户名的哈希可以分两部分,一是客户端哈希,到了服务器端,可以进行再次哈希。

在本文的 Demo 中,将不对用户名哈希。

通信的安全

在应用层面基本上已经很安全了。接下来就是客户端和通信的安全。客户端的环境基本不可控,所以只能在通信的安全上想办法了。不过其实也不用想什么多的办法,直接使用 HTTPS 就行了。

登录流程

上面总结了怎样保证一个用户名密码登录系统的安全,这里再来看看一个满足上述要求的登录系统的登录流程。注册流程相对来讲简单一些,所以就不再详细介绍。

Demo 是一个简单的 Web 用户名密码登录系统,代码示例也取自于它。

浏览器登录

浏览器主要完成以下工作:

  • 获取用户输入的用户名及密码
  • 通过输入的用户名和密码,进行哈希,得到浏览器端密文
  • 将用户名和密文提交给后端

主要代码如下,取自 client/app.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 密码与用户名的哈希
function encryptPwd(username, password) {
  username = username.toLowerCase();
  return sha256(
    username + sha256 (
      sha256(sha256(sha256(password))) + sha256(username)
    )
  );
}

$scope.login = function(){
  // 检查用户名和密码的合法性,比如是否输入,长度是否足够等
  if($scope.check()) {
    return;
  }
  $scope.successMessage = '';
  $scope.errorMessage = '';
  $scope.status = 'loading';
  // 向后端提交登录请求
  $resource('/user/login')
  .save({
    username: $scope.username,
    password: encryptPwd($scope.username, $scope.password)
  }, function(res){
    $scope.status = 'done';
    $scope.successMessage = 'login successful!';
  }, function(reason){
    $scope.status = 'done';
    $scope.errorMessage = reason.data || 'failed';
  });
};

后端密码验证

后端的验证流程如下:

  • 获取前端提交的用户名及浏览器端密文
  • 根据用户名,在数据库中查询出对应的盐 id
  • 通过盐 id 取出对应的盐,再通过用户名、浏览器端密文和盐算出后端密文
  • 根据用户名和后端密文到用户表查询,如果有结果,则表明登录信息正确,返回给浏览器登录成功的响应
  • 生成新的盐,算出新的后端密文,并将两者更新到数据库中

实现的代码如下,取自 app/controllers/user.server.controller.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
function encryptPwd(usr, pwd, salt){
  usr = usr.toLowerCase();
  return sha256(
    sha256(usr + sha256(pwd + salt)) + salt + sha256(usr + salt)
  )
}

function login(req, res, next){
  // 用户名密码获取和检查已省略
  // 根据用户名,获取盐 id
  req.models.user
  .findOne({select:['username', 'saltId'], where: {username: username}})
  .exec(function(err, userDoc){
    if(err) return next(err);
    if(!userDoc) return next(new Error('username not exists'));

    // 取盐
    req.models.salt
    .findOne({id: userDoc.saltId})
    .exec(function(err, saltDoc){
      if(err) return next(err);
      if(!saltDoc) return next(new Error('can NOT find salt'));

      // 根据用户名、密码和盐推算出密文
      var pwdHash = encryptPwd(username, password, saltDoc.salt);
      // 在数据库中核对用户名和密文
      req.models.user
      .findOne({select: ['id'], where: {username: username, password: pwdHash }})
      .exec(function(err, doc){
        if(err) return next(err);
        if(!doc) return next(new Error('password error'));

        res.json({
          username: username
        });

        return updateSalt(saltDoc, userDoc, password, next);
      });
    });
  });
}

盐与密文的更新

前面返回给用户成功登录的响应之后,调用了更新盐和密文的方法,该方法具体流程如下:

  • 生成并存储新盐
  • 根据新盐、用户名和浏览器端密文,生成新的后端密文
  • 存储后端密文到用户信息表

实现如下,取自 app/controllers/user.server.controller.js

1
2
3
4
5
6
7
8
9
10
11
function updateSalt(saltDoc, userDoc, passwordInputed, next){
  saltDoc.salt = Math.random().toString(15).substr(3, 27);
  saltDoc.save(function(err){
    if(err) return next(err);
    userDoc.password = encryptPwd(userDoc.username, passwordInputed, saltDoc.salt);
    userDoc.save(function(err){
      if(err) return next(err);
      return next();
    });
  });
}

Demo

Demo 托管在 Github 上。前端采用 AngularJS + Bootstrap ,后端使用 Node.js + Express + MongoDB ,是一个典型的 MEAN 应用 。

数据存储这块,使用了 Waterline 这个 ORM 中间件(以前也曾经写过两篇介绍文章,可供参考:Node.js ORM 数据操作中间件 Waterline在 Express 项目中使用 Waterline)。使用它的目的主要是为了将用户信息和盐存储到不同的地方。本例中将盐用 sails-disk 存储到了文件中,用户信息用 sails-mongo 存储到了 MongoDB 中。

1
2
3
4
5
git clone https://github.com/stiekel/safe-username-password-login.git
cd safe-username-password-login
npm i
npm i -g gulp
gulp

再在浏览器中打开 http://localhost:7102/ 即可。

 

https://chensd.com/2016-08/Safe-username-password-Login-System.html?utm_source=tuicool&utm_medium=referral

 

展开阅读全文
加载中

作者的其它热门文章

打赏
0
1 收藏
分享
打赏
0 评论
1 收藏
0
分享
返回顶部
顶部