You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter中能否使用Digest Authentication获取Onvif摄像头JPEG快照?

Flutter中通过Digest Authentication获取Onvif设备JPEG快照

当然可以实现,核心是处理Digest认证的双向握手流程——因为CachedNetworkImage默认只支持Basic Auth,所以需要自定义HTTP请求逻辑或扩展它的客户端来适配Digest认证。以下是两种可行方案:

方案1:手动处理Digest认证,直接获取图片字节流

使用http包手动完成认证握手,拿到图片字节后用Image.memory显示,适合不需要缓存的场景:

步骤1:依赖准备

确保在pubspec.yaml中添加依赖:

dependencies:
  http: ^1.1.0
  crypto: ^3.0.3

步骤2:实现认证请求函数

import 'dart:convert';
import 'dart:typed_data';
import 'dart:math';
import 'package:http/http.dart' as http;
import 'package:crypto/crypto.dart';

Future<Uint8List?> fetchOnvifSnapshot(String url, String username, String password) async {
  final client = http.Client();
  try {
    // 1. 发送初始请求,触发认证挑战
    var response = await client.get(Uri.parse(url));
    if (response.statusCode == 200) {
      return response.bodyBytes;
    } else if (response.statusCode != 401) {
      throw Exception('请求失败:${response.statusCode}');
    }

    // 2. 解析服务器返回的Digest认证头
    final authHeader = response.headers['www-authenticate']!;
    final match = RegExp(r'digest realm="([^"]+)", nonce="([^"]+)"').firstMatch(authHeader);
    if (match == null) throw Exception('无法解析认证头');

    final realm = match.group(1)!;
    final nonce = match.group(2)!;
    final uri = Uri.parse(url);
    final method = 'GET';
    final qop = 'auth';
    final nc = '00000001'; // 请求计数,首次为1,后续需递增
    final cnonce = _generateCnonce();

    // 3. 计算Digest响应所需的哈希值
    final ha1 = md5.convert(utf8.encode('$username:$realm:$password')).toString();
    final ha2 = md5.convert(utf8.encode('$method:${uri.path}')).toString();
    final responseDigest = md5.convert(utf8.encode('$ha1:$nonce:$nc:$cnonce:$qop:$ha2')).toString();

    // 4. 构造认证头并重新发送请求
    final authResponse = 'Digest username="$username", realm="$realm", nonce="$nonce", uri="${uri.path}", qop=$qop, nc=$nc, cnonce="$cnonce", response="$responseDigest"';
    response = await client.get(Uri.parse(url), headers: {'Authorization': authResponse});

    if (response.statusCode == 200) {
      return response.bodyBytes;
    } else {
      throw Exception('认证失败:${response.statusCode}');
    }
  } finally {
    client.close();
  }
}

// 生成随机cnonce值
String _generateCnonce() {
  final random = Random.secure();
  final bytes = Uint8List(16);
  for (var i = 0; i < 16; i++) {
    bytes[i] = random.nextInt(256);
  }
  return base64.encode(bytes);
}

步骤3:在Widget中使用

FutureBuilder<Uint8List?>(
  future: fetchOnvifSnapshot('http://你的相机IP/onvif/snapshot', '用户名', '密码'),
  builder: (context, snapshot) {
    switch (snapshot.connectionState) {
      case ConnectionState.waiting:
        return const CircularProgressIndicator();
      case ConnectionState.done:
        if (snapshot.hasError) {
          return Text('加载失败:${snapshot.error}');
        }
        if (snapshot.hasData && snapshot.data != null) {
          return Image.memory(snapshot.data!);
        }
        return const Text('无图片数据');
      default:
        return const SizedBox.shrink();
    }
  },
)

方案2:自定义CachedNetworkImage的HTTP客户端,支持Digest认证

如果需要图片缓存功能,可以封装一个支持Digest认证的HTTP客户端,传给CachedNetworkImage:

实现自定义客户端

class DigestAuthClient extends http.BaseClient {
  final http.Client _innerClient;
  final String username;
  final String password;
  int _requestCount = 1; // 用于递增nc值

  DigestAuthClient(this._innerClient, {required this.username, required this.password});

  @override
  Future<http.StreamedResponse> send(http.BaseRequest request) async {
    var response = await _innerClient.send(request);
    if (response.statusCode == 401) {
      final authHeader = response.headers['www-authenticate'];
      if (authHeader != null && authHeader.startsWith('digest')) {
        final match = RegExp(r'digest realm="([^"]+)", nonce="([^"]+)"').firstMatch(authHeader);
        if (match != null) {
          final realm = match.group(1)!;
          final nonce = match.group(2)!;
          final uri = request.url;
          final method = request.method;
          final qop = 'auth';
          final nc = '${_requestCount.toString().padLeft(8, '0')}';
          final cnonce = _generateCnonce();

          final ha1 = md5.convert(utf8.encode('$username:$realm:$password')).toString();
          final ha2 = md5.convert(utf8.encode('$method:${uri.path}')).toString();
          final responseDigest = md5.convert(utf8.encode('$ha1:$nonce:$nc:$cnonce:$qop:$ha2')).toString();

          final authResponse = 'Digest username="$username", realm="$realm", nonce="$nonce", uri="${uri.path}", qop=$qop, nc=$nc, cnonce="$cnonce", response="$responseDigest"';
          
          request.headers['Authorization'] = authResponse;
          _requestCount++;
          response = await _innerClient.send(request);
        }
      }
    }
    return response;
  }

  String _generateCnonce() {
    final random = Random.secure();
    final bytes = Uint8List(16);
    for (var i = 0; i < 16; i++) {
      bytes[i] = random.nextInt(256);
    }
    return base64.encode(bytes);
  }
}

步骤2:在CachedNetworkImage中使用

CachedNetworkImage(
  imageUrl: 'http://你的相机IP/onvif/snapshot',
  httpClient: DigestAuthClient(http.Client(), username: '用户名', password: '密码'),
  placeholder: (context, url) => const CircularProgressIndicator(),
  errorWidget: (context, url, error) => const Icon(Icons.error_outline),
)

注意事项

  • 不同Onvif设备的快照URL可能存在差异,常见路径有/snapshot、/onvif/media/snapshot,需根据设备文档确认
  • 若需多次请求,建议维护nc(请求计数)的递增逻辑,避免认证失败
  • 可添加异常捕获逻辑,处理网络断开、设备离线等场景

内容的提问来源于stack exchange,提问作者TheDizzyEgg

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.29 19:12:06