共计 8975 个字符,预计需要花费 23 分钟才能阅读完成。
HI!,你好,我是 zane,zanePerfor 是一款我开发的一个前端性能监控平台,现在支持 web 浏览器端和微信小程序端。
我定义为一款完整,高性能,高可用的前端性能监控系统,这是未来会达到的目的,现今的架构也基本支持了高可用,高性能的部署。实际上还不够,在很多地方还有优化的空间,我会持续的优化和升级。
开源不易,如果你也热爱技术,拥抱开源,希望能小小的支持给个 star。
项目的 github 地址:https://github.com/wangweiang… 项目开发文档说明:https://blog.seosiwei.com/per…
谈起 Token 登录机制,相信绝大部分人都不陌生,相信很多的前端开发人员都有实际的开发实践。
此文章的 Token 登录机制主要针对于无实际开发经验或者开发过简单登录机制的人员,如果你是大佬几乎可以略过了,如果你感兴趣或者闲来无事也可以稍微瞅它一瞅。
此文章不会教你一步一步的实现一套登录逻辑,只会结合 zanePerfor 项目阐述它的登录机制,讲明白其原理比写一堆代码来的更实在和简单。
zanePerfor 项目的主要技术栈是 egg.js、redis 和 mongodb, 如果你不懂没关系,因为他们都只是简单使用,很容易理解。
登录实现结果:
如果用户未注册时先注册然后直接登录
用户每次登录都会动态生成 session 令牌
同一账号在同一时刻只能在一个地方登录
cookie 在项目中的作用
我们知道 http 是无状态的,因此如果要知道用户某次请求是否登录就需要带一定的标识,浏览器端 http 请求带标识常用的方式有两种:1、使用 cookie 附带标识,2、使用 header 信息头附带标识。这里我们推荐的方式是使用 cooke 附带标识,因为它相当于来说更安全和更容易操作。
更安全体现在:cookie 只能在同域下传输,还可以设置 httpOnly 来禁止 js 的更改。更容易操作体现在:cookie 传输是浏览器请求时自带的传输头信息,我们不需要额外的操作,cookie 还能精确到某一个路径,并且可以设置过期时间自动过期,这样就显得更可控。当然 header 信息头也有它的优势和用武之地,这里不做阐述。
redis 在项目中的作用
一般的项目我们会把识别用户的标识放存放在 Session 中,但是 Session 有其使用的局限性。
Session 的局限:Session 默认存放在 Cookie 中,但是如果我们的 Session 对象过于庞大,浏览器可能拒绝保存,这样就失去了数据的完整性。当 Session 过大时还会对每次 http 请求带来额外的开销。还有一个比较大的局限性是 Session 存放在单台服务器中,当有多台服务器时无法保证统一的登录态。还会带来代码的强耦合性,不能使得登录逻辑代码解耦。
因此这里引入 redis 进行用户身份识别的储存。
redis 的优势:redis 使用简单,redis 性能足够强悍,储存空间无限制,多台服务器可以使用统一的登录态,登录逻辑代码的解耦。
前端统一登录态封装
前端统一登录态应该是每位前端童鞋都做过的事情,下面以 zanePerfor 的 Jquery 的 AJAX 为例做简单的封装为例:
// 代码路径:app/public/js/util.js
ajax(json) {
// … 代码略 …
return $.ajax({
type: json.type || “post”,
url: url,
data: json.data || “”,
dataType: “json”,
async: asyncVal,
success: function(data) {
// … 代码略 …
// success 时统一使用 this.error 方法进行处理
if (typeof(data) == ‘string’) {
This.error(JSON.parse(data), json);
} else {
This.error(data, json);
}
},
// … 代码略 …
});
};
error(data, json) {
// 判断 code 并处理
var dataCode = parseInt(data.code);
// code 为 1004 表示未登录 需要统一走登录页面
if (!json.isGoingLogin && dataCode == 1004) {
// 判断 app 或者 web
if (window.location.href.indexOf(config.loginUrl) == -1) {
location.href = config.loginUrl + ‘?redirecturl=’ + encodeURIComponent(location.href);
} else {
popup.alert({
type: ‘msg’,
title: ‘ 用户未登陆, 请登录!’
});
}
} else {
switch (dataCode) {
// code 为 1000 表示请求成功
case 1000:
json.success && json.success(data);
break;
default:
if (json.goingError) {
// 走 error 回调
json.error && json.error(data);
} else {
// 直接弹出错误信息
popup.alert({
type: ‘msg’,
title: data.desc
});
};
}
};
}
前端的逻辑代码很简单,就是统一的判断返回 code, 如果未登录则跳转到登录页面。
User 表结构说明
// 代码路径 app/model/user.js
const UserSchema = new Schema({
user_name: {type: String}, // 用户名称
pass_word: {type: String}, // 用户密码
system_ids: {type: Array}, // 用户所拥有的系统 Id
is_use: {type: Number, default: 0}, // 是否禁用 0:正常 1:禁用
level: {type: Number, default: 1}, // 用户等级(0:管理员,1:普通用户)
token: {type: String}, // 用户秘钥
usertoken: {type: String}, // 用户登录态秘钥
create_time: {type: Date, default: Date.now}, // 用户访问时间
});
用户表中 usertoken 字段比较重要,它表示每次用户登录时动态生成的 Token 令牌 key, 也是存在在 redis 中用户信息的 key 值,此值每次用户登录时都会更新,并且是随机和唯一的。
Node Servers 端登录逻辑
我们先来一张登录的页面
业务代码如下:
// 代码路径 app/service/user.js
// 用户登录
async login(userName, passWord) {
// 检测用户是否存在
const userInfo = await this.getUserInfoForUserName(userName);
if (!userInfo.token) throw new Error(‘ 用户名不存在!’);
if (userInfo.pass_word !== passWord) throw new Error(‘ 用户密码不正确!’);
if (userInfo.is_use !== 0) throw new Error(‘ 用户被冻结不能登录,请联系管理员!’);
// 清空以前的登录态
if (userInfo.usertoken) this.app.redis.set(`${userInfo.usertoken}_user_login`, ”);
// 设置新的 redis 登录态
const random_key = this.app.randomString();
this.app.redis.set(`${random_key}_user_login`, JSON.stringify(userInfo), ‘EX’, this.app.config.user_login_timeout);
// 设置登录 cookie
this.ctx.cookies.set(‘usertoken’, random_key, {
maxAge: this.app.config.user_login_timeout * 1000,
httpOnly: true,
encrypt: true,
signed: true,
});
// 更新用户信息
await this.updateUserToken({username: userName, usertoken: random_key});
return userInfo;
}
对照 user 表来进行逻辑的梳理。
每次登录前都会清除上一次在 redis 中的登录态信息,所以上一次的登录令牌对应的 redis 信息会失效,因此我们只需要做一个校验用户 Token 的信息在 redis 中是否存在即可判断用户当前登录态是否有效。
清除上一次登录态信息之后立即生成一个随机并唯一的 key 值做为新的 Token 令牌,并更新 redis 中 Token 的令牌信息 和 设置新的 cookie 令牌,这样就保证了以前的登录态失效,当前的登录态有效。
redis 和 cookie 都设置相同的过期时间,以保证 Token 的时效性和安全性。
cookie 的 httpOnly 我们需要开启,这样就保证的 Token 的不可操作性,encrypt 和 signed 参数是 egg.js 的参数,主要负责对 cookie 进行加密,让前端的 cookie 不已明文的方式呈现,提高安全性。
最后再更新用户的 Token 令牌信息,以保证用户的 Token 每次都是最新的,也用以下次登录时的清除操作。
Servers 端用户登录校验中间件
中间件的概念相信大家都不陌生,用过 koa,express 和 redux 都应该知道,egg.js 的中间件来自于与 koa,在这里就不说概念了。
在 zanePerfor 项目中我们只需要对所有需要进行登录校验的路由 (请求) 进行中间件校验即可。
在 egg 中可这样使用:
// 代码来源 app/router/api.js
// 获得 controller 和 middleware(中间件)
const {controller, middleware} = app;
// 对需要校验的路由进行校验
// 退出登录
apiV1Router.get(‘user/logout’, tokenRequired, user.logout);
业务代码如下:
// 代码路径 app/middleware/token_required.js
// Token 校验中间件
module.exports = () => {
return async function(ctx, next) {
const usertoken = ctx.cookies.get(‘usertoken’, {
encrypt: true,
signed: true,
}) || ”;
if (!usertoken) {
ctx.body = {
code: 1004,
desc: ‘ 用户未登录 ’,
};
return;
}
const data = await ctx.service.user.finUserForToken(usertoken);
if (!data || !data.user_name) {
ctx.cookies.set(‘usertoken’, ”);
const descr = data && !data.user_name ? data.desc : ‘ 登录用户无效!’;
ctx.body = {
code: 1004,
desc: descr,
};
return;
}
await next();
};
};
// finUserForToken 方法代码路径
// 代码路径 app/service/user.js
// 根据 token 查询用户信息
async finUserForToken(usertoken) {
let user_info = await this.app.redis.get(`${usertoken}_user_login`);
if (user_info) {
user_info = JSON.parse(user_info);
if (user_info.is_use !== 0) return {desc: ‘ 用户被冻结不能登录,请联系管理员!’};
} else {
return null;
}
return await this.ctx.model.User.findOne({token: user_info.token}).exec();
}
逻辑梳理:
首先会获得上传的 token 令牌,这里 cookie.get 方法的 encrypt 和 signed 需要为 true,这会把 Token 解析为明文。
在 finUserForToken 方法中主要是获取 Token 令牌对应的 redis 用户信息,只有当用户的信息为真值时才会通过校验
在中间件这一环节还有一个比较常规的验证 就是 验证请求的 referer, referer 也是浏览器请求时自带的,在浏览器端不可操作,这相对的增加了一些安全性(项目中暂未做,这个验证比较简单,如果有需要的自己去实现)。
到此 zanePerfor 的 Token 校验机制其实已经完全实现完了,只是未做整体的总结,下面来继续的完成注册的逻辑。
用户注册逻辑实现
业务代码如下:
// 代码路径 app/service/user.js
// 用户注册
async register(userName, passWord) {
// 检测用户是否存在
const userInfo = await this.getUserInfoForUserName(userName);
if (userInfo.token) throw new Error(‘ 用户注册:用户已存在!’);
// 新增用户
const token = this.app.randomString();
const user = this.ctx.model.User();
user.user_name = userName;
user.pass_word = passWord;
user.token = token;
user.create_time = new Date();
user.level = userName === ‘admin’ ? 0 : 1;
user.usertoken = token;
const result = await user.save();
// 设置 redis 登录态
this.app.redis.set(`${token}_user_login`, JSON.stringify(result), ‘EX’, this.app.config.user_login_timeout);
// 设置登录 cookie
this.ctx.cookies.set(‘usertoken’, token, {
maxAge: this.app.config.user_login_timeout * 1000,
httpOnly: true,
encrypt: true,
signed: true,
});
return result;
}
用户注册的代码比较简单,首先检测用户是否存在,不存在则储存
生成动态并唯一的 Token 令牌,并保持数据到 redis 和设置 cookie 令牌信息,这里都设置相同的过期时间,并加密 cookie 信息和 httpOnly。
退出登录逻辑
退出登录逻辑很简单,直接清除用户 Token 对应的 redis 信息和 cookie token 令牌即可。
// 登出
logout(usertoken) {
this.ctx.cookies.set(‘usertoken’, ”);
this.app.redis.set(`${usertoken}_user_login`, ”);
return {};
}
冻结用户逻辑
冻结用户的逻辑也比较简单,唯一需要注意的是,冻结的时候需要清除用户 Token 对应的 redis 信息。
// 冻结解冻用户
async setIsUse(id, isUse, usertoken) {
// 冻结用户信息
isUse = isUse * 1;
const result = await this.ctx.model.User.update(
{_id: id},
{is_use: isUse},
{multi: true}
).exec();
// 清空登录态
if (usertoken) this.app.redis.set(`${usertoken}_user_login`, ”);
return result;
}
删除用户逻辑
删除用户逻辑跟冻结用户逻辑一致,也需要注意清除用户 Token 对应的 redis 信息。
// 删除用户
async delete(id, usertoken) {
// 删除
const result = await this.ctx.model.User.findOneAndRemove({_id: id}).exec();
// 清空登录态
if (usertoken) this.app.redis.set(`${usertoken}_user_login`, ”);
return result;
}
第三方 github 登录说明
根据 zanePerfor 的登录校验机制可以得出以下的结论:
User 表的用户名必须存在,密码可无,并且用户名在代码中强校验不能重复,但是在数据库中用户名是可以重复的。
usertoken 字段很重要,是实现所有 Token 机制的核心字段,每次登录和注册都会是随机并唯一的值
基于以上两点做第三方登录我们只需要实现以下几点即可:
只要给用户名赋值即可,因为用户密码登录和第三方登录是两套逻辑,因此用户名可以重复,这就解决了第三方登录一定不会存在用户已注册的提示。
第一次登录时注册用户,并把第三方的用户名当做表的用户名,第三方的 secret 作为用户的 token 字段。
第二次登录时使用 token 字段检测用户是否已注册,已注册走登录逻辑,未注册走注册逻辑。
// 代码地址 app/service/user.js
// github register 核心注册逻辑
async githubRegister(data = {}) {
// 此字段为 github 用户名
const login = data.login;
// 此字段为 github 唯一用户标识
const token = data.node_id;
let userInfo = {};
if (!login || !token) {
userInfo = {desc: ‘github 权限验证失败, 请重试!’};
return;
}
// 通过 token 去查询用户是否存在
userInfo = await this.getUserInfoForGithubId(token);
// 身材 Token 随机并唯一令牌
const random_key = this.app.randomString();
if (userInfo.token) {
// 存在则直接登录
if (userInfo.is_use !== 0) {
userInfo = {desc: ‘ 用户被冻结不能登录,请联系管理员!’};
} else {
// 清空以前的登录态
if (userInfo.usertoken) this.app.redis.set(`${userInfo.usertoken}_user_login`, ”);
// 设置 redis 登录态
this.app.redis.set(`${random_key}_user_login`, JSON.stringify(userInfo), ‘EX’, this.app.config.user_login_timeout);
// 设置登录 cookie
this.ctx.cookies.set(‘usertoken’, random_key, {
maxAge: this.app.config.user_login_timeout * 1000,
httpOnly: true,
encrypt: true,
signed: true,
});
// 更新用户信息
await this.updateUserToken({username: login, usertoken: random_key});
}
} else {
// 不存在 先注册 再登录
const user = this.ctx.model.User();
user.user_name = login;
user.token = token;
user.create_time = new Date();
user.level = 1;
user.usertoken = random_key;
userInfo = await user.save();
// 设置 redis 登录态
this.app.redis.set(`${random_key}_user_login`, JSON.stringify(userInfo), ‘EX’, this.app.config.user_login_timeout);
// 设置登录 cookie
this.ctx.cookies.set(‘usertoken’, random_key, {
maxAge: this.app.config.user_login_timeout * 1000,
httpOnly: true,
encrypt: true,
signed: true,
});
}
return userInfo;
}
详细的 github 第三方授权方式请参考:https://blog.seosiwei.com/per…
总结:
前端封装统一的登录验证,项目中 code 1004 为用户未登录,1000 为成功。
user 数据表中储存一个 usertoken 字段,此字段是随机并唯一的标识,在注册时存入此字段,在每次登录时更新此字段。
浏览器端的 Token 令牌即 usertoken 字段,redis 的每个 Token 存储的是相应的用户信息。
每次登录时清除上一次用户的登录信息,即清除 redis 登录校验信息,这样就能保证同一用户同一时间只能在一个地方登录。
usertoken 字段是随时在变的,redis 用户信息和 cookie Token 令牌都有过期时间,cookie 经过加密和 httpOnly,更大的保证了 Token 的安全性。
对所有需要校验的 http 请求做中间件校验,通过 Token 令牌获取 redis 用户信息并验证,验证即通过,验证失败则重新去登录。
第三方登录使用 token 做用户是否重复校验,第一次时登录注册,第二次登录时则走登录逻辑。
原文地址:https://blog.seosiwei.com/det…