Node鉴权系列3:JWT实现跨站点的单点登录

流程说明

这篇教程我们将使用Express.js来实现单点登录系统,首先我们有两个域名(可在host中配置),分别是:

  • node.com
  • login.com

image.png整个流程如上图所示:

  1. 如果用户访问node.com,服务端发现没有登录后自动重定向到login.com/login页面
  2. login.com/login页面登录成功后,将带上有用户身份的jwt标准的token,跳转到node.com
  3. node.com解析token,如果解析成功,则注入cookie允许访问。

示例代码

1. 没有cookie和token时进行重定向(后端)

下面假设用户现在访问的是node.com,现在请求上即没有cookie,url上也没有携带token。说明该用户没有登录,此时我们将请求重定向到login.com:3099/login页面上。

需要注意的是,我们通过req对象获取到了原始的域名,并经过encodeURIComponent()后保存到了source的参数上,方便之后在登录成功后进行跳转回原页面操作。

// 检查用户是否登录
app.get("/", async function (req, res) {
  const url = getURL(req);
  // 即没有cookie也没有token的情况
  if (!req.cookies.USER_ID && !req.query.token) {
    res.redirect(
      `http://login.com:3099/login?source=${encodeURIComponent(url)}`
    );
    return
  }
});
复制代码

2. 登录页面(前端)

此时页面跳转到了登录页页面,触发了/login的路由,我们返回了一个登录的静态页面。

app.get("/login", async function (req, res) {
  const loginPagePath = path.resolve(__dirname, "./views/login.html");
  fs.readFile(loginPagePath, (err, html) => {
    if (err) {
      res.send("登录页读取错误");
    } else {
      res.set("Content-Type", "text/html");
      res.send(html);
    }
  });
});

复制代码

页面比较简单,主要是为了实现一个简单的登录,登录页面的相关代码和样式如下。
image.png

<div id="login-page">
  <input type="text" placeholder="用户名" v-model="name" />
  <input type="password" placeholder="密码" v-model="pwd" />
  <button @click="login">登录</button>
</div>

<script>
  const LoginPage = {
    data() {
      return {
        name: "",
        pwd: "",
      };
    },
    methods: {
      login() {
        const { name, pwd } = this;
        fetch("/login", {
          method: "POST",
          redirect: 'follow',
          mode: 'cors',
          headers: new Headers({
            "Content-Type": "application/json",
          }),
          body: JSON.stringify({ name, pwd }),
        }).then(res => res.json()).then(data => {
          location.replace(data.redirectURL)
        });
      },
    },
  };
  Vue.createApp(LoginPage).mount("#login-page");
</script>
复制代码

3. 登录成功生成JWT(后端)

这里我们使用了jsonwebtoken这个npm包来生成JWT为,它提供了jwt.sign(payload, secretKey, option)方法来生成JWT。

const SECRET_KEY = "sSjXx81KjQiayUhHHlCRr9mU";
const jwt = require("jsonwebtoken");


app.post("/login", async function (req, res) {
  const { name, pwd } = req.body;
  const isValid = await validateUserLogin(name, pwd); // 验证用户登录
  const query = url.parse(req.headers.referer).query; // 获取到请求的query
  const sourceURL = decodeURIComponent(qs.parse(query).source); // 获取要跳转的地址

  if (isValid) {
    const token = jwt.sign({name}, SECRET_KEY, { expiresIn: '1h' })
    res.cookie("USER_ID", name, {
      httpOnly: true,
      maxAge: ONE_HOUR,
    });
    res.header('Access-Control-Allow-Origin', '*')
    const redirectURL = `${sourceURL}?token=${token}`
    res.send({
      success: true,
      redirectURL: redirectURL
    })
  } else {
    res.send({
      success: false,
      info: "登录失败",
    });
  }
});
复制代码

上面的代码做了如下事情:

  1. 获取到请求体里面的namepwd,验证用户身份。
  2. 获取到网站链接上面的source参数,用作登录成功后的重定向地址redirectURL
  3. 如果用户身份验证成功,调用jwt.sign()生成token
  4. token拼接在重定向的链接上,返回给前端,用来重定向。
  5. 此时页面跳转回node.com,整个页面链接是:[http://node.com:3099/?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiYWJjIiwiaWF0IjoxNjMyNDkzMDM5LCJleHAiOjE2MzI0OTY2Mzl9.kaiOwYX9P6-v3WbryxyK8jJBseza3ZbEi5a56P28knA](http://node.com:3099/?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiYWJjIiwiaWF0IjoxNjMyNDkzMDM5LCJleHAiOjE2MzI0OTY2Mzl9.kaiOwYX9P6-v3WbryxyK8jJBseza3ZbEi5a56P28knA)

4. 解析token

现在页面跳转回node.com,我们需要解析token。此时需要使用到jwt.verify()方法。如果token被篡改了,此时该方法会抛出错误。如果解析成功,在上一步我们的payload中的{name}对象能够够被我们成功获取到。让我们来给/路由新增一个判断条件,用来解析token

// 检查用户是否登录
app.get("/", async function (req, res) {
  const url = getURL(req);
  // 有token的情况
  if(req.query.token) {
    const token = req.query.token
    jwt.verify(token, SECRET_KEY, (err, decoded) => {
      if (err) {
        res.send({
          success: false,
          info: 'token无效'
        })
      } else {
        res.cookie('USER_ID', decoded.name)
        res.redirect('/')
      }
    })
    return
  }

  // 即没有cookie也没有token的情况
  if (!req.cookies.USER_ID && !req.query.token) {
    res.redirect(
      `http://login.com:3099/login?source=${encodeURIComponent(url)}`
    );
    return
  }
});
复制代码

可以看到,如果解析成功后,我们将payload中的name的值保存到了cookie中。并进行了一次重定向,用来抹掉连接上冗余的token字段,现在让我们再来添加一个提示用户登录成功的响应吧。

app.get("/", async function (req, res) {
  const url = getURL(req);
  // 有cookie的情况
  if(req.cookies.USER_ID) {
    res.send({
      success: true,
      info: `${req.cookies.USER_ID}已登录!`
    })
    return
  }

  // 有token的情况
  if(req.query.token) {
    const token = req.query.token
    jwt.verify(token, SECRET_KEY, (err, decoded) => {
      if (err) {
        res.send({
          success: false,
          info: 'token无效'
        })
      } else {
        res.cookie('USER_ID', decoded.name)
        res.redirect('/')
      }
    })
    return
  }

  // 即没有cookie也没有token的情况
  if (!req.cookies.USER_ID && !req.query.token) {
    res.redirect(
      `http://login.com:3099/login?source=${encodeURIComponent(url)}`
    );
    return
  }
});
复制代码

此时你会看到我们已经成功的给用户保存上了登录态:
image.png