基于Token的身份验证是无状态的,我们不将用户信息存在服务器或Session中。
这种概念解决了在服务端存储信息时的许多问题,NoSession意味着你的程序可以根据需要去增减机器,而不用去担心用户是否登录。基于Token的身份验证的过程如下:
1.用户通过用户名和密码发送请求。
2.程序验证。
3.程序返回一个签名的token 给客户端。
4.客户端储存token,并且每次用于每次发送请求。
5.服务端验证token并返回数据。
客户端收到服务器返回的 JWT,可以储存在 Cookie 里面,也可以储存在 localStorage。 此后,客户端每次与服务器通信,都要带上这个 JWT。你可以把它
1.放在 Cookie 里面自动发送
2.放在 HTTP 请求的头信息Authorization字段里面
3.JWT 就放在 POST 请求的数据体里面。
Authorization: Bearer <token>
这里我选用的是第二种方法,放在 HTTP 请求的头信息Authorization字段里面。
后端代码
这里都只贴了核心代码, 完整后端代码参考 github.com/missingone6…
完整接口文档参考代码www.showdoc.com.cn/weshare/972…
该项目需要你已经装好 mongodb(存放用户账号密码等信息)和redis(存放登录注册验证码)
该项目提供了三个 api
/api/login/login(登录)/api/login/register(注册)
使用 koa-jwt
在 index.js 中加入 koa-jwt 中间件。
import koa from 'koa';
import cors from '@koa/cors';
import compose from 'koa-compose'
import router from './routes/routes';
import JWT from 'koa-jwt';
import errorHandle from './common/errorHandle';
const jwt = JWT({
secret: 'loginfsawtonikmegrgrr3gerago',
}).unless({
// jwt白名单
path: [
/^\/public/,
/^\/api\/login/,
/^\/api\/public/,
]
});
const middleware = compose([
cors(),
errorHandle,
jwt,
])
app.use(middleware);
app.use(router())
app.listen(8000);
其中 secret 是用于加密的key。
unless() 用于设置哪些 api 是不需要通过 token 验证的。也就是我们通常说的 public api,无需登录就能访问的 api。在这个例子中,设置了 /register 和 /login 两个 api 无需 token 检查。
在使用 koa-jwt 后,所有的路由(除了 unless() 设置的路由除外)都会检查 Header 首部中的 token,是否存在、是否有效。只有正确之后才能正确的访问。否则会保存,被自定义的errorHandle中间件所捕捉到。
自定义 errorHandle中间件
使用了 koa-jwt 中间件后,如果没有token,或者token失效,该中间件会给出对应的错误信息。如果没有自定义中间件的话,会直接将 koa-jwt 暴露的错误信息直接返回给用户。
const errorHandle = (ctx, next) => {
return next().catch((err) => {
// Custom 401 handling if you don't want to expose koa-jwt errors to users
if (401 == err.status) {
ctx.status = 401;
ctx.body = {
code: 401,
msg: 'Protected resource, use Authorization header to get access\n'
}
} else {
ctx.status = err.status || 500
ctx.body = Object.assign({
code: ctx.status,
msg: err.message
}, process.env.NODE_ENV === 'development'
? { stack: err.stack }
: {}
)
console.log(err.stack);
}
});
};
export default errorHandle;
登录实现
用户输入用户名和密码登录,如果用户名和密码正确的话,使用 jsonwebtoken.sign() 生成 token,并返回给客户端。客户端将token存储在localStorage里,在每次的 HTTP 请求中,都将 token 添加在 HTTP Header Authorazition: Bearer token 中。然后后端每次去验证该token的正确与否。只有token正确后才能访问到对应的资源。
async login(ctx) {
const { cid, code, username, password } = ctx.request.body;
// 检查验证码是否有效,是否正确
const isCodeCorrectAndValid = await checkCode(cid, code);
if (isCodeCorrectAndValid) {
// 检查账号是否正确
const user = await UsersModel.findOne({
username
})
if (user === null) {
ctx.body = {
code: 404,
msg: '用户名或者密码错误'
}
} else {
if (user.password === password) {
const token = jwt.sign({
username: username,
_id: user._id,
}, config.JWT_SECRET, { expiresIn: config.JWT_EXPIRESIN });
const arr = ['password'];
let data = {};
Object.keys(user.toJSON()).forEach((key) => {
if (!arr.includes(key)) {
data[key] = user[key];
}
});
data = await addIsSignIn(data)
ctx.body = {
code: 200,
data,
msg: '登录成功',
token: token,
}
} else {
ctx.body = {
code: 404,
msg: '用户名或者密码错误'
}
}
}
} else {
ctx.body = {
code: 401,
msg: '图片验证码错误'
}
}
}
需要注意的是,在使用 jsonwebtoken.sign() 时,需要传入的 secret 参数,这里的 secret 必须要与 前面设置 jwt() 中的 secret 一致。
更多关于 jsonwebtoken 的方法,可见:github.com/auth0/node-…
注册实现
账号、密码、验证码都合法后,再向用户邮箱发送链接。链接中的token参数是根据用户id生成的,这里不直接使用id,而是用token是为了防止用户伪造链接。当用户点击链接后,再将信息存入mongodb数据库。实际项目中,后续还需要对用户输入的字段进行验证。
async register(ctx) {
const { cid, code, username, password, name } = ctx.request.body;
// 检查验证码是否有效,是否正确
const isCodeCorrectAndValid = await checkCode(cid, code);
if (isCodeCorrectAndValid) {
// 检查username是否被注册
const user = await UsersModel.findOne({
username
})
if (user && (user.username !== undefined)) {
ctx.body = {
code: 404,
msg: '用户名已注册过,请直接登录'
}
} else {
// 检查name是否被注册
const user2 = await UsersModel.findOne({
name
})
if (user2 && (user2.name !== undefined)) {
ctx.body = {
code: 404,
msg: '昵称与他人重复,请修改昵称'
}
} else {
// 派发jwt,这里是为了防止用户伪造连接
const token = jwt.sign({
id,
}, config.JWT_REGISTER_SECRET, {
expiresIn: config.JWT_REGISTER_EXPIRESIN
});
// 信息存入redis
await setValue(username, {
password,
name
}, 30 * 60);
// 发送邮件、验证邮箱否是是本人邮箱
await send({
subject: '注册-验证邮箱',
data: {
token,
},
route: '/confirm/register',
expire: moment()
.add(30, 'minutes')
.format('YYYY-MM-DD HH:mm:ss'),
email: username,
name: name
})
ctx.body = {
code: 200,
msg: `验证链接已发至您的邮箱${username},请登录邮箱查看`,
}
return;
}
}
} else {
ctx.body = {
code: 401,
msg: '图片验证码错误'
}
}
}
// 用户验证注册接口
async verifyRegister(ctx) {
const { token } = ctx.request.body;
if (token === undefined) {
ctx.body = {
code: 404,
msg: '缺少参数'
}
return;
}
// 验证token是否正确
let cert;
try {
cert = jwt.verify(token, config.JWT_REGISTER_SECRET);
} catch (err) {
ctx.body = {
code: 404,
msg: '很抱歉,token值无效或者链接已过期'
}
return;
}
const obj = await getHValue(cert.username);
if (obj === null || isEmptyObject(obj)) {
ctx.body = {
code: 404,
msg: '很抱歉,链接有误或者链接已过期'
}
return;
}
const { password, name } = obj;
// 写入数据
const data = new UsersModel({
username: cert.username,
password,
name
});
const result = await data.save();
ctx.body = {
code: 200,
msg: '注册成功'
}
}
前端代码
在axios请求拦截器中,根据请求url,判断是否需要带上Authorization请求头。
在axios相应拦截器中,根据返回来的json,判断是否有token,有则将token存入localStorage。
import axios from "axios";
import { TOKEN } from "../storage/config";
import localStorage from "../storage/localStorage";
import { BASE_URL, TIMEOUT } from './config';
import errorHandle from "./errorHandle";
// 不需要鉴权
const PUBLIC_PATH_ARRAY = [
/^\/public/,
/^\/api\/login/,
/^\/api\/public/,
]
const service = axios.create({
baseURL: BASE_URL,
timeout: TIMEOUT,
headers: {
"Content-Type": "application/json;charset=utf-8"
}
});
service.interceptors.request.use((config) => {
const token = localStorage.getItem(TOKEN);
if (token
&& (!PUBLIC_PATH_ARRAY.some(item => item.test(config.url)))
)
) {
config.headers.common['Authorization'] = 'Bearer ' + token;
}
return config;
}, (error) => {
errorHandle(error);
return Promise.reject(error);
});
service.interceptors.response.use((response) => {
if (response.status === 200) {
//如果token存在则存在localStorage
authorization && localStorage.setItem(TOKEN, authorization);
return Promise.resolve(response.data); // 处理数据格式
} else {
return Promise.reject(response);
}
}, (error) => {
// 添加异常处理
errorHandle(error);
return Promise.reject(error);
});
export default service;
我的思考&可以优化的点
续签
JWT 的最大缺点是,由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑,比如将jwt放入redis中并设置过期时间。所以,我们应该把jwt的过期时间设置的尽量短一点,比如30分钟。
然而,在实际操作中,总会遇到由于token快到了过期时间的临界点,而导致操作失败,从而需要再重新获取token重新操作一遍,用户体验感非常差。
所以,当离过期时间不足10分钟时,我们就派发新的token给用户,来实现token自动续签的功能。
后台获取token后,先解析token是否合法,合法再获取它的过期时间点,然后通过过期时间和当前系统时间的对比得出一个时间差,最后再判断这个时间差是否在10min内,是的话则结合你项目的场景刷新token返回给前端)具体参考下面核心代码:
import jwt from 'jsonwebtoken';
import config from '../../config';
/**
* 判断token是否过期。
* (过期则判断是否在10分钟之内,
* 如果在10分钟之内则给用户返回新的token)
*/
const RefreshTokenHandle = async (ctx, next) => {
//获取生成token时的过期时间,判断是否在允许过期延迟时间范围内,
// 如果是则重新生成token返回,否则报错
const payload = {};
const oldToken = ctx.header.authorization.split(' ')[1];
//防止token不传或token前缀不符,直接返回让后面的koa-jwt去处理
if (!ctx.header.authorization || ctx.header.authorization.indexOf('Bearer ') == -1) {
await next();
} else {
try {
payload = jwt.verify(oldToken, config.JWT_SECRET);
} catch (err) {
console.log(err.message, new Date(err.expiredAt).getTime());
throw err;
}
if (payload && payload.exp) {
var allowTime = parseInt(payload.exp) - parseInt(new Date().getTime() / 1000);
console.log(allowTime);
if (allowTime <= 60 * 10) {
const { username, _id } = payload;
await next();
// 解决重复生成jwt的问题,只记录过去5秒内原始jwt刷新生成新jwt的数据
// 几秒内如果发现同样的jwt再次请求刷新,就返回相同的新的jwt数据。
let newToken = await getValue(oldToken)
if (newToken === null) {
newToken = jwt.sign({
username,
_id,
}, config.JWT_SECRET, {
expiresIn: config.JWT_EXPIRESIN
});
await setValue(oldToken, newToken, 5);
}
ctx.body.token = newToken;
} else {
await next();
}
}
}
}
export default RefreshTokenHandle;
假如用户发送了两个一模一样的请求a和请求b,由于网络原因,先发送的请求a反而后到达。那么就会造成重复生成jwt的问题。
解决方案:只记录过去5秒内原始jwt刷新生成新jwt的数据。几秒内如果发现同样的jwt再次请求刷新,就返回相同的新的jwt数据。
安全
JWT 中的paload默认是不加密的情况下,不能将秘密数据写入 JWT。
为了减少盗用,JWT 不应该使用 HTTP 协议明码传输,要使用 HTTPS 协议传输。
项目地址
WeShare是一款发帖分享论坛。
🚀 登录模块实现了用户注册、登录,忘记密码(通过邮箱验证找回)、修改密码功能
🚀 帖子模块实现了发帖、删帖、收藏帖子、评论回复、评论点赞功能
🚀 用户模块实现了修改用户资料、展示用户资料、每日签到获取积分功能
项目亮点
🚀 采用jwt+localStorage进行会话管理,实现了用户在期限内免登录的效果
🚀 利用axios请求拦截器解决异步请求竞态问题
🚀 对首页帖子展示实现滚动加载(懒加载)