← 返回蜂巢洞察

API认证与授权机制:深入探讨其工作原理、权衡因素以及可能出现的故障类型

每种API都包含某种形式的认证机制。但仅仅拥有认证机制与正确实施这一机制是两回事。 我曾经见过一些生产环境中的系统:在这些系统中,JWT令牌没有过期时间限制;有些系统的API密钥被硬编码在源代码中,并被提交到了公共代码仓库中;还有一些系统在使用OAuth重定向URL时使用了通配符;还有些系统在处理金融相关的API时使用了基本认证机制,而这些API本应通过HTTPS进行传输,但却没有人对此进行检查。 上述每一个情况都构成了潜在的安全漏洞,随时可能被恶意利用。 这些问题的产生,并非源于粗心的工程师。而是那些虽然了解各种认证机制的工作原理,却不了解它们在出现故障时会如何导致问题发生的工程师。没有人告

每种API都包含某种形式的认证机制。但仅仅拥有认证机制与正确实施这一机制是两回事。

我曾经见过一些生产环境中的系统:在这些系统中,JWT令牌没有过期时间限制;有些系统的API密钥被硬编码在源代码中,并被提交到了公共代码仓库中;还有一些系统在使用OAuth重定向URL时使用了通配符;还有些系统在处理金融相关的API时使用了基本认证机制,而这些API本应通过HTTPS进行传输,但却没有人对此进行检查。

上述每一个情况都构成了潜在的安全漏洞,随时可能被恶意利用。

这些问题的产生,并非源于粗心的工程师。而是那些虽然了解各种认证机制的工作原理,却不了解它们在出现故障时会如何导致问题发生的工程师。没有人告诉他们这些机制出错时会发生什么,也没有人明确规定组织应该采用哪些标准。这些工程师只是选择了自己熟悉的技术手段,将其实现得足够好以通过代码审查,然后就继续开展后续工作了。

这篇文章的目的就是改变这种现状。它不仅会解释各种认证机制的工作原理,还会说明在什么情况下应该使用它们,在什么情况下不应该使用它们,以及这些机制在生产环境中究竟会如何出现故障。

首先,让我们澄清一个常常导致安全漏洞的误解:认证用来回答“你是谁?”这个问题,而授权则用于回答“你被允许做什么?”这个问题。

一个认证机制完善但授权机制不完善的系统,仍然会向用户提供未经授权的数据;而一个授权机制完善但认证机制薄弱的系统,也很容易被攻击者绕过。这两种机制都必须独立地做到正确无误才行。

目录

先决条件

在阅读本文之前,您应该已经掌握了以下知识:

  • 什么是API,以及HTTP请求和响应的工作原理

  • 对令牌或会话机制的基本了解

  • 软件架构的基础概念:了解网关和服务层的功能

  • 熟悉Dart或C#的语法

你并不需要具备安全相关背景知识。这里介绍的每一个概念都是从工程技术的角度进行解释的。

基础要求:在选择具体机制之前必须确保这些条件得到满足

在考虑使用哪种机制之前,有三件事必须是到位的。如果这些条件缺失,任何机制都无法为你提供保护。

1. TLS是必不可少的

所有的API都是通过HTTPS进行通信的。无论是生产环境还是测试环境,所有端点都是如此——不仅仅是那些处理卡号信息的端点,全部端点都是这样。

如果没有TLS,本文中提到的任何机制都可能被截获。基本认证令牌、API密钥、bearer令牌以及JWT这些数据都是通过HTTP头部传输的。而在没有TLS的情况下,HTTP头部所传递的信息就是明文形式。

你的基础设施至少必须支持TLS 1.2协议。TLS 1.0和1.1存在已知的安全漏洞,而SSLv3则完全不可靠。如果客户端尝试使用较旧的协议版本进行连接,那么在基础设施层就必须拒绝该连接请求。这是一个配置方面的要求,并不是通过代码来实现的。

2. 生产环境中的API必须受到适当控制,不能通过公共工具直接访问

如果生产环境中的API能够通过Postman或Swagger等工具在互联网上被直接访问,且没有相应的访问控制机制,那么这就一定会引发安全问题。

开发和测试环境应该使用专用的环境,并使用不同的认证凭证;这些环境和工具绝对不能访问生产环境中的数据。

3. 生产数据不得被复制到开发或测试环境中

这不仅仅是一种良好的实践原则。根据尼日利亚的NDPA 2023法规,个人数据的处理必须仅限于明确规定的、合法的用途。将生产环境中的个人数据复制到开发环境中,会直接导致法律风险。而根据PCI-DSS标准,任何存储、处理或传输持卡人信息的环境都必须符合相关合规要求。

从工程技术的角度来看,在所有非生产环境中,都应该使用合成测试数据或经过匿名处理的数据库数据。

满足了这些要求之后,下面就来介绍七种具体的机制吧。

1. 基本认证

工作原理

客户端在每次请求时都会发送用户名和密码。这些凭证会被组合成`username:password`的形式,然后使用Base64编码,并放在Authorization头部中。

Authorization: Basic am9objpzZWNyZXQxMjM=

上述编码后的字符串在Base64格式下表示为`john:secret123`。需要注意的是,Base64其实并不是一种加密技术,而只是一种编码方式。任何能够捕获到这个头部信息的人,都可以使用网上现有的任何Base64解码工具在几秒钟内将其解码。

Dart语言中的实现:

// 服务器端的基本认证验证逻辑
String? extractBasicAuthCredentials(Request request) {
  final authHeader = request.headers['authorization'];
  if (authHeader == null || !authHeader.startsWith('Basic ')) return null;

  final encoded = authHeader.substring(6);
  final decoded = utf8.decode(base64decode(encoded));
  return decoded; 
}

Handler basicAuthMiddleware(Handler handler, UserService userService) {
  return (Request request) async {
    final credentials = extractBasicAuthCredentials(request);
    if (credentials == null) {
      return Response.unauthorized(
        '缺少认证信息',
        headers: {'WWW-Authenticate': 'Basic realm="API"'},
      );
    }

    final parts = credentials.split(':');
    if (parts.length != 2) return Response.unauthorized('认证信息无效');

    final isValid = await userService.validateCredentials(parts[0], parts[1]);
    if (!isValid) return Response.unauthorized('认证信息无效');

    return handler(request);
  };
}
C#:
public class BasicAuthHandler : AuthenticationHandler
{
    private readonly IUserService _userService;

    protected override async Task HandleAuthenticateAsync()
    {
        if (!RequestHeaders.ContainsKey("Authorization"))
            return AuthenticateResult FAIL("缺少Authorization头");

        var authHeader = Request Headers["Authorization"].ToString();
        if (!authHeader.startsWith("Basic "))
            return AuthenticateResult_fail("无效的授权方案");

        var encoded = authHeader.Substring(6);
        var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(encoded));
        var parts = decoded.Split(':');

        if (parts.Length != 2)
            returnAuthenticateResult FAIL("凭证格式无效");

        var isValid = await _userServicevalidateCredentials(parts[0], parts[1]);
        if (!isValid)
            return AuthenticateResult_fail("凭证无效");

        var claims = new[] { new Claim(ClaimTypes.Name, parts[0]) };
        var identity = new ClaimsIdentity(claims, Scheme.Name);
        var principal = new ClaimsPrincipalidentity);
        var ticket = new AuthenticationTicket(principal, Scheme.Name);

        returnAuthenticateResult.Success(ticket);
    }
}

基本认证的真正问题

凭证会随每个请求一起被发送。如果攻击者截获了某个请求,他们就能永久获得用户的用户名和密码。这些凭证没有过期期限,也不可以通过更改密码来撤销其效力。使用Base64编码进行传输根本无法提供任何安全保障。

对于使用基本认证的接口来说,根本不存在速率限制机制,这就为暴力攻击提供了可乘之机。拥有常见密码列表的攻击者会系统地尝试每一组密码,如果没有任何阻止措施,他们最终肯定能够入侵系统。

何时使用基本认证

在环境受到严格控制的情况下,例如服务器之间的内部通信中,由于连接始终处于加密状态,且API接口并未暴露在互联网上,因此基本认证是可以使用的。但绝对不要将其用于面向用户的API、处理敏感数据的场景,也不应该在没有TLS加密机制的情况下使用它。

2. API密钥

API密钥其实就是服务器发给客户端的一组唯一标识字符串。当你注册使用第三方服务(比如支付网关或短信平台)时,系统会为你生成一个API密钥。你的应用程序在向这些服务发送请求时,都必须包含这个密钥,这样服务才能识别出是你的请求,同时也能追踪你的使用情况,并根据需要为你的请求设置相应的权限和速率限制。

API密钥是与用户个人无关的,而是与你的应用程序相关联的。这就是API密钥与用户认证令牌之间的根本区别。

工作原理

服务器会向客户端提供一组固定的秘密字符串,客户端在每次发送请求时都会将这个字符串作为请求头的一部分一并发送出去。

X-API-Key: sk_live_abc123xyz

Dart:

class ApiKeyService {
  final ApiKeyRepository _repository;

  ApiKeyService(this._repository);

  Future〈Result〈ApiKeyContext, AppException〉>> validateApiKey(
    String apiKey,
    String callerDomain,
  ) async {
    final keyRecord = await _repository.findByKey(apiKey);

    if (keyRecord == null) {
      return Result.failure(AppException.unauthorized('无效的API密钥'));
    }

    if (keyRecord.isExpired) {
      return Result FAILURE(AppException.unauthorized('API密钥已过期'));
    }

    if (keyRecord.isRevoked) {
      return Result.Failure(AppException.unauthorized('API密钥已被吊销'));
    }

    // 验证调用域是否被允许使用该API密钥
    if (!keyRecord.allowedDomains.contains(callerDomain)) {
      return Result.failure(
        AppException.forbidden('该API密钥不允许用于当前调用域'),
      );
    }

    return Result.success(ApiKeyContext(
      clientId: keyRecord.clientId,
      environment: keyRecord.environment,
      allowedScopes: keyRecordallowedScopes,
    ));
  }
}

HandlerApiKeyMiddleware(Handler handler, ApiKeyService apiKeyService) {
  return (Request request) async {
    final apiKey = request.headers['x-api-key'];
    if (apiKey == null || apiKey.isEmpty) {
      return Response(401, body: jsonEncode({'error': '需要API密钥'}));
    }

    final origin = requestheaders['origin'] ?? request.headers['host'] ?? '';
    final result = await apiKeyService.validateApiKey(apiKey, origin);

    if (result.isFailure) {
      return Response(403, body: jsonEncode(result.error?.message)));
    }

    return handler(request);
  };
}

C#:

public class ApiKeyMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IApiKeyService _apiKeyService;

    public async Task InvokeAsync(HttpContext context)
    {
        if (!context.RequestHeaders.ContainsKey("X-API-Key", out var apiKey))
        {
            context.Response.StatusCode = 401;
            await context.Response.WriteAsync("需要API密钥");
            return;
        }

        var callerDomain = context.Request Headers["Origin"].ToString()
            ?? context.Request.Host.Value;

        var result = await _apiKeyService.validateApiKey(apiKey, callerDomain);

        if (!result.IsSuccess)
        {
            context.Response.StatusCode = 403;
            await context.Response.WriteAsync(result.Error);
            return;
        }

        context.Items["ApiKeyContext"] = result.Value;
        await _next(context);
    }
}

API密钥的真正问题

API密钥是静态的,它们不会自动过期。如果有人将API密钥放在公共的GitHub仓库中、通过Slack消息或日志文件进行共享,那么这个密钥就会一直处于可用状态,直到有人发现并将其更换为止。

我在大型组织中经常看到的一种常见问题是:被多个客户端调用的API没有设置特定的允许访问域名。这些API密钥可以在任何域名下使用,这使得不同客户端和不同环境之间可以共享这些密钥。例如,测试环境的密钥可以被用于生产环境,移动端使用的密钥也可以被Web客户端调用,它们之间实际上没有任何界限。

任何API密钥都不应该能在多个客户端或多种环境中通用。密钥必须与特定的允许访问域名及特定环境绑定在一起,这一点是绝对必要的。

千万不要将API密钥硬编码到源代码中,也绝不要将其提交到Git仓库中。《.gitignore》文件是无法起到保护作用的。这些密钥必须在运行时从可靠的密钥管理工具中获取,比如Vault、Azure App Configuration或AWS Secrets Manager。API密钥的轮换应该成为组织的一项常规操作,而不是在怀疑发生泄漏后才采取的措施。

何时使用API密钥

在服务器之间的通信中、需要识别调用者并对其进行速率限制的公共API中,以及开发者工具和集成系统中,都应该使用API密钥。总之,在任何人类用户不是直接调用者的场景下,都应使用API密钥。

3. 承载令牌认证

工作原理

客户端在完成登录流程后会获得一个令牌,这个令牌会在后续的所有请求中作为`Authorization`头部字段被发送。

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

在初次登录之后,客户端不再需要提供其他认证信息,只需要携带这个令牌即可。服务器会在每次请求时验证这个令牌的有效性。

Dart语言示例:

class BearerTokenMiddleware {
  final TokenValidator _validator;

  BearerTokenMiddleware(this._validator);

  Handler call(Handler handler) {
    return (Request request) async {
      final authHeader = request.headers['authorization'];

      if (authHeader == null || !authHeader.startsWith('Bearer ')) {
        return Response(401, body: jsonEncode({'error': '需要承载令牌'}));
      }

      final token = authHeader.substring(7);
      final validationResult = await _validator.validate(token);

      if (validationResult.isFailure) {
        return Response(401, body: jsonEncode({'error': validationResult.error?.message]));
      }

      final updatedRequest = request.change(
        context: {'auth_claims': validationResult.value},
      );

      return handler(updatedRequest);
    };
  }
}

C#语言示例:

builder.services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options-tokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidAudience = builderConfiguration["Jwt:Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!)
            ),
            ClockSkew = TimeSpan_zero
        };
    });

bearer Tokens的真实问题

令牌被盗用是最大的问题。如果攻击者获得了有效的bearer token,他们就可以一直使用该令牌,直到它过期或被手动撤销。正因如此,令牌的过期机制其实并不是什么“可有可无”的功能——正是这一机制在令牌被泄露时限制了可能造成的损害。采用短期有效期的访问令牌,并结合令牌更新机制,才是正确的解决方案。

何时使用bearer-tokens

在大多数现代的Web和移动API中,在用户认证流程中,以及在任何需要无状态、可扩展认证功能的API中,bearer-tokens都能发挥很好的作用。

4. JWT——JSON Web Token

工作原理

JWT是一种专门用于表示bearer tokens的格式,并非一种独立的认证机制。它们是自包含的令牌,其中包含了关于用户的各种信息。

一个JWT由三部分组成,这些部分之间用点分隔:

头部信息→有效载荷→签名
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiIxMjMiLCJyb2xlIjoiYWRtaW4ifQ.SIGNATURE

头部信息指定了用于生成签名的算法;有效载荷中包含了用户的身份信息、角色、令牌有效期以及发行时间;签名部分则是一种加密哈希值,用于证明该令牌确实来自你的服务器,并且没有被篡改。

服务器会通过加密手段验证这个签名,并信任有效载荷中的信息。因此,不需要进行任何数据库查询——正是这一特性使得JWT具有无状态和可扩展的特点。

Dart语言示例:

class JwtService {
  final String _secret;
  final String _issuer;
  final Duration _accessTokenExpiry;

 JwtService({
    required String secret,
    required String issuer,
    Duration accessTokenExpiry = const Duration(minutes: 15),
  }) : _secret = secret,
        _issuer = issuer,
        _.accessTokenExpiry = accessTokenExpiry;

  String generateAccessToken(User user) {
    final now = DateTime.now();
    final payload = {
      'sub': user.id,
      'role': user.role.name,
      'iat': now.millisecondsSinceEpoch ~/ 1000,
      'exp': now.add(_accessTokenExpiry).millisecondsSinceEpoch ~/ 1000,
      'iss': _issuer,
    };

    return _sign(payload);
  }

  Result validateToken(String token) {
    try {
      final claims = _verifyAndDecode(token);

      final exp = claims['exp'] as int;
      if (DateTime.fromMillisecondsSinceEpoch(exp * 1000).isBefore(DateTime.now())) {
        return Result.failure(AppException.unauthorized('令牌已过期'));
      }

      if (claims['iss'] != _issuer) {
        return Resultfailure(AppException.unauthorized('无效的令牌发行者'));
      }

      return Result.success(JwtClaims.fromMap(claims));
    } on SignatureVerificationException {
      return Result.failure(AppException.unauthorized('令牌签名无效'));
    } catch (e) {
      return Result_failure(AppException.unauthorized('令牌验证失败'));
    }
  }
}
C#:
public class JwtService
{
    private readonly JwtSettings _settings;

    public string GenerateAccessToken(User user)
    {
        var securityKey = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(_settings.Secret)
        );

       
        var credentials = new SigningCredentials(
            securityKey,
            SecurityAlgorithms.HmacSha256
        );

        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub, user.Id),
            new Claim(ClaimTypes.Role, user.Role.ToString()),
            new Claim(JwtRegisteredClaimNames.Iat,
                DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()),
           
        };

        var token = new JwtSecurityToken(
            issuer: _settings.Issuer,
            audience: _settings.Audience,
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(15),
            signingCredentials: credentials
        );

        return newJwtSecurityTokenHandler().WriteToken(token);
    }
}

JWT故障模式

当正确实现时,JWT是一种非常优秀的技术。但如果实施不当,它可能会带来严重的后果。所有的故障都属于实现层面的问题,而非标准协议本身的缺陷。以下是我遇到过的一些会导致实际问题的情况。

1. 算法混淆攻击

JWT头部会明确说明用于签署该令牌的算法。如果服务器允许使用令牌中声明的任何算法,攻击者就可以将算法设置为none,从而完全去除签名部分。这样一来,服务器就会将任何这样的令牌视为有效的。

2. 弱密的签名密钥

如果使用的签名密钥强度不足,攻击者就可以通过离线暴力破解来获取该密钥。他们无需访问服务器,只需利用获取到的令牌进行破解,就能得到密钥,进而可以以任何用户的身份生成任意令牌。

建议使用至少256位长度的强加密密钥。在需要高安全性的场景中,应采用RS256或ES256算法,并配合非对称密钥进行使用。

3> 令牌没有过期时间

如果JWT中没有设置exp字段,那么该令牌永远不会失效。因此必须为所有令牌设置过期时间。对于短期使用的访问令牌,建议将过期时间设置为15分钟到1小时之间。通过使用刷新令牌机制,可以保证会话的连续性。

4> 有效载荷中包含敏感数据

JWT的有效载荷只是经过Base64编码,并没有被加密。因此,任何获取到该令牌的人都可以解码并读取其中的内容。切勿在JWT的有效载荷中存放密码、完整的账户信息或其他敏感数据,而应该只使用标识符。这些敏感数据应该由服务器在需要时再从数据库中获取。

5> 没有撤销机制

由于JWT是一种无状态的技术,服务器并不会跟踪已经发放的令牌。因此,一旦令牌被盗,它在过期之前仍然是有效的。

为了解决这个问题,可以建立一份被明确撤销的令牌列表,或者使用具有较短有效期的刷新令牌机制,从而将潜在的危害范围控制在最小限度内。

何时使用JWT

对于大规模的无状态API、那些需要在每次请求时都不调用中央认证服务器来验证用户身份的微服务,以及移动应用程序来说,JWT都是非常合适的选择。在任何那些认为无状态认证的可扩展性比管理令牌撤销机制的复杂性更为重要的系统中,JWT都能发挥重要作用。

5. OAuth 2.0

工作原理

OAuth 2.0实际上是一个授权框架,而非认证协议。它允许用户允许第三方应用程序访问他们的资源,而无需向该第三方应用程序提供自己的密码。

每当你使用谷歌账号登录某个应用,或者看到“允许此应用访问你的账户”这样的提示时,其实就是在使用OAuth 2.0。

OAuth 2.0涉及四个方:发行令牌的授权服务器、托管受保护API的资源服务器、请求访问权限的应用程序客户端,以及拥有这些资源的用户。

OAuth 2.0的工作流程如下:

  1. 客户端将用户重定向到授权服务器,并请求特定的访问范围。

  2. 用户进行身份验证,并批准所请求的访问范围。

  3. 授权服务器会向客户端返回一个授权码。

  4. 客户端会在服务器端使用这个授权码来换取访问令牌。

  5. 之后,客户端就可以使用这个访问令牌来调用资源服务器了。

Dart代码示例:

class OAuthClient {
  final String _clientId;
  final String _clientSecret;
  final String _redirectUri;
  final String _authorizationEndpoint;
  final String _tokenEndpoint;

  OAuthClient({
    required String clientId,
    required String clientSecret,
    required String redirectUri,
    required String authorizationEndpoint,
    required String tokenEndpoint,
  })  : _clientId = clientId,
        _clientSecret = clientSecret,
        _redirectUri = redirectUri,
        _authorizationEndpoint = authorizationEndpoint,
        _tokenEndpoint = tokenEndpoint;

  String buildAuthorizationUrl(List scopes) {
    final state = _generateSecureState();

    final params = {
      'response_type': 'code',
      'client_id': _clientId,
      'redirect_uri': _redirectUri,
      'scope': scopes.join(' '),
      'state': state,
    };

    final uri = Uri.parse(_authorizationEndpoint)
        .replace(queryParameters: params);

    return uri.toString();
  }

  Future> exchangeCodeForTokens(
    String code,
    String state,
    String expectedState,
  ) async {
    if (state != expectedState) {
      return Result.failure(
        AppException.unauthorized('无效的状态参数'),
      );
    }

    final response = await http.post(
      Uri.parse(_tokenEndpoint),
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: {
        'grant_type': 'authorization_code',
        'code': code,
        'redirect_uri': _redirectUri,
        'client_id': _clientId,
        'client_secret': _clientSecret,
      },
    );

    if (response.statusCode != 200) {
      return Result.failure(AppException.unauthorized('令牌交换失败'));
    }

    final tokens = OAuthTokensfromJson(jsonDecode(response.body));
    return Result.success(tokens);
  }

  String _generateSecureState() {
    final bytes = List.generate(32, (_) => Random.secure().nextInt(256);
    return base64Url.encode(bytes);
  }
}

C#:

builder.Services.AddAuthentication(options => {
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = "OAuth2";
})
.AddCookie()
.AddOAuth("OAuth2", options => {
    options.ClientId = builder.Configuration["OAuth:ClientId"]!;
    options.ClientSecret = builder.Configuration["OAuth:ClientSecret"]!;
    options.CallbackPath = "/auth/callback";
    options AuthorizationEndpoint = "https://auth.provider.com/authorize";
    options.TokenEndpoint = "https://auth-provider.com/token";
    options.SaveTokens = true;
    options.Scope.Add("openid");
    options Scope.Add("profile");

    options.Events = new OAuthEvents {
        OnCreatingTicket = async context => {
            var userInfoRequest = new HttpRequestMessage(
                HttpMethod.Get,
                "https://auth.provider.com/userinfo"
            );
            userInfoRequestHeadersAuthorization =
                new AuthenticationHeaderValue("Bearer", context.AccessToken);

            var response = await context.Backchannel.SendAsync(userInfoRequest);
            var userInfo = await response.Content.ReadFromJsonAsync();

            context.Identity!.AddClaim(new Claim(
                ClaimTypes.NameIdentifier,
                userInfo!.RootElement.GetString("sub")!
            ));
        }
    };
});

OAuth 2.0的失败模式

1. 重定向URL配置错误

如果授权服务器不对重定向URL进行严格验证,攻击者就可以替换这些URL并截获授权码。因此,应使用精确匹配的方式来防止这种情况发生。

2. URL中包含令牌

由于有人将访问令牌放在查询参数中而不是Authorization头部中,这些令牌可能会出现在服务器日志、浏览器历史记录或引用头信息中。实际上,访问令牌应该被放置在Authorization头部中。而URL只会出现在日志中,不会出现在Authorization头部中。

何时使用OAuth 2.0

在任何需要第三方访问用户资源的场景中,OAuth 2.0都能发挥很好的作用。例如社交登录、组织之间的API集成,或者合作伙伴系统之间的集成等。

6. OpenID Connect (OIDC)

其工作原理

OAuth 2.0负责处理授权流程,而OpenID Connect则在OAuth 2.0的基础上增加了身份验证功能。它不仅能告诉您用户允许哪些操作,还能确认用户的真实身份。

OIDC在发放访问令牌的同时,还会发放一个ID令牌。这个ID令牌是一种JSON Web Token,其中包含了经过验证的用户身份信息,如主体标识符、电子邮件地址、姓名、头像以及授权发生的时间等信息。

Dart:

class OidcService {
  final String _issuer;
  final String _clientId;
  final JwtValidator _jwtValidator;

  OidcService({
    required String issuer,
    required String clientId,
    requiredJwtValidator jwtValidator,
  }) : _issuer = issuer,
        _ClientId = clientId,
        _jwtValidator = jwtValidator;

  Future〈Result〈UserIdentity, AppException〉>> validateIdToken(
    String idToken,
  ) async {
    final claimsResult = await _jwtValidator.validate(idToken);
    if (claimsResult.isFailure) {
      return Result.failure(claimsResult.error!);
    }

    final claims = claimsResult.value!;

    if (claims['iss'] != _issuer) {
      return Result FAILURE(AppException.unauthorized('无效的令牌发行者'));
    }


    final aud = claims['aud'];
    final audiences = aud is List ? aud : [aud];
    if (!audiences.contains(_clientId)) {
      return Result.failure(
        AppException.unauthorized('该令牌并非为当前客户端设计');
      );
    }

    return Result.success(UserIdentity(
      subject: claims['sub'] as String,
      email: claims['email'] as String?,
      name: claims['name'] as String?,
      emailVerified: claims['email_verified'] as bool? ?? false,
    ));
  }
}

C#:

builder.Services.AddAuthentication(options => {
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnect Defaults AuthenticationScheme;
});
.AddCookie()
.AddOpenIdConnect(options => {
    options.Authority = "https://accounts.google.com";
    options.ClientId = builder.Configuration["OIDC:ClientId"]!;
    options.ClientSecret = builder.Configuration["OIDC:ClientSecret"]!;
    options.ResponseType = "code";
    options Scope.Add("openid");
    options.Scope.Add("profile");
    options_scope.Add("email");
    options.SaveTokens = true;
    options.GetClaimsFromUserInfoEndpoint = true;

    options.TokenValidationParameters = new TokenValidationParameters {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        NameClaimType = "name",
        RoleClaimType = "role"
    };
});

何时使用OIDC

对于任何需要通过可信的身份提供者来验证用户身份的应用程序来说,OIDC都是一个非常合适的选择——无论是企业级单点登录系统,还是社交登录服务。在任何希望将身份验证工作委托给第三方、而自己不必负责管理用户凭证的系统中,OIDC都能发挥良好的作用。

Google Sign-In、Microsoft Azure AD、Okta以及Auth0都实现了OIDC技术。如果你想要确认“这个人到底是谁”,而不仅仅是判断“这个请求是否合法”,那么OIDC就是最适合你的解决方案。

7. 相互TLS(mTLS)

工作原理

在普通的TLS协议中,客户端会验证服务器的证书;而服务器则会信任任何能够与其建立连接的客户端。

而在相互TLS机制中,双方都会互相验证对方的证书。服务器只会接受那些使用由可信证书颁发机构颁发的证书进行连接的客户端;客户端也无法伪造自己的身份,因为它们需要使用真实的证书才能进行通信。

在这种机制下,不存在所谓的“承载令牌”或“API密钥”——证书本身就是用于身份验证的依据。

Dart(客户端端的mTLS实现):

class MtlsHttpClient {
  final http.Client _client;

  MtlsHttpClient._internal(this._client);

  static Future create(
    required String certificatePath,
    required String privateKeyPath,
    required String trustedCaPath,
  ) async {
    final context = SecurityContext(withTrustedRoots: false);

    // 加载客户端证书
    context.useCertificateChainBytes(
      await File(certificatePath).readAsBytes(),
    );

    // 加载客户端私钥
    context.usePrivateKeyBytes(
      await File(privateKeyPath).readAsBytes(),
    );

    // 仅信任特定的证书颁发机构
    context.setTrustedCertificatesBytes(
      await File(trustedCaPath).readAsBytes(),
    );

    final httpClient = HttpClient(context: context);
    final client = IOClient(httpClient);

    return MtlsHttpClient._internal(client);
  }

  Future get(String url, {Map? headers}) {
    return _client.get(Uri.parse(url), headers: headers);
  }

  Future post(
    String url, {
      Map? headers,
      Object? body,
    }) {
      return _client.post(Uri.parse(url), headers: headers, body: body);
    }
  }
}

C#:

builder.WebHost.ConfigureKestrel(options =>
{
    options ConfigureHttpsDefaults(httpsOptions =>
    {
        httpsOptions.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
        httpsOptions.ClientCertificateValidation = (certificate, chain, errors) =>
        {
            if (errors != SslPolicyErrors.None)
                return false;

            var expectedThumbprint = "your-trusted-cert-thumbprint";
            return certificate.Thumbprint == expectedThumbprint;
        };
    });
});

public class MtlsMiddleware
{
    private readonly RequestDelegate _next;

    public async Task InvokeAsync(HttpContext context)
    {
        var clientCert = context.Connection.ClientCertificate;

        if (clientCert == null)
        {
            context.Response.StatusCode = 401;
            await context.Response.WriteAsync("需要客户端证书");
            return;
        }

        if (!IsValidClientCertificate(clientCert))
        {
            context.ResponseStatusCode = 403;
            await context.Response.WriteAsync("客户端证书无效");
            return;
        }

        await _next(context);
    }

    private bool IsValidClientCertificate(X509Certificate2 cert)
    {
        if (cert.NotAfter < DateTime.UtcNow) return false;

        var trustedThumbprints = new HashSet
        {
            "THUMBPRINT_SERVICE_A",
            "THUMBPRINT_SERVICE_B",
        };

        return trustedThumbprints.Contains(cert.Thumbprint);
    }
}

MTLS真正存在的问题

这里的核心问题在于证书管理。证书会过期,如果不能自动进行证书更新,过期的证书就可能在最糟糕的时刻导致服务之间的通信中断。此外,CA认证机构本身的安全性也必须得到保障;一旦CA认证机构遭到攻击,它颁发的所有证书都会变得不安全。

对于那些没有能力维护完整的PKI系统的团队来说,使用具有严格域名访问控制机制以及定期更新规则的API密钥可能是更实际的选择。MTLS确实是一种合适的架构,但同时也最需要仔细管理和维护才能确保其正常运行。

何时使用MTLS

在需要高安全性的服务间通信场景中,例如支付网关或银行API,应该使用MTLS。在受到监管的环境中,如果合规性要求在传输层进行身份验证,MTLS也同样适用。在任何高安全性系统中,内部服务之间的所有通信都应被视为敏感信息,而MTLS正好为这种通信提供了必要的安全保障。

选择合适的认证机制

选择哪种认证机制其实是一个架构层面的决策。以下是一套参考框架:

对于面向用户的Web应用和移动应用:**应选择基于JWT的令牌认证机制,使用短期访问令牌并结合刷新令牌机制来管理用户权限,并对所有认证接口实施速率限制。

对于第三方委托访问:**应使用OAuth 2.0。如果还需要验证用户身份,可以在其基础上添加OIDC机制。 对于企业级单点登录:**应选择与企业身份认证系统结合使用的OpenID Connect方案。 对于安全要求较低的服务器间通信:**可以使用带有域名白名单功能的API密钥、针对特定环境的密钥,以及定期轮换的密钥管理策略。 对于处于高度监管环境中的服务间通信:**应使用mTLS协议进行加密。 对于控制严格的内部系统:**基本认证是最基本的身份验证方式,但只有在能够确保TLS安全性的情况下才能使用;其他情况下应采用更安全的认证机制。

选择合适的认证机制时,应该根据调用方的身份、数据的敏感程度以及合规性要求来决定,而不是依据惯例、上一个项目的使用经验,或者哪种实现方式最简单。

将所有环节紧密联系起来的组织规范

正确完成技术实现只是成功的一半,另一半则取决于组织层面的管理。

API密钥的轮换必须要有明确的计划并主动执行,而不能等到怀疑发生泄漏时才采取行动。每把密钥都有规定的最长使用期限,其轮换计划也应由工程团队负责制定。

任何API密钥都不能同时用于多个客户端或不同的环境。测试环境的密钥就是用于测试环境的密钥,移动客户端的密钥也是专门为移动客户端设计的;在不同环境中重复使用密钥会严重威胁系统安全。

敏感信息绝对不能保存在源代码、应用程序代码或提交到版本控制系统的配置文件中。这些信息必须在运行时从可信的密钥管理系统中获取,这是工程领域的标准做法,而非个人喜好。

加密算法也必须成为组织层面的统一标准,而不能由每个项目中的开发人员自行决定。应该由具有相应权限的工程师或架构师来规定整个组织应使用哪种加密算法、何种加密模式以及密钥长度。所有项目都必须遵守这一标准,没有任何开发人员会为每个项目单独重新实现加密功能。

企业应该建立并维护用于加密操作的共享库,开发人员只需导入这些库即可使用相应的加密功能,而无需自己编写加密代码。这样就可以避免因随意选择加密方式而导致的各种问题。

在框架层面必须强制实施带有字段级屏蔽功能的结构化日志记录机制。在任何环境中(包括生产环境、测试环境和开发环境),授权头信息、令牌、密码、账户编号等敏感数据都绝对不能出现在日志中。这种屏蔽措施是在日志记录框架层面上进行的,因此即使个别开发人员试图泄露敏感信息,也无法成功实现这一目的。

结论

本文介绍的每种认证机制,只要正确实施,都能发挥应有的作用;而如果实施不当,这些机制都会以可预测且被记录在案的方式导致失败。

利用JWT算法中的漏洞进行的攻击确实导致了真实的身份验证绕过现象;配置错误的OAuth重定向URL使得他人能够窃取用户的真实账户信息;缺失的状态参数为CSRF攻击提供了机会;而弱密钥则导致了大规模的冒充行为;在代码库中硬编码API密钥,也使生产系统容易受到未经授权的访问。

这些并非理论上的故障模式,它们确实存在于各种规模的组织所使用的生产系统中。开发这些系统的工程师们并非能力不足,只是他们没有被告知这些潜在的故障风险;也没有人制定相应的组织标准或在开发流程中严格执行这些标准。

真正的工程素养意味着在编写第一行代码之前,就必须清楚地了解每种机制可能出现的故障方式,并从一开始就设计出能够避免这些问题的实现方案。这才是让身份验证系统正常运行、能够在对抗性环境中保持稳定性的关键所在。

祝编码愉快!

相关文章

技术实践

API漏洞的“工程学分析”:深入探讨OWASP列出的十大最常见API安全风险

大多数安全文章读起来都像威胁报告。它们从外部角度描述各种漏洞:攻击者会采取什么行动、这些漏洞会造成什么影响,以及全球范围内有多少系统会受到影响。这样的信息确实很有参考价值,但它们并不能帮助工程师构建更安全的系统。 但这篇文章有所不同。它从内部角度分析OWASP API安全十大漏洞中的每一个:是哪些工程决策导致了这些漏洞的出现,违反了哪些架构原则,以及应该采取什么样的工程标准才能防止这类漏洞被应用到生产环境中。 我在受监管的金融环境中从事过多年大规模生产级应用程序的开发工作。这份列表中提到的漏洞并非理论上的概念,我在实际系统中见过其中的大部分。有些漏洞在我将其投入生产之前就发现了;而有些则在我开

阅读全文
技术实践

如何打破人工智能编程代理的循环机制

你很可能已经看过这样的情景:在使用人工智能编码工具构建的应用程序中,出现了某些故障。你让这个工具去修复这些问题,它似乎也“修复”了它们,但实际上错误依然存在,或者又出现了新的问题。 于是你会说“还是不行”。工具会再次尝试修复。二十分钟后,你会发现代码中的错误更多了,可用的功能也更少了,而且完全不知道该如何让应用程序恢复到正常状态。 人们常常用“人工智能反而使问题更加严重”或者“工具陷入了无限循环的修复模式”这样的表述来描述这种情况。这并不是某个特定产品的缺陷,像Replit、Cursor、Claude Code、Base44这类工具都会出现同样的问题,因为这种故障的本质是系统性的。 本教程将解

阅读全文
技术实践

Aspire 13.6版本新增了持续性的仪表盘数据监控功能,并支持原生Java程序及Rust语言程序的运行。

微软发布了Aspire 13.6版本。此次更新中,控制面板开始使用SQLite来存储相关数据,而且每个应用程序最多可以记录10次测试结果。新版本还为Java和Rust语言提供了预发布版本的运行环境包,同时增加了可移植的文件路径设置以及新的命令行选项。值得注意的变化还包括:对于本地的MongoDB资源,系统现在会自动启用TLS加密功能;此外,默认使用的Cosmos DB模拟器镜像也进行了更新。 作者:Almir Vuk

阅读全文
技术实践

如何向英国税务海关总署的“数字化纳税”API提交季度更新报告

每年有四次,所有参与“税收数字化”计划的个体经营者和房东都必须向英国税务海关总署提交一份收入与支出的汇总报表。 2026-27纳税年度的第一个截止日期是8月7日,这个期限已经过去;第二个截止日期则是11月7日。每份汇总报表都需要通过软件向英国税务海关总署发送一次API请求,而本教程正是专门讲解如何正确完成这些请求操作的。 您无需事先阅读任何其他资料即可跟随本教程进行操作。每次进行“税收数字化”集成时所需的一次性设置信息(包括沙箱应用程序、OAuth 2.0访问令牌以及防欺诈相关配置)已在下方的简要总结中列出,同时每段代码示例中也都会使用到一些辅助函数。 如果您想深入了解这些设置步骤,我曾在 之

阅读全文