单点登录

824 阅读5分钟

浅聊一下

这两天捣鼓了一下单点登录,赶紧写下来,既为记录,也为巩固...

登录

据我所知,登录有很多种方法:

  • session + cookie
  • 单token
  • 双token

session + cookie

image.png

  • 首先客户端请求登录,服务端验证账号密码
  • 账号密码无误创建一个session,生成一个唯一标识符sessionID储存在session中并且返回给客户端
  • 客户端会将sessionID储存在cookie中,在之后的每次请求都带上这个cookie
  • 服务端验证cookie带上的sessionID,若正确则允许访问

缺点:

  • 安全性堪忧,session储存在cookie中,存在安全隐患
  • 服务器需要储存会话数据,随着用户登录增加,服务器压力越来越大
  • 水平扩展困难,在负载均衡的情况下,如果用户被分配到不同的服务器,而会话数据没有进行跨服务器共享,那么用户可能需要重新登录

单token登录

image.png

  • 用户在前端提交注册或登录请求,包含用户名和密码。
  • 后端验证用户信息,生成一个包含用户身份信息和过期时间的 Token。
  • 前端在后续的请求中将 Token 包含在请求头的 Authorization 字段中,格式为 Bearer <Token>
  • 服务器验证 Token 的签名,确保其未被篡改。检查 Token 的过期时间,确保其仍在有效期内。如果 Token 有效,服务器将允许访问受保护的资源。

优点:

  • 相对于传统的session+cookie模式,token并不需要在服务端储存,解决了拓展性和依赖性的问题

缺点

  • token储存在客户端,被获取以后,且token仍在有效期内,仍可以使用token访问受限内容

单点登录

终于来到了重点...既然单token因为在有效期内会被别人获取而导致安全问题,那么是不是能把token的有效时间设置的非常短,那么即使别人获取了token,也是一个过期的token,毫无用处

生成token

先来一个简单的用户界面 image.png

在客户端输入账号密码,点击登录发送给服务端,来看click方法

const login = async () => {
  if (username.value.length < 5) {
    alert('用户名长度不能小于5位')
    return;
  }
  if (password.value.length < 5) {
    alert('密码长度不能小于5位')
    return;
  }
  // 发送登录请求
  const { data } = await axios.post("http://localhost:3000/auth/login", {
    username: username.value,
    password: password.value
  })
  // 若请求成功,则返回一个access_token,一个refresh_token
  if (data.code === 0) {
    const {
      access_token,
      refresh_token
    } = data.data
    // 储存
    localStorage.setItem('access_token', access_token);
    localStorage.setItem('refresh_token', refresh_token);
    // 登录状态为true
    isLogin.value = true
  }

}

来看看这一块的服务端是如何完成的,首先配置一下路由

module.exports = function router(app) {
    return function (req, res, next) {
        app.post('/auth/login', loginAction);
        next();
    }
}

当请求登录时,执行loginAction

exports.loginAction = (req, res) => {
    const {
        username,
        password
    } = req.body;
    // 这一段是在验证账号密码
    const userData = users[username];
    if(!userData) {
        return res.json({
            code:1,
            msg: '用户名不存在'
        })
    }
    const isRightPassword = checkCryptoHash(password,userData.password);
    if(!isRightPassword) {
        return res.json({
            code:1,
            msg: '密码错误'
        })
    }
    // 验证成功后应该获取access_token和refresh_token并且返回
    const {accessToken, refreshToken} = createDoubleTokne(userData);
    res.json({
        code:0,
        msg:'ok',
        data:{
            access_token:accessToken,
            refresh_token:refreshToken
        }
    })
}

// 使用jwt创建token
function createDoubleTokne(userData) {
    const accessToken = createToken(
        // 用户信息
        {
            userId: userData.id,
            username: userData.username
        },
        //密钥
        ACCESS_TOKEN_KEY,
        //配置过期时间等
        ACCESS_TOKEN_OPTIONS
    )

    const refreshToken = createToken(
        {
            userId: userData.id,
            username: userData.username
        },
        REFRESH_TOKEN_KEY,
        REFRESH_TOKEN_OPTIONS
    )
    return {
        accessToken,
        refreshToken
    }
}

createToken就是对jwt.sign()方法做了封装

const jwt = require('jsonwebtoken');

function createToken(secretKey, payload, options) {
    return jwt.sign(secretKey, payload, options);
}
module.exports = {
    createToken
}

解释一下这几个参数

  1. secretKey

    • 这是一个字符串或 Buffer,用于对生成的 JWT 进行签名。它类似于一个密钥,确保 JWT 的完整性和不可篡改性。只有知道这个密钥的服务器才能验证 JWT 的有效性。
  2. payload

    • 这是一个对象,包含需要存储在 JWT 中的用户信息或其他数据。例如:

      {
          userId: 1,
          username: 'john_doe',
          role: 'admin'
      }
      
  3. options

    • 这是一个对象,用于配置 JWT 的生成选项。常用的选项包括:

      • expiresIn:指定 JWT 的过期时间,可以是一个字符串,如 '1h'(1 小时)或 '7d'(7 天)。
      • algorithm:指定签名算法,默认是 'HS256'(HMAC 与 SHA-256)。

到了这里,我们的两个token就都生成完毕并且设置了access_token的过期时间为10秒,refresh_token的过期时间为120s,现在客户端是登录状态,向服务端请求一下数据

image.png

10s以后,access_token过期,但是refresh_token还没有过期,使用refresh_token去刷新access_token,然后用户仍然可以访问受限内容,但如果refresh_token过期了,那就得用户重新登录了,虽然refresh_token的过期时间很长,但是拿到refresh_token并不能访问受限内容

token无感刷新

上面说到,要使用refresh_token来刷新access_token,那么如何做到自动刷新呢(也就是无感刷新) 先从客户端请求受限数据,带上access_token

const loadCustomerInfo = async () => {
  const { data } = await axios({
    url: "http://localhost:8080/api/customer",
    method: 'POST',
    headers: {
      Authorization: `Bearer ${localStorage.getItem('access_token')}`
    },
    data: {
      userId: 1
    }
  })

来看服务端,服务端接收请求以后需要先验证token

module.exports = function(app) {
    return function(req,res,next) {
        app.post('/api/customer',verifyToken(ACCESS_TOKEN_KEY),getCustomer);
        next();
    }
}

先看验证token

const jwt = require('jsonwebtoken')

module.exports = function verifyToken(secretKey) {
    /**
     * 前端发送请求的时候携带token的地方和格式
     * 1. headers => authorization: Bearer token
     */
    return function (req, res, next) {
        // 获取到access_token
        const token = req.headers.authorization.split(' ')[1];
        // 使用jwt.verify来验证token
        jwt.verify(token, secretKey, (err, decoded) => {
            // 验证失败返回token无效
            if(err) {
                console.log(err);
                return res.json({
                    code:2,
                    msg: 'token无效'
                })
            }
            // 成功则返回decoded 里面包含了用户信息
            req.tokenInfo = decoded;
            next();
        })
    }
}

验证成功则返回数据,我们来看验证失败的情况

const loadCustomerInfo = async () => {
  const { data } = await axios({
    url: "http://localhost:8080/api/customer",
    method: 'POST',
    headers: {
      Authorization: `Bearer ${localStorage.getItem('access_token')}`
    },
    data: {
      userId: 1
    }
  })
  //验证成功返回code=0
    if (data.code === 0) {
    customerInfo.value = data.data
  }
  //验证失败返回2 需要用refresh_token刷新access_token
  if (data.code === 2) {
    refreshToken((data) => {
      if (data.code === 2) {
        isLogin.value = false
        return;
      }
      const {
        access_token,
        refresh_token
      } = data.data

      localStorage.setItem('access_token', access_token);
      localStorage.setItem('refresh_token', refresh_token);
      loadCustomerInfo();
    })
  }
}

来看一下refreshToken

async function refreshToken(callback) {
    // 携带refresh_token向服务端请求刷新accsess_token
    const { data } = await axios({
      url: "http://localhost:3000/auth/refresh",
      method: 'POST', 
      headers:{
        authorization: `Bearer ${localStorage.getItem('refresh_token')}`
      }
    })
    //将返回的data作为参数,调用callback函数
    callback(data);
  }

服务端是如何写的refresh请求呢?

module.exports = function router(app) {
    return function (req, res, next) {
        app.post('/auth/login', loginAction);
        //还是先校验refresh_token,校验完调用refreshToken
        app.post('/auth/refresh', verifyToken(REFRESH_TOKEN_KEY), refreshToken);
        next();
    }
}
exports.refreshToken = (req, res) => {
    // 记得刚才验证token的时候将用户信息存在tokenInfo中吗
    const {tokenInfo} = req;
    const {userId, username} = tokenInfo;
    // 校验用户信息
    const userData = users[username];
    
    // 校验成功则重新生成两个token,并且返回给客户端
    if(userData && userData.id === userId) {
        const {
            accessToken,
            refreshToken
        } = createDoubleTokne(userData);
        return res.json({
            code:0,
            msg:'ok',
            data:{
                access_token:accessToken,
                refresh_token:refreshToken
            }
        })
    }
    // 校验失败则说明需要重新登录了
    res.json({
        code:1,
        msg:'需要重新登录'
    })
}

再重新回到客户端,我们已经获取到了新的token

if (data.code === 2) {
    refreshToken((data) => {
        // 如果失败则说明需要重新登录
      if (data.code === 2) {

        isLogin.value = false
        return;
      }
      // 成功了则重新储存access_token和refresh_token
      const {
        access_token,
        refresh_token
      } = data.data

      localStorage.setItem('access_token', access_token);
      localStorage.setItem('refresh_token', refresh_token);
      //记得需要重新调用方法获取受限资源
      loadCustomerInfo();
    })
  }

到这里单点登录算是完成了...

总结

不知道大家对登录还有什么其他的idea...