登录的小知识

lxf2023-02-16 15:49:04

大多数的系统都是支持用户登录的,那么就会涉及到登录的小知识。

在现在的大部分网站登录接口的实现,根据 用户名密码 调用登录接口,成功之后登录接口返回 token 信息,在请求其他接口时,就会携带上,用于登录认证。

那么自己是否思考过,为什么需要登录认证?以前是怎么实现的?现在是怎么实现的?

为什么需要登录凭证

我们知道,系统的请求接口基本上都是基于 http 协议的( websocket 除外)。

但是 http 协议有一个显著的特点:无状态

何为无状态呢?

通俗的话来说,客户端向服务器发送的每个请求接口,都是相互独立的,互不干扰。

第一步:调用了登录接口,登录系统成功。

第二步:当调用列表接口时,还是会提示你没有登录。因为 http 是不会记录上次的信息,从而也就拿不到登录信息( http 无状态)。

但是在系统中的绝大数接口,就是需要用户信息,来进行数据过滤、数据权限等操作;没有用户信息,接口就会抛出异常。

这也就解释了为什么需要登录凭证。

不知道解释清楚没有。


既然知道了登录凭证的必要性,那么该如何实现呢?可以采用多种技术方案都可以实现,比如:

早期阶段:

  • cookie
  • session

现阶段

  • token

接下来,就看看各技术的实现方案。

cookie

掌握程度:了解

介绍 cookie

cookie,称为“小甜饼”,用于网站为了辨别用户信息而保存在客户端的数据。

在客户端上可以保存多个 cookie,其复数形式为 cookies 。

cookie 是和域名进行绑定的。如果A站点的cookie,B站点是不能进行访问的,这是由于浏览器同源策略限定,主要出于安全考虑。

代码操作之前,先记住两句话:

  1. 虽然 cookie 保存在客户端,但是设置是由服务端进行设置。
  2. 如果客户端存在 cookie,在客户端发送请求时,请求就会自动携带上 cookie(作用域匹配满足,后面说)。
登录的小知识

简单理解就是说,客户端是不用对 cookie 进行操作的,也就是前端人员不需要操作 cookie。

cookie 分类

  • 内存cookie 由浏览器维护,保存在内存中,浏览器关闭时 cookie 就会消失,其存在时间是短暂的;
  • 硬盘cookie 保存在硬盘中,有一个过期时间,用户手动清理或者过期时间到时,才会被清理;

那么如何判断是内存cookie还是硬盘cookie

  1. 没有设置过期时间,默认情况下 cookie 类型是内存cookie,在关闭浏览器时会自动删除。
  2. 有设置过期时间,并且过期时间不为 0 或者负数的cookie,是硬盘cookie,需要手动或者到期时,才会删除。

如何手动删除 cookie ?(下面的解释好像不对,至少 MDN 上存在删除 cookie 的方法

是没有方法来进行删除 cookie 的,那么删除cookie的方式就是设置过期时间为 0。因为时间为0,就会立即过期,从而也就会自动删除。

客户端操作 cookie

虽然说前端开发人员不用操作 cookie,但是可以进行操作。

<body>
    <div id="f"></div>
    <script>
      // 设置cookie
      document.cookie='name=copyer;';
    </script>
</body>

查看 cookie 的两种方式:

方式一:

登录的小知识

方式二:

登录的小知识

在第二种的方式中,发现了除了 key 和 value 之外的属性,还有着其他的属性(比如:domain、path、max-age等)。

认识 cookie 的属性

cookie 的生命周期

  • 默认情况下的 cookie 是 内存cookie,即浏览器关闭就自动删除。

  • 可以设置 expiresmax-age 来设置过期时间

    • expires 设置过期时间。设置时间格式为Date.toUTCString()。
    • max-age 设置过期的秒钟。(单位为 秒s)
    <body>
        <div id="f"></div>
        <script>
          // 设置cookie, 60s后自动删除
          document.cookie='name=copyer;max-age=60;';
        </script>
    </body>
    

cookie 的作用域

所谓的作用域就是允许哪些 url 能携带 cookie,哪些 url 不能携带 cookie。

domain 属性

domain 指定哪些主机可以接受 cookie。

  • 如果不设置,则默认值为origin,不包括子域名。
  • 如果设置之后,则就包含了子域名。

示例: www.baidu.com/login 在该域名下设置了cookie

  1. 如果 domain 没有设置值,那么只会在该域名下才会携带cookie,而其他的域名者不会携带cookie(比如:map.baidu.com 则不会携带)
  2. 设置 domain=baidu.com,那么 www.baidu.com 会携带 cookie; map.baidu.com 也会携带 cookie; 只要携带了 baidu.com 都会允许携带 cookie。

domain 要求设置

domain 译为 域,cookie 是不能跨域设置的,只能设置当前域或者更高级的域(必须是在同一个根域名下的)

比如 根域名: .aaa.com; 一级域名 bbb.aaa.com ; 二级域名 ccc.bbb.aaa.com;

如果是访问的ccc.bbb.aaa.com, 则可以设置 domain 是 ccc.bbb.aaa.com、bbb.aaa.com 和 .aaa.com 的cookie;

如果访问bbb.aaa.com,则可以设置 domain 是 bbb.aaa.com 和 .aaa.com 的cookie;

如果访问www.aaa.com,则只能设置 domain 是 .aaa.com 的cookie。

-- 摘自于某篇博文,但是又找不到该博文,如侵权请联系。

path 属性

如果设置 path = '/copyer',则下面的地址都会被匹配上:

  • /copyer
  • /copyer/abc
  • /copyer/abc/123

path 设置要求

cookie 的 path 属性设置只能在当前路径下生效(自测,对与否?),默认是路径是 /

服务端操作 cookie

在前面已经说到,cookie 是由服务端生成的,客户端请求时也是由浏览器自动携带上 cookie 到服务端。那么服务端是怎么设置,怎么获取的呢?如何做到登录验证呢?

在这里使用 node.js 的 koa 框架进行演示(koa不会不要紧,思路是最重要的。如果你熟悉 express,也是一样的)。

const koa = require("koa");
const KoaRouter = require("@koa/router");
​
const app = new koa();
const userRouter = new KoaRouter({ prefix: "/user" });
​
userRouter.get("/login", (ctx, next) => {
  // 拿起用户名和密码,与数据库中的信息进行比对,判断
  // 这里就假设对比成功,成功登录,设置cookie
  ctx.cookies.set("name", "copyer");
  /*
  // 设置其他属性
  ctx.cookies.set("name", "copyer", {
    maxAge: 1000*60*60, //单位毫秒
    domain: xxx,
    path: '/user/login'
  });
  */
  ctx.body = "登录成功";
});
​
userRouter.get("/list", (ctx, next) => {
  // 在执行逻辑之前,需要先判断一下是否登录,这下就根据cookie信息来进行判断
  // 获取cookie
  const cookieInfo = ctx.cookies.get("name");
  if (cookieInfo === "copyer") {
    ctx.body = "获取列表成功";
  } else {
    ctx.body = "未登录";
  }
});
​
app.use(userRouter.routes());
app.use(userRouter.allowedMethods());
​
app.listen(8000, () => {
  console.log("服务器启动成功");
});

在上面,只需要留意两端代码,也就是两个接口: /user/login/user/list

/user/login: 模拟登录,设置 cookie

/user/list: 模拟获取列表接口,但是前提是需要先登录。

在上面两个接口都是使用了 get 请求,主要是为了方便直接在浏览器的地址栏直接测试(浏览器可以直接调用 get 请求 )。

第一步:在浏览器地址中,输入 htttp://localhost:8000/user/login,回车。

登录的小知识

第二步:在浏览器地址中,输入 htttp://localhost:8000/user/list,回车。

就会发现界面上显示 获取列表成功。这样就实现了登录的验证。


你也可以自己试一试,关闭浏览器,第一步直接输入 htttp://localhost:8000/user/list,就会发现界面上显示 未登录。因为该请求接口没有获取到 cookie。

这就是 cookie 实现登录凭证的大致流程。

session

掌握程度:了解

session 是基于 cookie 的,那么既然有了cookie,为什么还需要 session?

为什么需要 session ?

在上面的 cookie 设置中,在浏览中很容易发现,cookie 的 key 和 value 都是明文的形式。明文谁都看的懂,那么就会造成一种问题,可以直接通过 javascript 代码设置 cookie。

// 假如服务端设置的cookie: key为name, value为copyer
// js代码直接模拟
document.cookie = 'name=copyer;'

那么就会造成登录凭证直接验证通过,显然是不合理的。那么 session 的意义就出现了,就是对 cookie 进行加密,在浏览器中展示密文

session 的使用

还是借用 koa 框架来进行实现,需要借助一个三方库:koa-session

const koa = require("koa");
const KoaRouter = require("@koa/router");
const koaSession = require("koa-session");
​
const app = new koa();
const userRouter = new KoaRouter({ prefix: "/user" });
​
// 调用session函数,注册session中间件,注册会在ctx对象上绑定一个session对象
const session = koaSession({
    key: "sessionId", // 设置浏览器中展示 cookie 的 key 值
    signed: false, // 是否需要加密,false不加密,true加密
}, app);
app.use(session);
​
userRouter.get("/login", (ctx, next) => {
  // 设置session
  ctx.session.name = "copyer";
  ctx.body = "登录成功";
});
​
userRouter.get("/list", (ctx, next) => {
  // 获取session
  const cookieInfo = ctx.session.name;
  if (cookieInfo === "copyer") {
    ctx.body = "获取列表成功";
  } else {
    ctx.body = "未登录";
  }
});
​
app.use(userRouter.routes());
app.use(userRouter.allowedMethods());
​
app.listen(8000, () => {
  console.log("服务器启动成功");
});
​

代码中的注释其实挺清楚了,就不用多做解释了。

接口的调用顺序,跟上面调用 cookie 顺序是一样的。

  • 先调用/user/login接口,注册session。
  • 再调用 /user/list 接口,获取session,进行验证。

在浏览器中可以看见:

登录的小知识

生成的 cookie,其 name 为 sessionId,value 是字符串(看起来像密文,但是却不是,是把原始值通过某种算法生成的字符串,是没有加密操作的)。

针对上面的 sessionId,如果存在算法大神,对生成的字符串进行解密,从而能够拿到原始值的,因此也不是足够的安全。

那么如何对 value 进行加密操作呢?

加密:算法拿到盐进行加密。

解密:算法拿到盐进行解密(错误的理解,码友们你们说呢)。

二次验证: 算法拿到明文和盐生成密文,然后密文与密文之间的验证

算法大神拿不到盐,也就没有办法进行解密(过程是不是这样,我不知道,但是这样确实便于记忆)。

const session = koaSession({
    key: "sessionId",
    signed: true, // 是否需要加密,false不加密,true加密
    // 可以设置cookie的其他属性: maxAge, domain, path等
    httpOnly: true, // 只允许服务端设置sessionId, 不允许 js 代码设置 sessionId
  }, app);
// 加盐操作
app.keys=['aaa', 'bbb', 'ccc']
app.use(session);

再次远行代码。

登录的小知识

会发现有两个cookie,再次请求的时候,就会把两个cookie同时带过去,与盐进行解密,权限验证。


上面也就是 session 的使用大致流程。

在以前的系统,一般都是通过 cookie + session组合来实现登录凭证的,更加的安全。

token

掌握程度:了解

token 是现阶段绝大数系统实现登录凭证的主流技术。

既然有了 cookie + session 的技术实现方案,那么为什么还需要 token?

cookie 和 session 的缺陷

  1. cookie 会被附加在每个 HTTP 请求中,所以无形中增加了流量(事实上某些请求是不需要的);
  2. cookie 是明文传递的,所以存在安全性的问题;
  3. cookie 的大小限制是 4KB,对于复杂的需求来说是不够的;
  4. 对于浏览器外的其他客户端(比如 iOS、Android),必须手动的设置 cookie 和 session( app 端是不会自动携带 cookie 的,需要手动设置 );
  5. 对于分布式系统服务器集群中如何可以保证其他系统也可以正确的解析 session。

其实前面三点的问题,是可以解决或者可以不用过多考虑的,主要的缺点是后面两点。

  1. app 快速发展,是主流的趋势(一套代码,多端用)。
  2. 针对服务端的高并发系统中,针对多个子系统 sessionId 不好认证(不要有❓,我也不知道)。

认识 token

token 被称为令牌,就是验证用户登录成功之后,给用户颁发一个令牌,后续就是用户就携带令牌访问一个有权限认证的接口资源。

token 也解决了后面两点的问题,令牌无论是在 web 端还是 app 端都是可以被用户携带过去,进行验证的;第五点,略过,哈哈哈。

token 步骤

  • 生成 token:登录的时候,颁发token。
  • 验证 token:访问某些资源或者接口时,验证 token。

JWT 实现 token

这里还是借用 node.js 中的 koa.js 框架来实现 token 的 生成、颁发、验证。

JWT:是 json web token 的简写。所以这里需要安装一个第三方库 jsonwebtoken

但是在使用之前,还是先来认识认识它是什么原理。从一张图片说起:

登录的小知识

可以看出 JWT 就是一把钥匙,用于安全认证。JWT 由三部分组成:headerpayloadsignature

header

  • alg: 采用的加密算法,默认是HS256算法,一种对称加密,使用同一个密钥进行加密和解密。
  • typ: 默认值是 JWT,也可以说是固定值。
  • 通过 base64Url 算法进行编码。

jsonwentoken的源码header部分截图:

登录的小知识

payload

  • 携带的数据,比如用户信息等。
  • 默认也会携带 iat(issued at),令牌的签发时间。
  • 我们也可以设置过期时间:exp(expiration time,单位是秒s)。
  • 通过 base64Url 算法进行编码。

signature

设置一个密钥 secretKey,将前面 header 生成的编码和 payload 生产的编码合并之后,通过 HS256 算法加密。

const mergeStr = base64Url(header) + '.' + base64Url(payload)
HS256(mergeStr, secretKey) // 生成token

在这里 secretKey 是至关重要的,加密和解密都依赖于它,千万不能泄漏。

认识完了,就开始表演了。

安装 jsonwebtoken 就不多做解释,直接使用。

const koa = require("koa");
const KoaRouter = require("@koa/router");
const jwt = require("jsonwebtoken");
​
const app = new koa();
const userRouter = new KoaRouter({ prefix: "/user" });
​
// 设置一个密钥secretKey
const secretKey = "copyer_token";
​
userRouter.get("/login", (ctx, next) => {
  // 验证用户信息是否于数据库的信息是否一致
  // 一致,则生成token
  const token = jwt.sign({
      userId: 100001,
      username: "copyer",
    }, secretKey, {
      expiresIn: 60 * 60 * 24, // 一天后过期
    }
  );
  ctx.body = {
    code: 200,
    token,
    message: "登录成功",
  };
});
​
userRouter.get("/list", (ctx, next) => {
  // 验证token的用户信息
  const authorization = ctx.headers.authorization;
  const token = authorization.replace("Bearer ", "");
  try {
    // 拿到用户信息
    const payload = jwt.verify(token, secretKey);
    ctx.body = {
      code: 200,
      list: [1, 2, 3],
    };
  } catch (err) {
    ctx.body = {
      code: "-100003",
      message: "token错误或失效",
    };
  }
});
​
app.use(userRouter.routes());
app.use(userRouter.allowedMethods());
​
app.listen(8000, () => {
  console.log("服务器启动成功");
});

jwt 函数解析

// jwt 的函数解析
const jwt = {
  // 生成token
  sign: (payload, secretKey, options) {
    // payload 携带的用户信息
    // secretKey 密钥
    // options 设置过期时间,算法等配置对象
  },
   
  // 解密token
  verify: (token, secretKey) {
    // token 解密的token
    // secretKey 密钥
  }
}

使用步骤:

第一步:使用 postman 调用 /user/login 请求接口,生成token。

登录的小知识

token 三部分组成:header编码 + payload编码 + signature编码

第二步:使用/user/list 验证 token,在验证之前,需要请求携带 token。

登录的小知识 登录的小知识

那么在调用/user/list接口的时候,token就携带在 headers 中了,验证的时候就可以直接从 headers 中拿起token。

userRouter.get("/list", (ctx, next) => {
  // 验证token的用户信息
  const authorization = ctx.headers.authorization;
  // 去掉 Bearer 字段,后面存在一个空格,替换之后就是token了,解密之后就可以拿到携带的信息。
  const token = authorization.replace("Bearer ", "");
}

非对称加密

在 JWT 中的默认算法是 HS256,是对称加密算法,使用同一密钥进行加密和解密,那么就会存在一个问题:

登录的小知识

当A系统,使用 secretKey 颁发 token 之后,BCD系统分别想要验证 token,也需要拿到相同的 secretKey 来进行解密。那么对于黑客而言,只要攻破了一个系统,拿到 secretKey 了,其他三个系统也就不攻自破了,就可以随便修改携带信息,颁发新的 token,这样是不安全的。

非对称算法的出现,就完美的解决了这个问题。

  • 使用 私密private_key 颁发token
  • 使用 公钥public_key 解密token
登录的小知识

A系统使用 private_key 颁发 token,BCD 系统验证用户是否登录,使用 public_key 进行解密验证。那么这时如果黑客攻破 BCD 其中一个系统,拿到了public_key,但是并不能颁发新的 token,安全性也就大大的升高了。黑客只有攻破 A 系统,才能发布新的token,那么只需要对 A 系统加强防护,黑客也就无从下手了。

知道了为什么要使用非对称算法加密,那么该如何使用呢。

其中最重要的两点:

  1. 拿到 private_keypublic_key
  2. 设置非对称加密算法

拿取公钥和私钥

使用 openssl 来获取公钥和私钥

Mac 系统直接使用终端。

Windows 系统使用 git bash(shell终端不行,没有集成openssl )。

创建一个文件夹 keys,专门用于保存 private.keypublic.key

>Open SSL: genrsa -out private.key 1024     # 生成私钥
# genras  genrate rsa 生成 rsa 非对称算法名
# -out 输出文件名
# private.key 名称
# 1024 字段长度
​
>Open SSL: rsa -in private.key -pubout -out public.key  # 生成公钥
# rsa 非对称算法
# -in private.key作为输入文件(目的是使私钥与公钥成为一对)
# -pubout  public out 公钥输出
# -out 输出文件名
# public.key 名称

执行了上面两句命令之后,就会生成两个文件,自行操作演示。

拿到了公钥和私钥,就可以颁发新的token了。

const koa = require("koa");
const KoaRouter = require("@koa/router");
const jwt = require("jsonwebtoken");
const fs = require("fs");
​
const app = new koa();
const userRouter = new KoaRouter({ prefix: "/user" });
​
// 使用 fs 模块同步读取两个文件 (步骤一)
const private_key = fs.readFileSync("./keys/private.key");
const public_key = fs.readFileSync("./keys/public.key");
​
userRouter.get("/login", (ctx, next) => {
  // 验证用户信息是否于数据库的信息是否一致
  // 一致,则生成token (步骤二)
  const token = jwt.sign({
      userId: 100001,
      username: "copyer",
    }, private_key, {
      expiresIn: 60 * 60 * 24, // 一天后过期
      algorithm: 'RS256' // 设置非对称算法
    }
  );
  ctx.body = {
    code: 200,
    token,
    message: "登录成功",
  };
});
​
userRouter.get("/list", (ctx, next) => {
  // 验证token的用户信息 (步骤三)
  const authorization = ctx.headers.authorization;
  const token = authorization.replace("Bearer ", "");
  try {
    // 拿到用户信息 (步骤四)
    const payload = jwt.verify(token, public_key, { algorithms: ['RS256']}); // 这里的算法是数组
    ctx.body = {
      code: 200,
      list: [1, 2, 3],
    };
  } catch (err) {
    ctx.body = {
      code: "-100003",
      message: "token错误或失效",
    };
  }
});
​
app.use(userRouter.routes());
app.use(userRouter.allowedMethods());
​
app.listen(8000, () => {
  console.log("服务器启动成功");
});

在上面的逻辑跟前面的对称算法步骤是一样的,也在代码中标注了步骤一步骤二步骤三步骤四。仔细看看即可。

报错之处

RS256 算法的私钥最小长度为2048,所以在生成私钥的长度设置2048以上,而不是1024。


token 演示到此结束。

结语

看到了这里,相信各位码友或多或少对登录凭证有一定的认知,如果觉得有用,点个赞呗。

无论是针对 cookie + session 组合技术,还是 token 新技术,其实都是服务端所要实现的技术,而不是前端所必须掌握的知识。既然不是必须掌握的知识,那么是不是可以不用学习、不用了解呢?是的

实际上,作为开发人员不要把自己局限在某一端,而是要主攻某一端,也要其他端还是需要一定的认识,培养自己编码兴趣。毕竟兴趣才是学习的动力,加薪的资本。(有没有感觉,学习上面的知识还是挺有意义的。技术的实现,安全的考虑)

上面的内容,对于本人而言,也大多数是新知识,如果存在错误,请指出来,虚心接受。