Better

Ethan的博客,欢迎访问交流

Node与Express开发之安全篇

现在大多数网站和应用程序都会有某种安全方面的需求。如果你允许人们登录,或者储存了个人身份信息(PII) ,就要给网站实现某种安全机制。本章重点学习HTTPS(HTTP Secure,这是建立安全网站的基础),以及认证机制,并重点讨论第三方认证。

HTTPS

使用 HTTPS 是提供安全服务的第一步。互联网的本质决定了第三方有可能截取客户端和服务器端之间传输的数据包。HTTPS 会对那些包进行加密,让攻击者极难访问到所传输的信息。

你可以把 HTTPS 当作确保网站安全的基础。它不提供认证,但为认证奠定了基础。安全的强度取决于整个体系中最弱的一环,而其中第一环就是网络协议。

HTTPS 协议基于服务器上的公钥证书,有时也叫 SSL 证书。SSL证书目前的标准格式是X.509。证书背后的思想是由证书颁发机构(CA)发行证书。CA让浏览器厂商能访问受信根证书。在你安装浏览器时,其中就包含这些受信根证书,并靠它们建立起 CA 和浏览器之间的信任链。要用这个信任链,你的服务器必须使用由 CA 颁发的证书。

结果是要提供 HTTPS,则需要有来自 CA 的证书,那怎么才能得到这样的证书呢?大体上有三种途径:你可以自己生成,也可以从免费 CA 那里获取,或者从商业 CA 那里买一个。

获取证书

生成自己的证书

生成证书很容易,但一般只适用于开发和测试用途(还有可能是部署在内网中) 。由于 CA 确立起来的层级性,浏览器只信任由已知 CA(并且那个可能不是你)生成的证书。如果你的证书来自浏览器不知道的 CA,浏览器会用非常惊悚的语言警告你,说你正在用一个未知(因此是不可信的)实体建立安全连接。这在开发和测试过程中没什么问题:你和你的团队知道你们是自己生成的证书,并且你知道浏览器会这样。如果你把这样一个网站部署到生产环境中让公众访问,他们会成群结队地离开的。

  1. 安装OpenSSL
  2. 命令生成
    openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout xxx.pem -out xxx.crt
    

它会问你一些细节信息,比如国家代码、城市、省(州) 、全限定域名(FQDN) 、邮件地址等。既然这个证书是用于开发 / 测试的,你怎么回答关系不大(实际上它们都是可选的,但不回答会让浏览器觉得这个证书更可疑)。通用名(FQDN)是浏览器用来识别域名的。所以如果你用的是本地服务器,可以用它做 FQDN,或者用服务器的 IP 地址,或者服务器名,如果可用的话。即便通用名和你用在 URL 中的域名不一致,也仍然可以加密,但浏览器会就此差异给你一个额外的警告。

这个命令会生成两个文件:xxx.pem 和 xxx.crt。PEM(Privacy-enhanced Electronic Mail,保密增强电子邮件)文件是你的私钥,不应该让客户端访问到。CRT 文件是自签名证书,会发送给浏览器建立安全链接。

使用免费的证书颁发机构

HTTPS 是基于信任的, 而在互联网上获得信任最简单的方式是购买它,这是个令人不爽的现实。建立安全基础设施、投保证书,以及维护跟浏览器厂商之间的关系都很昂贵。因此一分钱一分货,免费证书一般无法得到主流浏览器的支持。

购买证书

首先你要搞清楚,10 美元的证书跟 1500 美元的证书在加密级别上没有任何差别。给出高价格的 CA 希望你最好不知道这一点:他们的销售会用尽一切办法混淆这一事实的。

选择证书厂商时会考虑下面四点

  1. 客户支持
  2. 避免链式根证书
    证书链很普遍,也就是说你实际上要请求多个证书来建立安全连接。链式证书会导致额外的安装工作,因此我会多花点儿钱购买依赖于单个根证书的证书。一般很难(或不可能)确定你得到的是什么,这是寻找良好客户支持的另一个原因。如果你询问根证书是否是链式的,而他们不能或者不愿告诉你,你应该到别处看看。
  3. 单域证书、多子域证书、多域证书及通配证书
    • 单域证书:仅支持某个特定域名
    • 多子域证书:支持多个子域名,但是在购买时必须指出是哪些域名
    • 通配证书:能作用于任何子域名,并且不需要指出子域名是什么
    • 多域名证书:支持整个多域名,比如说,你可以有 xxx.com、xxx.us、xxx.com 和 www 的变体
  4. 域证书、组织证书和扩展验证证书
    • 域证书:只是证明你是在用你自己所认为的域名做业务。
    • 组织证书:在某种程度上为你在打交道的真正组织提供保证。
    • 扩展认证证书,这是 SSL 证书中的劳斯莱斯。
    • 我推荐你用不太昂贵的域名证书或者用扩展验证证书。组织证书尽管会证实组织的存在,但在浏览器中显示时没有任何差别,所以按我的经验,除非用户真去检查证书(非常罕见) ,否则它和域名证书没有明显的不同。而另一方面,扩展验证证书一般会向用户出示一些线索,表明他们所做的业务是合法的(比如 URL 栏是绿色的,并且组织名称显示在 SSL 图标旁边) 。

买了证书后,你就可以进入一个安全区域下载你的私钥和证书(你可能要仔细检查下载链接本身是通过HTTPS协议的:通过一个未经加密的通道传输私钥是不明智的!)。别理那些想要通过邮件给你发私钥的证书厂商:邮件不是安全渠道。私钥的标准扩展名是 .pem,有时是 .key。证书的扩展名有 .crt、.cer 或 .der(证书的格式叫作“特异编码规则” ,即Distinguished Encoding Rules 或 DER,因此 .der 扩展名不太常见) 。

Express启用HTTPS

切换到 HTTPS 很简单。我建议你把私钥和 SSL 证书放在 ssl 子目录下(尽管放在项目根目录下的情况十分常见) 。然后用 https 模块代替 http 模块,把 options 对象传给createServer 方法就可以了:

var https = require('https'); // 一般在文件顶部
var options = {
    key: fs.readFileSync(__dirname + '/ssl/meadowlark.pem');
    cert: fs.readFileSync(__dirname + '/ssl/meadowlark.crt');
};
https.createServer(options, app).listen(app.get('port'), function(){
    console.log('Express started in ' + app.get('env') +
        ' mode on port ' + app.get('port') + '.');
});

HTTPS和代理

正如我们所看到的那样,在 Express 中使用 HTTPS 非常容易,对于开发来说它工作得很好。然而当你想要扩展网站来处理更多流量时,你会想要使用 Nginx 这样的代理服务器

如果用了代理服务器,客户端(用户的浏览器)会跟代理服务器通信,不是直接连到你的服务器上。然后代理服务器很可能通过常规的 HTTP 跟你的应用通信(因为你的应用和代理服务器都运行在同一个可信网络中) 。你经常会听到有人说 HTTPS 止于代理服务器。

在大多数情况下,只要你或你的托管服务提供商正确配置好代理服务器,让它处理 HTTPS请求,你就不需要做任何额外的工作了。但如果你的应用程序需要同时处理安全和非安全请求则是个例外。

这个问题有两种解决办法。第一种是简单地将代理配置成所有 HTTP 请求都重定向到HTTPS,本质上是强制所有跟你的应用程序的通信都通过 HTTPS。这种方式越来越常见 了,并且肯定是一种简单的解决方案。

第二种方式在某种程度上是将客户端 - 代理所用的通信协议发给你的服务器。最常用的办法是通过 X-Forwarded-Proto 头。比如在 Nginx 中设定这个请求头:

proxy_set_header X-Forwarded-Proto $scheme;

然后在你的应用中检查用的是不是 HTTPS 协议:

app.get('/', function(req, res) {
    // 下面这行代码本质上等同于: if(req.secure)
    if(req.headers['x-forwarded-proto']==='https') {
        res.send('line is secure');
    } else{
        res.send('you are insecure!');
    }
});

Express 提供了一些便利的属性,在你使用代理时改变行为(十分正确) 。不要忘了用 app.enable('trust proxy') 告诉 Express 要相信代理。一旦你这样做了, req.protocol 、 req.secure 和 req.ip 将会指向客户端到代理的连接,不是到你的应用的。

跨站请求伪造

跨站请求伪造(CSRF)攻击利用了用户一般都会相信浏览器并且在同一个会话中访问多个网站这样的事实。在 CSRF 攻击中,恶意站点上的脚本会请求另外一个网站:如果你在另一个网站上登录过,恶意网站可以成功访问那个网站上的安全数据。

要防范CSRF攻击,你必须想办法确保请求合法地来自你的网站。我们的做法是给浏览器传一个唯一的令牌。当浏览器提交表单时,服务器会进行检查,以确保令牌是匹配的。csurf中间件负责令牌的创建和验证;你只需要确保令牌包含在到服务器的请求中。

// 这个必须放在 cookie-parser 和 connect-session 的引入之后
app.use(require('csurf')());
app.use(function(req, res, next){
    // csurf 中间件添加了 csurfToken 方法到请求对象上。
    res.locals._csrfToken = req.csrfToken();
    next();
});

现在你所有的表单(以及 AJAX 调用)都必须提供一个叫作 _csrf 的域,它必须跟生成的令牌相匹配。中间件 csurf 会处理剩下的工作:如果 body 中的域没有有效的 _csrf 域,它会引发一个错 误(确保你的中间件里有错误路由!)。

认证

认证是一个复杂的大课题。可惜大多数真正的 Web 应用程序都少不了认证部分。我能给你的最重要的经验是别试图自己做这个。如果你的名片上没有“安全专家”这样的头衔,可能还不清楚设计一个安全认证系统需要怎样复杂周密的思考

认证与授权

认证(authentication)是指验证用户的身份,即他们是自己所宣称的人。授权(authorization)是指确定用户有哪些权力,可以访问、修改或查看什么。

一般来说(但不总是这样) ,先认证,然后确定授权。授权可能非常简单(授权 / 没有授权) 、宽泛(用户 / 管理员) ,或非常细化,指定不同账号类型的读、写、删除和更新权限。授权系统的复杂性取决于你所写的应用程序类型。

密码

密码的问题在于每一个安全系统的强度取决于它最弱的环节。密码是要求用户提供的——这就是最弱的一环。

人类是不善于想出安全密码的。一些简单的密码极容易被破解,如果密码被破解,最终它一定会变成你的问题,应用设计者对此无能为力。然而,你可以做些事情来提升用户密码的安全性。一种做法是推掉责任,交给第三方做认证。另一种做法是把你的登录系统做得对密码管理服务更加友善。

第三方认证

互联网上几乎每个人都至少有一个主流服务的账号,第三方认证正是借助了这一点。所有这些服务都提供了一种通过它们的服务认证和识别用户的机制。

第三方认证有三个主要的优势:

  1. 认证负担降低了
  2. 减轻“密码疲劳”
  3. 第三方认证“没有摩擦”

如果你不用密码管理器,一般就是在大部分网站上使用相同的密码(大多数人都有个“安全的”密码用在银行之类的地方,还有一个“不安全的”密码用在其他所有地方) 。这种方式有个问题,即你用的那些网站中只要有一个被攻破了,你的密码就暴露了,黑客会尝试用相同的密码访问其他服务。

第三方认证有一个明显的缺点就是第三方混乱,如果用户用 Facebook 注册了你的服务,一段时间后。当面对一个有着 Facebook、Twitter、谷歌或 LinkedIn 登录选择的界面时,用户很可能已经忘了原来注册用的是哪个服务了。这是第三方认证的一个缺点,并且你对此几乎做不了什么。这是要求用户提供邮箱地址的另一个好理由:这样你就可以让用户通过邮箱找回账号,并且发送一封邮件给那个地址说明当初是用哪个服务认证的。

如果你觉得自己对用户所用的社交网络有充分的认识,可以提供一个“主认证服务”来缓解这个问题。比如,如果你十分肯定你的大部分用户都有 Facebook 账号,你可以在网站上放一个大按钮,写上“用 Facebook 登录” 。然后用比较小的按钮,甚至只是用文本写上“或者用谷歌、Twitter 或 LinkedIn 登录” 。这种方式可以减少第三方混乱情况出现的次数。

存储用户信息

不管你是否依赖第三方认证用户,你都会想要在自己的数据库中保存一份用户记录。比如,你用 Facebook 做认证,那只是证实了用户的身份。如果你需要保存针对那个用户的配置信息,不可能也用 Facebook,你必须把跟那个用户相关的信息保存在你自己的数据库中。

这里存储authId是需要注意:因为我们用了多个认证策略,所以为了防止冲突,ID 是策略类型和第三方 ID 的组合。比如说,一个 Facebook 用户的 authId 是 facebook:525764102 ,而一个 Twitter 用户的 authId 是 twitter:376841763 。

Passport

Passport 是为 Node/Express 做的认证模块,非常健壮,也非常流行。它没有绑死在任何认证机制上,而是基于可插拔认证策略的思想(如果你不想用第三方认证,它也有本地策略) 。

用第三方认证要明白的重要细节是你的应用绝对不会收到密码。完全是由第三方处理的。这是好事:第三方承担了安全处理和密码存储的重担

具体流程:

  1. 登录页
  2. 构建认证请求
    • 你要构造一个发送给第三方的请求(通过重定向) 。这个请求的细节比较复杂,并且是专门针对这个认证策略的。Passport(和策略插件)会完成所有繁重的工作。
    • 可以在这一步里向第三方授权机制请求更多信息。记住,你请求的用户信息越多,他们越不愿意给你的应用程序授权。
  3. 证实认证响应
    • 用户授权了你的应用程序,你就会从第三方得到一个有效的认证响应,即用户身份的证据。复杂的校验细节还是由 Passport(及策略插件)处理。
    • 如果认证响应表明用户没有授权(如果用户输入了无效的凭证,或者用户没有给你的应用程序授权) ,你会被重定向到一个合适的页面
    • 在认证响应中会有用户在第三方的唯一 ID,以及你在第二步中请求的所有细节。
    • 要完成第四步,我们必须“记住”用户是授权过的。一般是设定一个包含用户 ID 的会话变量,表明这个会话已经经过授权了
  4. 证实授权
    • 在第三步中,我们在会话中保存了用户 ID。有了用户 ID,我们就可以从数据库中获取用户对象,得到其中包含的用户授权信息。
    • 我们自己有包含认证规则的自有 User 对象(如果没有那个对象,表明请求没有授权,我们可以转到登录或“未经授权”页) 。

搭建*Passport(以facebook为例)

  1. 创建facebook应用
  2. 应用与域名关联
  3. 配置好应用后,你需要它的唯一 ID 和密钥
  4. 安装Passport和Facebook认证策略:npm install --save passport passport-facebook
  5. 创建auth.js,构建基础代码
    var User = require('../models/user.js'),
     passport = require('passport'),
     FacebookStrategy = require('passport-facebook').Strategy;
    passport.serializeUser(function(user, done){
     done(null, user._id);
    });
    passport.deserializeUser(function(id, done){
     User.findById(id, function(err, user){
         if(err || !user) return done(err, null);
         done(null, user);
     });
    });
    
    实现了这两个方法后,只要有活跃的会话,并且用户成功通过认证, req.session.passport.user 就会对应上 User 模型的实例。
  6. 为了启用 Passport 的功能,我们需要做两件事:初始化Passport并注册处理认证以及从第三方认证服务重定向的回调的路由。

    module.exports = function(app, options){
     // 如果没有指定成功和失败的重定向地址,设定一些合理的默认值
     if(!options.successRedirect)
         options.successRedirect = '/account';
     if(!options.failureRedirect)
         options.failureRedirect = '/login';
     return {
         init: function() { 
             var env = app.get('env');
             var config = options.providers;
             // 配置 Facebook 策略
             passport.use(new FacebookStrategy({
                 clientID: config.facebook[env].appId,
                 clientSecret: config.facebook[env].appSecret,
                 callbackURL: '/auth/facebook/callback',
             }, function(accessToken, refreshToken, profile, done){
                 //  profile有 Facebook 用户的信息
                 var authId = 'facebook:' + profile.id;
                 User.findOne({ authId: authId }, function(err, user){
                     if(err) return done(err, null);
                     // 存在直接返回,会调用serializeUser,由它把 MongoDB ID 放到会话中
                     if(user) return done(null, user);
                     // 如果没有返回用户记录,我们会创建一个新的 User 模型并把它存到数据库中。
                     user = new User({
                         authId: authId,
                         name: profile.displayName,
                         created: Date.now(),
                         role: 'customer',
                     });
                     user.save(function(err){
                         if(err) return done(err, null);
                         done(null, user);
                     });
                 });
             }));
             app.use(passport.initialize());
             app.use(passport.session());
         },
         registerRoutes: function() {
             // 注册 Facebook 路由
             app.get('/auth/facebook', function(req, res, next){
                 passport.authenticate('facebook', {
                     callbackURL: '/auth/facebook/callback?redirect=' + encodeURIComponent(req.query.redirect),
                 })(req, res, next);
             });
    
             app.get('/auth/facebook/callback',passport.authenticate('facebook',{ failureRedirect: options.failureRedirect },function(req, res){
                 // 只有认证成功才能到这里
                 res.redirect(303, req.query.redirect || options.successRedirect);
             }
             ));
         },
     };
    };
    

    省略 redirect 查询字符串参数能简化认证路由。如果你只有一个 URL 需要认证,这可能比较有诱惑性。然而实现这个功能最终还是方便,并且能提供更好的用户体验。毫无疑问,你之前肯定有过这样的体验:你发现了自己想要的页面,然后发现需要登录。你登录了,然后被重定向到默认页,你只能自己再退回到原来那个页面上。这种用户体验无法令人满意。

  7. 在探讨 init 和 registerRoutes 方法的细节之前,我们先看看将会如何使用这个模块(希望那样能让返回一个返回对象的函数这件事更清楚一点儿) :
    var auth = require('./lib/auth.js')(app, {
     providers: credentials.authProviders,
     successRedirect: '/account',
     failureRedirect: '/unauthorized',
    });
    // auth.init() 链入了 Passport 中间件:
    auth.init();
    // 现在可以指定我们的 auth 路由了:
    auth.registerRoutes();
    

基于角色的授权

在Express中,记住,在单个路由中,你可以有多个函数,它们是按顺序调用的。因此实现基于角色的授权十分简单。我们是需要定义多个函数处理器即可。

例如员工与客户:

function customerOnly(req, res){
    var user = req.session.passport.user;
    if(user && req.role==='customer') return next();
    res.redirect(303, '/unauthorized');
}

function employeeOnly(req, res, next){
    var user = req.session.passport.user;
    if(user && req.role==='employee') return next();
    // 调用 next('route') 不是执行路由中的 next 处理器,它会跳过这个路由。
    next('route');
}

app.get('/account', customerOnly, function(req, res){
    res.render('account');
});

app.get('/sales', employeeOnly, function(req, res){
    res.render('sales');
});

// 更具灵活性,创造性当然看自己啦
function allow(roles) {
    var user = req.session.passport.user;
    if(user && roles.split(',').indexOf(user.role)!==-1) return next();
    res.redirect(303, '/unauthorized');
}

发现两者的不同没,针对客户,如果未授权,直接跳转到未授权页面,但是针对员工并没有这么做,因为跳转到未授权页面间接的告诉用户我们存在这个页面,只是你没有权限,但针对员工的话,这么处理无疑会给攻击者一个可乘之机。我们甚至不想让外人知道有这样一个地址,即便他们不小心访问了这个地址。所以为了增加一点儿安全性,当非员工访问 /sales 页面时,我们想让他们看到常规的 404 页面,让潜在的攻击者无从下手。

添加更多认证提供者

现在我们的框架已经搭好了,添加更多的认证服务提供者很容易。比如我们想用谷歌认证。Google的API添加到init和registerRoutes方法中。

更多



留言