JS 中授权的三种方式
向服务端提供身份,来获得非公开资源的授权是大多数应用的基本需求。为用户提供资源授权有很多种方式,比如 token、session、cookie,还有运用广泛的 OAuth2.0。
为了实现这一应用,服务端需要提供 4 个路由:注册、登录、登出和修改密码。用户凭证在登录后生成,在修改密码中使用。
前端相应的分为注册、登录、修改密码三个 page。
Session 授权
实现原理
众所周知,Session 对象存储特定用户会话信息,有两种方式实现 session 验证:
- 服务端在用户登录成功后生成一个 sessionID,标识当前绘画用户,以后每次请求都会包含 sessionID,利用这个 ID 验证用户身份并授权。
- sessionID 和其他数据加密后传输给客户端,客户端存储数据(使用cookie)并在每次请求中伴随发送,以完成验证效果。
express-session API:
使用上述 session 的第一种机制。
express-session 主要的 API:
**session( options )**:生成 session 中间件,使用这个中间件会在当前会话中创建 session,session 数据将会被保存在服务端,而 session ID 会保存在 cookie。options 为传入的配置参数,有以下这些参数:
- cookie: 存储 session ID, 默认值 { path: ‘/‘, httpOnly: true,secure: false, maxAge: null })
- genid: 一个函数,返回一个字符串用来作为新的 session ID,传入 req 可以按需在 req 上添加一些值。
- name: 存储 session ID 的 cookie 的名字,默认是’connect.sid’,但是如果有多个使用 express-session 的 app 运行在同一个服务器主机上,需要用不同的名字命名 express-session 的 cookie。
- proxy : 当设置了secure cookies(通过”x-forwarded-proto” header )时信任反向代理。
- resave: 强制保存会话,即使会话在请求期间从未被修改过
- rolling: 强制在每次响应时,都设置保存会话标识符的cookie。cookie 到期时间会被重置为原始时间 maxAge。默认值为
false
。 - saveUninitialized: 默认
true
, 强制存储未初始化的 session。 - secret ( 必需 ): 用来对session ID cookie签名,可以提供一个单独的字符串作为 secret,也可以提供一个字符串数组,此时只有第一个字符串才被用于签名,但是在 express-session 验证 session ID 的时候会考虑全部字符串。
- store: 存储 session 的实例。
- unset: 控制 req.session 是否取消。默认是
keep
,如果是destroy
,那么 session 就会在响应结束后被终止。
req.session:这是 express-session 存放 session 数据的地方,注意,只有 session ID 存储在 cookie,所以 express-session 会自动检查 cookie 中的 session ID ,并用这个 session ID 来映射到对应的 session 数据,所以使用 express-session 时我们只需读取 req.session ,express-session 知道应该读取哪个 session ID 标识的 session 数据。
- 可以从 req.session 读取 session : req.session.id:每一个 session 都有一个唯一ID来标识,可以读取这个ID,而且只读不可更改,这是 req.sessionID 的别名; req.session.cookie:每一个 session 都有一个唯一 的cookie来存储 session ID,可以通过 req.session.cookie 来设置 cookie 的配置项,比如 req.session.cookie.expires 设置为 false ,设置 req.session.cookie.maxAge 为某个时间。
- req.session 提供了这些方法来操作 session: req.session.regenerate( callback (err) ): 生成一个新的 session, 然后调用 callback; req.session.destroy( callback (err) ): 销毁 session,然后调用 callback; req.session.reload( callback (err) ): 从 store 重载 session 并填充 req.session ,然后调用 callback; req.session.save( callback (err) ): 将 session 保存到 store,然后调用 callback。这个是在每次响应完之后自动调用的,如果 session 有被修改,那么 store 中将会保存新的 session; req.session.touch(): 用来更新 maxAge。
req.sessionID:和 req.session.id 一样。
store:如果配置这个参数,可以将 session 存储到 redis和mangodb 。store 提供了以下方法来操作 store:
- store.all( callback (error, sessions) ) : 返回一个存储store的数组;
- store.destroy(sid, callback(error)): 用session ID 来销毁 session;
- store.clear(callback(error)): 删除所有 session
- store.length(callback(error, len)): 获取 store 中所有的 session 的数目
- store.get(sid, callbackcallback(error, session)): 根据所给的 ID 获取一个 session
- store.set(sid, session, callback(error)): 设置一个 session。
- store.touch(sid, session, callback(error)): 更新一个 session
以上就是 express-session 的全部 API。
使用
使用 express-session 是依赖于 cookie 来存储 session ID 的,而 session ID 用来唯一标识一个会话,如果要在一个会话中验证当前会话的用户,那么就要求用户前端能够发送 cookie,而且后端能够接收 cookie。
所以前端我们设置 axios 的 withCredentials = true 来设置 axios 可以发送 cookie。
后端我们需要设置响应头 Access-Control-Allow-Credentials:true,并且同时设置 Access-Control-Allow-Origin 为前端页面的服务器地址,而不能是*
。
我们可以用 cors 中间件代替设置:
1 | // 跨域 |
我开始就是因为没有设置这个,所以遇到了问题,就是后端登录接口在session中保存 用户名( req.session.username = req.body.username
) 之后,在修改用户密码的接口需要读取 req.session.username
以验证用户的时候读取不到 req.session.username
,很明显两个接口的 req.session
不是同一个 session
,果然 console 出来 的 session ID
是不同的。这就让我想到了 cookie,cookie 是生成之后每次请求都会带上并且后端可以访问的,现在存储在 cookie 中的 session ID 没有被读取到而是读取到了新 session ID,所以问题就出在后端不能拿到 cookie,也有可能是因为前端发送不出去 cookie。可是开始的时候搜索关于 session ID 读取不一致的这个问题我找不到解决办法,而且发现很多人存在同样的问题,但是没有人给出答案,现在通过自己的思考想到了解决办法,这是很多人需要避免的巨坑。
现在可以来编写前后端所有的逻辑了。关于注册的逻辑,是一个很简单的用户注册信息填写页面,它发送用户的名字和密码到后端注册接口,后端注册接口保存用户的名字和密码到数据库理。因此我在这里省略掉前端注册页面和后端注册接口,只讲前端登录页面和后端登录接口,前端修改密码页面和后端修改密码接口和登出接口。
前端登录接口:
1 | async function login(){ // 登录 |
后端登录接口:
1 | const getModel = require('../db').getModel |
前端修改密码和登出页面:
1 | // src/axios.config.js: |
后端修改密码接口:
1 | const getModel = require('../db').getModel |
sessionAuth 验证中间件:
1 | const sessionAuth = (req,res,next)=>{ |
后端登出:
1 | const router = require('express').Router() |
还需要调用 session 的中间件配置一些参数,才能在之后的中间件中用 req.session 进行存储、读取和销毁 session 的操作:
1 | // server/app.js: |
使用 JWT 授权
JWT 的原理:
首先来看看 JWT 的概念,JWT 的 token 由 头部(head)、数据(payload)、签名(signature) 3个部分组成 具体每个部分的结构组成以及JWT更深的讲解可以看看这个。其中头部(header)和数据(payload)经过 base64 编码后经过秘钥 secret的签名,就生成了第三部分—-签名(signature) ,最后将 base64 编码的 header 和 payload 以及 signature 这3个部分用圆点 . 连接起来就生成了最终的 token。
1 | signature = HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret) |
token 生成之后,可以将其发送给客户端,由客户端来存储并在以后每次请求中发送会后端用于验证用户。前端存储和发送 token 的方式有以下两种:
使用 Header.Authorization + localStorage 存储和发送 token
在 localStorage 中存储 token,通过请求头 Header 的 Authorization 字段将 token发送给后端。
这种方法可以避免 CSRF 攻击,因为没有使用 cookie ,在 cookie 中没有 token,而 CSRF 就是基于 cookie 来攻击的。虽然没有 CSRF ,但是这种方法容易被 XSS 攻击,因为 XSS 可以攻击 localStorage ,从中读取到 token,如果 token 中的 head 和 payload 部分没有加密,那么攻击者只要将 head 和 payload 的 base64 形式解码出来就可以看到head 和payload 的明文了。这个时候,如果 payload 保护敏感信息,我们可以加密 payload。
使用 cookie 存储和发送 token:
在这种情况下,我们需要使用 httpOnly 来使客户端脚本无法访问到 cookie,才能保证 token 安全。这样就避免了 CSRF 攻击。
jsonwebtoken 实现 JWT 用户授权:
主要 API:
jwt.sign(payload, secretOrPrivateKey, [options, callback]) 用于签发 token
如果有 callback 将异步的签名 token。
payload 就是我们要在 token 上装载的数据,比如我们可以在上面添加用户ID,用于数据库查询。payload可以是一个object, buffer或者string,payload 如果是 object,可以在里面设置 exp 过期时间。
secretOrPrivateKey 即包含HMAC算法的密钥或RSA和ECDSA的PEM编码私钥的string或buffer,是我们用于签名 token 的密钥,secretOrPublicKey 应该和下面 的 jwt.verify 的 secretOrPublicKey 一致。
options 的参数有:
1 | 1)algorithm (default: HS256) 签名算法,这个算法和下面将要讲的 jwt.verify 所用的算法一个一致 |
jwt.verify(token, secretOrPublicKey, [options, callback]) 用于验证 token
如果有 callback 将异步的验证 token。
token 便是我们保存在前端的token,我们将它发送给后端,后端调用 jwt.verify 并接受 token 和传入放在后端的 secretOrPublicKey 来验证 token。注意这里的 secretOrPublicKey 与之前用于签发 token 的 secretOrPublicKey 应该是同一个。
options 的参数有:
1 | 1)algorithms: 一个包含签名算法的数组,比如 ["HS256", "HS384"]. |
jwt.decode(token [, options]) 解码 token
只是解码 token 中的 payload,不会验证 token。 options 参数有:
1 | 1)json: 强制在 payload 用JSON.parse 序列化,即使头部没有声明 "typ":"JWT" |
错误码
在验证 token 的过程中可能或抛出错误,jwt.verify() 的回调的第一个参数就是 err,err 对象有一下几种类型:
- TokenExpiredError:
1 | err = { |
- JsonWebTokenError:
1 | err = { |
- NotBeforeError:
1 | err = { |
jsonwebtoken 的签名算法
HS256、HS384、HS512、RS256 等。
使用:
后端登录接口现在需要改用 JWT 来签发 token,把原来使用 express-session 的代码去掉:
1 | if(olduser.password === password){// 密码正确 |
后端给前端发回了 token,前端需要存储 token 以便于后续请求授权,可以存储在 localStorage ,在修改密码页面再取出 localStorage 中 的 token,并再 axios 发送请求之前拦截请求,在请求头的 Authorization 中带上 token:
前端存储 token:
1 | // src/pages/login.js: |
前端拦截 axios 请求,从 localStorage 中取出保存好的 token,在请求头带上 token:
1 | // src/axios.config.js: |
前端修改密码页面调用可以拦截请求的 aios 来发送修改密码的请求:
1 | // src/pages/ModifyUserInfo.js: |
后端修改密码接口调用 JWT 的用户认证中间件:
认证中间件:
1 | const JWT = require('jsonwebtoken') |
使用 OAuth 2.0 授权:
OAuth 2.0
有的应用会提供第三方应用登录,比如掘金 web 客户端提供了微信、QQ账号登录,我们可以不用注册掘金账号,而可以用已有的微信账号登录掘金。看看用微信登录掘金的过程:
step1: 打开掘金,未登录状态,点击登录,掘金给我们弹出一个登录框,上面有微信、QQ登录选项,我们选择微信登录;
step2: 之后掘金会将我们重定向到微信的登录页面,这个页面给出一个二维码供我们扫描,扫描之后;
step3: 我们打开微信,扫描微信给的二维码之后,微信询问我们是否同意掘金使用我们的微信账号信息,我们点击同意;
step4: 掘金刚才重定向到微信的二维码页面,现在我们同意掘金使用我们的微信账号信息之后,又重定向回掘金的页面,同时我们可以看到现在掘金的页面上显示我们已经处于登录状态,所以我们已经完成了用微信登录掘金的过程。
这个过程比我们注册掘金后才能登录要快捷多了。这归功于 OAuth2.0 ,它允许客户端应用(掘金)可以访问我们的资源服务器(微信),我们就是资源的拥有者,这需要我们允许客户端(掘金)能够通过认证服务器(在这里指微信,认证服务器和资源服务器可以分开也可以是部署在同一个服务上)的认证。很明显,OAuth 2.0 提供了4种角色,资源服务器、资源的拥有者、客户端应用 和 认证服务器,它们之间的交流实现了 OAuth 2.0 整个认证授权的过程。
OAuth 2.0 登录的原理,根据4中不同的模式有所不同。我们使用授权码模式学习 OAuth2.0 的登录过程。
GitHub OAuth 登录客户端
用 OAuth2.0 来使用 GitHub 账号来授权我们上面的应用,从而修改我们应用的密码。
步骤:
- 在 GitHub 上申请注册一个 OAuth application:https://github.com/settings/applications/new。 填写我们的应用名称、应用首页和授权需要的回调 URL。
- 然后GitHub 生成了 Client ID 和 Client Secret。
- 之后在原有的登录页面增加一个使用 GitHub 账号登录的入口:
这个登录入口其实就是一个指向 GitHub 登录页面的连接
1 | <a href='https://github.com/login/oauth/authorize?client_id=211383cc22d28d9dac52'> 使用 GitHub 账号登录 </a> |
用户进入上面的 GitHub 登录页面之后,可以输入自己的GitHub用户名和密码登录,然后 GitHub 会将授权码以回调形式传回之前我们设置的 http://localhost:3002/login/callback 这个页面上,比如 http://localhost:3002/login/callback?code=37646a38a7dc853c8a77, 我们可以在 http://localhost:3002/login/callback 这个路由获取 code 授权码,并结合我们之前获得的 client-id、client_secret,向https://github.com/login/oauth/access_token请求token,token 获取之后,我们可以用这个 token向 https://api.github.com/user?access_token=用户的token 请求到用户的GitHub账号信息比如GitHub用户名、头像等等。
1 | // server/routes/login.js: |
在请求到用户的GitHub信息之后,我们可以将用户头像和用户名存在cookie、里,便于发送给前端在页面上显示出来,告诉用户他已经用GitHub账号登录了我们的客户端。 同时,我们把GitHub用户名存到我们自己的数据库里,并给一个‘123’简单的初始化密码,后面用户可以在获得权限后修改密码。
接下来,我们使用GitHub登录后,我们需要获得授权以修改我们的密码。
我们使用和 JWT 一样的发送token的方式,前面我们从GitHub获得用户的token之后有已经用cookie的方式将其发送给前端,我们在前端可以读取cookie里的token,然后将其通过 Authorization 头方式给后端验证:
前端读取 token,并加到 Authorization 里:
1 | // OAuth2.0 |
后端验证中间件 :
1 | const axios = require('axios') |
最后
session、JWT、OAuth2.0 这三种授权方式每一种里面都会有其他方式的影子,主要是体现在用户凭证的存储和发送上,比如通常所说的基于服务端的 session,它可以把用户凭证,也就是 session ID 存储在服务端(内存或者数据库redis等),但是也是可以发给前端通过cookie保存的。JWT 可以把作为用户凭证的 token 在服务端签发后发给用户保存,可以在 localStorage 保存,同样也可以保存在 cookie 。OAuth2.0是比较复杂的一种授权方式,但是它后面获得 token 后也可以像 JWT 一样处理 token 的保存和验证来授权用户。
不管是哪种方式,都会有一些要注意的安全问题,还有性能上需要兼顾的地方。