在 C# (web api) 中测试经过身份验证的方法(使用不记名令牌)的正确方法

Proper way to test authenticated methods (using bearer tokens) in C# (web api)

我有一个 Web API,其中包含大量方法,所有方法都需要持有者令牌才能使用。这些方法都是从不记名令牌中提取信息。

我想测试 API 是否在其生成时正确填充不记名令牌。我正在使用 Microsoft.Owin.Testing 框架来编写我的测试。我有一个看起来像这样的测试:

[TestMethod]
public async Task test_Login() 
{
    using (var server = TestServer.Create<Startup>())
    {
        var req = server.CreateRequest("/authtoken");
        req.AddHeader("Content-Type", "application/x-www-form-urlencoded");
        req.And(x => x.Content = new StringContent("grant_type=password&username=test&password=1234", System.Text.Encoding.ASCII));
        var response = await req.GetAsync();

        // Did the request produce a 200 OK response?
        Assert.AreEqual(response.StatusCode, System.Net.HttpStatusCode.OK);

        // Retrieve the content of the response
        string responseBody = await response.Content.ReadAsStringAsync();
        // this uses a custom method for deserializing JSON to a dictionary of objects using JSON.NET
        Dictionary<string, object> responseData = deserializeToDictionary(responseBody); 

        // Did the response come with an access token?
        Assert.IsTrue(responseData.ContainsKey("access_token"));

    }
}

所以我能够检索表示令牌的字符串。但现在我想实际访问该令牌的内容,并确保提供了某些声明。

我将在实际经过身份验证的方法中用于检查声明的代码如下所示:

var identity = (ClaimsIdentity)User.Identity;
IEnumerable<Claim> claims = identity.Claims;

var claimTypes = from x in claims select x.Type;

if (!claimTypes.Contains("customData"))
    throw new InvalidOperationException("Not authorized");

所以我想做的是,在我的测试本身中,提供承载令牌字符串并接收 User.Identity 对象或以其他方式获得对令牌包含的声明的访问权限。这就是我想要测试我的方法是否正确地向令牌添加必要声明的方式。

"naive" 方法可能是在我的 API 中编写一个方法,该方法只是 returns 它所提供的不记名令牌中的所有声明。但感觉这应该是不必要的。 ASP.NET 在调用我的控制器方法之前以某种方式将给定的标记解码为对象。我想在我的测试代码中自己复制相同的操作。

这能做到吗?如果可以,怎么做?


编辑:我的 OWIN 启动 class 实例化了一个我编写的身份验证令牌提供程序,它处理身份验证和令牌生成。在我的初创公司 class 我有这个:

public void Configuration(IAppBuilder app)
{
    // Setup configuration object
    HttpConfiguration config = new HttpConfiguration();

    // Web API configuration and services
    // Configure Web API to use only bearer token authentication.
    config.SuppressDefaultHostAuthentication();
    config.Filters.Add(new HostAuthenticationFilter(OAuthDefaults.AuthenticationType));

    // Web API routes
    config.MapHttpAttributeRoutes();
    config.Routes.MapHttpRoute(
        name: "DefaultApi",
        routeTemplate: "api/{controller}/{id}",
        defaults: new { id = RouteParameter.Optional }
    );

    // configure the OAUTH server
    OAuthAuthorizationServerOptions OAuthServerOptions = new OAuthAuthorizationServerOptions()
    {
        //AllowInsecureHttp = false,
        AllowInsecureHttp = true, // THIS HAS TO BE CHANGED BEFORE PUBLISHING!

        TokenEndpointPath = new PathString("/authtoken"),
        AccessTokenExpireTimeSpan = TimeSpan.FromDays(1),
        Provider = new API.Middleware.MyOAuthProvider()
    };

    // Now we setup the actual OWIN pipeline.

    // setup CORS support
    // in production we will only allow from the correct URLs.
    app.UseCors(Microsoft.Owin.Cors.CorsOptions.AllowAll);

    // Token Generation
    app.UseOAuthAuthorizationServer(OAuthServerOptions);
    app.UseOAuthBearerAuthentication(new OAuthBearerAuthenticationOptions());

    // insert actual web API and we're off!
    app.UseWebApi(config);
}

这是来自我的 OAuth 提供商的相关代码:

public override async Task GrantResourceOwnerCredentials(OAuthGrantResourceOwnerCredentialsContext context)
{

    // Will be used near end of function
    bool isValidUser = false;

    // Simple sanity check: all usernames must begin with a lowercase character
    Match testCheck = Regex.Match(context.UserName, "^[a-z]{1}.+$");
    if (testCheck.Success==false)
    {
        context.SetError("invalid_grant", "Invalid credentials.");
        return;
    }

    string userExtraInfo;
    // Here we check the database for a valid user.
    // If the user is valid, isValidUser will be set to True.
    // Invalid authentications will return null from the method below.
    userExtraInfo = DBAccess.getUserInfo(context.UserName, context.Password);
    if (userExtraInfo != null) isValidUser = true;

    if (!isValidUser)
    {
        context.SetError("invalid_grant", "Invalid credentials.");
        return;
    }

    // The database validated the user. We will include the username in the token.
    string userName = context.UserName;

    // generate a claims object
    var identity = new ClaimsIdentity(context.Options.AuthenticationType);

    // add the username to the token
    identity.AddClaim(new Claim(ClaimTypes.Sid, userName));

    // add the custom data on the user to the token.
    identity.AddClaim(new Claim("customData", userExtraInfo));

    // store token expiry so the consumer can determine expiration time
    DateTime expiresAt = DateTime.Now.Add(context.Options.AccessTokenExpireTimeSpan);
    identity.AddClaim(new Claim("expiry", expiresAt.ToString()));

    // Validate the request and generate a token.
    context.Validated(identity);

}

单元测试希望确保 customData 声明确实存在于身份验证令牌中。因此,我需要一种方法来评估提供的令牌以测试它包含哪些声明。


编辑 2:我花了一些时间查看 Katana 源代码并在网上搜索了一些其他帖子,看起来我在 IIS 上托管这个应用程序很重要,所以我会使用 SystemWeb .看起来 SystemWeb 对令牌使用机器密钥加密。选项中的 AccessTokenFormat 参数看起来也与此处相关。

所以现在我想知道我是否可以根据这些知识实例化我自己的 "decoder"。假设我只会在 IIS 上托管,我可以实例化一个解码器,然后解码令牌并将其转换为 Claims 对象吗?

这方面的文档有点稀疏,代码似乎把你扔得乱七八糟,很多东西都试图让我的头脑保持清醒。


编辑 3:我找到了一个项目,其中包含应该是不记名令牌反序列化器的项目。我在它的 "API" 库中修改了代码,并一直试图用它来解密我的 API.

生成的令牌

我使用 Microsoft 的 PowerShell 脚本生成了一个 <machineKey...> 值,并将其放在 API 本身的 Web.config 文件和 App.confg 文件中测试项目。

然而,令牌仍然无法解密。我收到抛出的异常:System.Security.Cryptography.CryptographicException 消息 "Error occurred during a cryptographic operation." 以下是错误的堆栈跟踪:

at System.Web.Security.Cryptography.HomogenizingCryptoServiceWrapper.HomogenizeErrors(Func`2 func, Byte[] input)
at System.Web.Security.Cryptography.HomogenizingCryptoServiceWrapper.Unprotect(Byte[] protectedData)
at System.Web.Security.MachineKey.Unprotect(ICryptoServiceProvider cryptoServiceProvider, Byte[] protectedData, String[] purposes)
at System.Web.Security.MachineKey.Unprotect(Byte[] protectedData, String[] purposes)
at MyAPI.Tests.BearerTokenAPI.MachineKeyDataProtector.Unprotect(Byte[] protectedData) in D:\Source\MyAPI\MyAPI.WebAPI.Tests\BearerTokenAPI.cs:line 251
at MyAPI.Tests.BearerTokenAPI.SecureDataFormat`1.Unprotect(String protectedText) in D:\Source\MyAPI\MyAPI.WebAPI.Tests\BearerTokenAPI.cs:line 287

此时我被难住了。在整个项目中将 MachineKey 值设置为相同的情况下,我不明白为什么我无法解密令牌。我猜密码错误是故意含糊不清的,但我不确定现在从哪里开始解决这个问题。

我只想在单元测试中测试令牌是否包含所需数据....:-)

我终于找到了解决办法。我向 Startup class 添加了一个 public 变量,它公开了传递给 UseBearerTokenAuthentication 方法的 OAuthBearerAuthenticationOptions object。从那个 object,我可以调用 AccessTokenFormat.Unprotect 并获得解密的令牌。

我还重写了我的测试以单独实例化 Startup class,这样我就可以从测试中访问该值。

我仍然不明白为什么 MachineKey 不起作用,为什么我不能直接解除对令牌的保护。似乎只要 MachineKey 匹配,我就应该能够解密令牌,甚至是手动解密。但至少这似乎可行,即使它不是最佳解决方案。

这可能会做得更干净,例如,Startup class 可能会以某种方式检测它是否正在测试中启动,并在某些情况下将 object 传递给测试 class其他时尚而不是让它在 breeze 中闲逛。但就目前而言,这似乎正是我所需要的。

我的初创公司 class 以这种方式公开变量:

public partial class Startup
{
    public OAuthBearerAuthenticationOptions oabao;

    public void Configuration(IAppBuilder app)
    {

        // repeated code omitted

        // Token Generation
        app.UseOAuthAuthorizationServer(OAuthServerOptions);
        oabao = new OAuthBearerAuthenticationOptions();
        app.UseOAuthBearerAuthentication(oabao);

        // insert actual web API and we're off!
        app.UseWebApi(config);

    }
}

我的测试现在看起来像这样:

[TestMethod]
public async Task Test_SignIn()
{
    Startup owinStartup = new Startup();
    Action<IAppBuilder> owinStartupAction = new Action<IAppBuilder>(owinStartup.Configuration);

    using (var server = TestServer.Create(owinStartupAction))
    {
        var req = server.CreateRequest("/authtoken");
        req.AddHeader("Content-Type", "application/x-www-form-urlencoded");

        // repeated code omitted

        // Is the access token of an appropriate length?
        string access_token = responseData["access_token"].ToString();
        Assert.IsTrue(access_token.Length > 32);

        AuthenticationTicket token = owinStartup.oabao.AccessTokenFormat.Unprotect(access_token);

        // now I can check whatever I want on the token.
    }
}

希望我所有的努力能帮助其他尝试做类似事情的人。