【实战】用户登录时如何使用token进行验证

469 阅读5分钟

之前的文章,现在实战有了更深的理解,可能还有一些错误之处,后续更改 【项目问题】token存储在哪里以及localStorage的跨域问题 - 掘金 (juejin.cn)

需求

使用koa+lowdb服务端框架与React实现一个登录页面,需要进行csrf攻击防御和token验证

登录流程

  • 用户第一次登录时,向服务端发送login请求
  • 服务端处理login请求,判断用户名密码是否正确。如果正确,使用密钥生成jwt_token
  • 服务端将token放在response的cookie中发送给用户

❗token发送给客户端的最常见方式是通过HTML表单的隐藏字段。服务端在生成表单页时,将token放入表单的隐藏字段中,确保每次表单提交时都携带token。这里直接放在了token中,且并没有设置http-only,不够安全

router.post('/api/login', async (ctx) => {
  const { username, password } = ctx.request.body;
  const user = userdb.data.users.find(user => user.username === username && user.password === password);

  if (user) {
    const token = jwt.sign({ username }, secret, { expiresIn: '1h' }); //使用jwt
    const csrfToken = csrfTokens.create(csrfSecret);
    ctx.cookies.set('token', token, { httpOnly: false });
    ctx.cookies.set('csrfToken', csrfToken,{ path: '/', httpOnly: false }); 
    ctx.body = { token};
  } else {
    ctx.status = 401; // 未授权
    ctx.body = { message: 'Invalid credentials' };
  }
});
  • 用户收到响应后从cookie中获取token(如果cookie设置了http-only则无法获取),将token放入localstorage中,路由页面通过判断localstorage有无token来进行页面跳转
  const isAuthenticated = () => {
    return !!localStorage.getItem('jwt_token'); // 检查 JWT token 是否存在
  };
<Route 
  path="/index" 
  element={
    isAuthenticated() ? (
      <div className="container">
        <Hello/>
      </div>
    ) : (
      <Navigate to="/login" />
    )
  } 
/>
  • 后续登录时,cookie会随着请求发送到服务端,服务端获取请求cookie中的token并使用密钥进行验证,如果验证成功则登录成功
const authMiddleware = async (ctx, next) => {
  const token = ctx.cookies.get('token');
  const csrfToken = ctx.cookies.get('csrfToken');

  if (!token) {
    ctx.status = 401;
    ctx.body = { message: 'No token provided' };
    return;
  }
    const decoded = jwt.verify(token, secret);
    if (!csrfTokens.verify(csrfSecret, csrfToken)) {
      ctx.status = 403;
      ctx.body = { message: 'Invalid CSRF token' };
      return;
    }
    ctx.state.user = decoded;
    await next();
};

服务端验证token

除了从cookie中获取token进行验证,还有其他方法

请求头中获取

  • 用户登录时将token放入请求头中
const handleLogin = async () => {
  try {
    const csrfToken = getCsrfToken();
    const response = await axios.post('http://localhost:3001/api/login', {
      username: DOMPurify.sanitize(username),
      password: DOMPurify.sanitize(password),
    }, {
      headers: {
        'CSRF-Token': csrfToken, // CSRF 令牌
        'Authorization': `Bearer ${localStorage.getItem('jwt_token')}` // JWT 令牌
      },
      withCredentials: true, //自动携带cookie
    });
    const { token } = response.data;
    localStorage.setItem('jwt_token', token);
    localStorage.setItem('username', username);
    try {
      await TodoManager.getTodos(); // 确保这里没有抛出异常
      console.log('Fetched todos successfully');
    } catch (error) {
      console.error('Failed to fetch todos:', error);
    }
    navigate('/hello');
  } catch (error) {
    message.error('登录失败');
  }
};

  • 服务端从请求头中获取token并进行验证
const authMiddleware = async (ctx, next) => {
  const token = ctx.headers['authorization'] ? ctx.headers['authorization'].split(' ')[1] : null;
  const csrfToken = ctx.headers['csrf-token'];
  if (!token) {
    ctx.status = 401;
    ctx.body = { message: 'No token provided' };
    return;
  }
  try {
    const decoded = jwt.verify(token, secret);
    if (!csrfTokens.verify(csrfSecret, csrfToken)) {
      ctx.status = 403;
      ctx.body = { message: 'Invalid CSRF token' };
      return;
    }
    ctx.state.user = decoded;
    await next();
  } catch (err) {
    ctx.status = 401;
    ctx.body = { message: 'Invalid token' };
  }
};

csrf_token验证

  1. 在用户会话初始化时生成一个唯一的 CSRF token

    • 将该 token 存储在服务器端会话中(如果使用会话存储)。
    • 将 token 发送给客户端,并保存在客户端的 cookie 中。
  2. 在每个请求中,客户端发送 token

    • 在请求头中添加 CSRF token。
    • 同时,cookie 也会自动包含 CSRF token。
  3. 服务器端验证

    • 服务器从 cookie 和请求头中分别读取 CSRF token。
    • 比较这两个 token 是否一致。
    • 验证 token 是否为服务器生成的合法 token。
const authMiddleware = async (ctx, next) => {
  const token = ctx.headers['authorization'] ? ctx.headers['authorization'].split(' ')[1] : null;
  const csrfTokenFromHeader = ctx.headers['csrf-token'];
  const csrfTokenFromCookie = ctx.cookies.get('csrfToken');
  if (!token) {
    ctx.status = 401;
    ctx.body = { message: 'No token provided' };
    return;
  }
  try {
    const decoded = jwt.verify(token, secret);
    if (!csrfTokenFromHeader || csrfTokenFromHeader !== csrfTokenFromCookie) {
      ctx.status = 403;
      ctx.body = { message: 'Invalid CSRF token' };
      return;
    }
    ctx.state.user = decoded; // 保存解码后的用户信息
    await next();
  } catch (err) {
    ctx.status = 401;
    ctx.body = { message: 'Invalid token' };
  }
};

csrf补充

CSRF 攻击原理

  1. 用户登录受信任网站 A

    • 用户在浏览器中访问并登录网站 A,浏览器保存了网站 A 的会话 cookie。
  2. 攻击者构造恶意请求

    • 攻击者诱导用户访问攻击者控制的恶意网站 B。
    • 恶意网站 B 构造一个针对网站 A 的请求,例如发起一个数据修改请求。
  3. 浏览器自动携带 cookie

    • 用户点击恶意链接或加载恶意网站 B 后,浏览器会自动携带网站 A 的会话 cookie,发送请求到网站 A。
  4. 网站 A 误认为是合法用户请求

    • 由于请求包含合法的会话 cookie,网站 A 误以为这是用户自己发起的请求,从而执行了攻击者的恶意操作。

CSRF token 防御机制

CSRF token 是一种独特的、不可预测的字符串,通常在用户会话初始化时生成,并在随后的每个请求中验证。以下是 CSRF token 的工作原理:

  1. 生成 CSRF token

    • 当用户访问网站并创建会话时,服务器生成一个唯一的 CSRF token,并将其存储在服务器端(例如,存储在用户的会话中)。
    • 服务器同时将 CSRF token 发送给客户端,通常通过设置 HttpOnly cookie 或在 HTML 中嵌入一个隐藏字段或 meta 标签。
  2. 客户端发送请求时包含 CSRF token

    • 在客户端发送请求时,JavaScript 从 meta 标签、隐藏字段或其他存储中读取 CSRF token,并将其添加到请求头或请求体中。
  3. 服务器验证 CSRF token

    • 服务器接收到请求后,从请求头或请求体中提取 CSRF token,并与存储在服务器端的 token 进行比较。
    • 如果 CSRF token 验证成功,则请求被认为是合法的;否则,拒绝该请求。

CSRF token 的防御原理

CSRF token 防御 CSRF 攻击的原理在于攻击者无法获得受害者的 CSRF token。即使攻击者能够诱导受害者的浏览器发送请求,攻击者也无法知道或猜测正确的 CSRF token。因为:

  • 唯一性:每个会话的 CSRF token 都是唯一的,且通常是随机生成的,难以被猜测。
  • 不可预测性:CSRF token 的生成算法使得它无法被预测,即使攻击者知道某个 token 也无法推测出其他 token。
  • 双向验证:服务器同时验证会话 cookie 和 CSRF token,确保请求确实来自合法用户。