首页 关于 文章 作品集 工具 联系
返回文章列表

Flutter + Node.js:安全认证体系设计与最佳实践

认证和安全是移动应用后端服务中最关键的环节之一。一个设计不当的认证体系可能导致用户数据泄露、接口被恶意调用、甚至整个系统被攻破。这篇文章记录了我在 Flutter + Node.js 项目中构建生产级安全认证体系的完整思路和实现细节。

安全不是一个功能模块,而是一种贯穿整个系统设计的思维方式。每一次 API 调用、每一次数据传输、每一条存储的记录,都应该经过安全评估。

一、JWT 双 Token 机制

单一 Token 的方案有一个明显的问题:Token 过期后用户必须重新登录,体验很差。但如果延长 Token 有效期,一旦被截获风险又太大。解决方案是采用双 Token 机制:Access Token(短期有效)+ Refresh Token(长期有效)。

// 生成 Access Token(有效期 30 分钟)
const generateAccessToken = (userId) => {
  return jwt.sign(
    { id: userId, type: 'access' },
    process.env.JWT_SECRET,
    { expiresIn: '30m' }
  );
};

// 生成 Refresh Token(有效期 7 天)
const generateRefreshToken = (userId) => {
  return jwt.sign(
    { id: userId, type: 'refresh' },
    process.env.JWT_REFRESH_SECRET,  // 使用独立的密钥
    { expiresIn: '7d' }
  );
};

登录成功后同时返回两个 Token:

const login = async (req, res) => {
  const { email, password } = req.body;

  const user = await User.findOne({ email }).select('+password');
  if (!user || !(await user.matchPassword(password))) {
    return errorResponse(res, '邮箱或密码错误', 401);
  }

  const accessToken = generateAccessToken(user._id);
  const refreshToken = generateRefreshToken(user._id);

  // Refresh Token 存储到数据库(可随时撤销)
  await Token.create({
    userId: user._id,
    token: refreshToken,
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
  });

  successResponse(res, {
    accessToken,
    refreshToken,
    user: { id: user._id, username: user.username, email: user.email }
  });
};

二、Refresh Token 轮换

每次使用 Refresh Token 换取新的 Access Token 时,旧的 Refresh Token 立即失效,同时生成一个新的 Refresh Token。这种「一次性使用」策略可以有效防止 Token 重放攻击:

const refreshToken = async (req, res) => {
  const { refreshToken: token } = req.body;

  if (!token) {
    return errorResponse(res, '缺少 Refresh Token', 400);
  }

  // 验证 Token 签名
  const decoded = jwt.verify(token, process.env.JWT_REFRESH_SECRET);

  // 确认是 refresh 类型
  if (decoded.type !== 'refresh') {
    return errorResponse(res, 'Token 类型错误', 401);
  }

  // 在数据库中查找并删除旧 Token
  const storedToken = await Token.findOneAndDelete({
    userId: decoded.id,
    token,
    expiresAt: { $gt: new Date() }
  });

  if (!storedToken) {
    // Token 可能已被使用过或已过期 → 可能存在盗用
    await Token.deleteMany({ userId: decoded.id });
    return errorResponse(res, 'Refresh Token 已失效,请重新登录', 401);
  }

  // 生成新的 Token 对
  const newAccessToken = generateAccessToken(decoded.id);
  const newRefreshToken = generateRefreshToken(decoded.id);

  await Token.create({
    userId: decoded.id,
    token: newRefreshToken,
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
  });

  successResponse(res, {
    accessToken: newAccessToken,
    refreshToken: newRefreshToken
  });
};

三、接口防刷:Rate Limiting

暴力破解、短信轰炸、恶意注册……这些攻击都可以通过接口限流来有效防护。使用 express-rate-limit 实现基于 IP 的请求频率限制:

const rateLimit = require('express-rate-limit');

// 通用限流:每 IP 每分钟最多 100 次请求
const generalLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 100,
  message: {
    success: false,
    message: '请求过于频繁,请稍后再试'
  },
  standardHeaders: true,
  legacyHeaders: false
});

// 登录接口限流:每 IP 每小时最多 10 次
const loginLimiter = rateLimit({
  windowMs: 60 * 60 * 1000,
  max: 10,
  message: {
    success: false,
    message: '登录尝试次数过多,请1小时后再试'
  },
  skipSuccessfulRequests: true  // 登录成功不计入
});

// 应用限流
app.use('/api/', generalLimiter);
app.use('/api/v1/auth/login', loginLimiter);

四、安全中间件配置

使用 helmet 一键配置常用的 HTTP 安全头,防止 XSS、点击劫持、MIME 类型嗅探等常见攻击:

const helmet = require('helmet');
const hpp = require('hpp');

app.use(helmet());
app.use(hpp());  // 防止 HTTP 参数污染

// 禁用不必要的响应头
app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    imgSrc: ["'self'", "data:", "https:"],
  }
}));

五、输入校验:防止注入攻击

永远不要信任客户端传来的数据。使用 express-validator 对每个接口的输入参数进行严格校验:

const { body, validationResult } = require('express-validator');

const validateRegister = [
  body('username')
    .trim()
    .isLength({ min: 2, max: 20 })
    .withMessage('用户名需为 2-20 个字符')
    .matches(/^[\u4e00-\u9fa5a-zA-Z0-9_]+$/)
    .withMessage('用户名只能包含中英文、数字和下划线'),

  body('email')
    .isEmail()
    .normalizeEmail()
    .withMessage('邮箱格式不正确'),

  body('password')
    .isLength({ min: 6, max: 32 })
    .withMessage('密码需为 6-32 个字符')
    .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
    .withMessage('密码需包含大小写字母和数字'),

  // 校验处理中间件
  (req, res, next) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({
        success: false,
        errors: errors.array().map(e => ({
          field: e.path,
          message: e.msg
        }))
      });
    }
    next();
  }
];

// 使用
router.post('/register', validateRegister, register);

六、Flutter 端安全存储与自动刷新

在 Flutter 端,Token 必须使用 flutter_secure_storage 安全存储,绝不能使用明文的 SharedPreferences

import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:dio/dio.dart';

class AuthManager {
  static const _storage = FlutterSecureStorage(
    aOptions: AndroidOptions(encryptedSharedPreferences: true),
    iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
  );

  static const _accessTokenKey = 'access_token';
  static const _refreshTokenKey = 'refresh_token';

  // 保存 Token
  static Future<void> saveTokens({
    required String accessToken,
    required String refreshToken,
  }) async {
    await _storage.write(key: _accessTokenKey, value: accessToken);
    await _storage.write(key: _refreshTokenKey, value: refreshToken);
  }

  // 自动刷新 Token 的拦截器
  static Interceptor get authInterceptor => InterceptorsWrapper(
    onError: (error, handler) async {
      if (error.response?.statusCode == 401) {
        // 尝试用 Refresh Token 换取新的 Access Token
        final newTokens = await _refreshAccessToken();
        if (newTokens != null) {
          // 更新请求头并重试原请求
          error.requestOptions.headers['Authorization'] =
              'Bearer ${newTokens['accessToken']}';
          return handler.resolve(await _dio.fetch(error.requestOptions));
        }
        // Refresh Token 也过期了 → 跳转登录页
        await logout();
      }
      handler.next(error);
    },
  );
}

七、HTTPS 与数据加密传输

生产环境必须使用 HTTPS。如果在 Node.js 中自托管,可以使用 letsencrypt 免费证书:

const https = require('https');
const fs = require('fs');

const options = {
  key: fs.readFileSync('/etc/letsencrypt/live/yourdomain.com/privkey.pem'),
  cert: fs.readFileSync('/etc/letsencrypt/live/yourdomain.com/fullchain.pem'),
};

https.createServer(options, app).listen(443, () => {
  
});

同时配置 HTTP 自动重定向到 HTTPS:

const http = require('http');
http.createServer((req, res) => {
  res.writeHead(301, {
    Location: `https://${req.headers.host}${req.url}`
  });
  res.end();
}).listen(80);

总结

一个生产级的安全认证体系需要从多个维度进行防护:

身份认证层面:JWT 双 Token 机制 + Refresh Token 轮换,兼顾安全性与用户体验。Access Token 短期有效降低泄露风险,Refresh Token 一次性使用防止重放攻击。

接口防护层面:Rate Limiting 限制请求频率,输入校验防止注入攻击,Helmet 加固 HTTP 安全头。

数据传输层面:强制 HTTPS 加密传输,敏感数据加密存储。

客户端层面:使用 flutter_secure_storage 安全存储凭证,拦截器自动处理 Token 过期和刷新。

安全没有银弹,最好的策略是「纵深防御」——每一层都有自己的安全措施,即使某一层被突破,后面的层仍然能提供保护。这套方案虽然不是最复杂的,但在大多数中小型项目中已经足够安全可靠。

已是最新