JWT in Request Header is not the same in receiving

2019-07-04 11:54发布

问题:

When I make a request to my .Net Core 2 API from my Angular app the JWT is not the same as the one sent in the request header.

Startup.cs

public class Startup
{
    public Startup(IHostingEnvironment env)
    {
        var builder = new ConfigurationBuilder()
            .SetBasePath(env.ContentRootPath)
            .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
            .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true)
            .AddEnvironmentVariables();
        _config = builder.Build();
    }

    IConfigurationRoot _config;

    // This method gets called by the runtime. Use this method to add services to the container.
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddSingleton(_config);
        services.AddDbContext<ApplicationDbContext>(ServiceLifetime.Transient);

        services.AddTransient<IEmailSender, AuthMessageSender>();
        services.AddTransient<ISmsSender, AuthMessageSender>();

        services.AddSingleton<IUserTwoFactorTokenProvider<ApplicationUser>, DataProtectorTokenProvider<ApplicationUser>>();

        // Add application services.

        // Add application repositories.

        // Add options.
        services.AddOptions();
        services.Configure<StorageAccountOptions>(_config.GetSection("StorageAccount"));

        // Add other.
        services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
        services.AddTransient<ApiExceptionFilter>();

        // this makes "this.User" reflect the properties of the jwt sent in the request
        services.AddTransient<ClaimsPrincipal>(s => s.GetService<IHttpContextAccessor>().HttpContext.User);

        services.AddIdentity<ApplicationUser, IdentityRole>(options =>
        {
            // set password complexity requirements
            options.Password.RequireDigit = true;
            options.Password.RequireLowercase = true;
            options.Password.RequireUppercase = false;
            options.Password.RequireNonAlphanumeric = false;
            options.Password.RequiredLength = 6;

            options.Tokens.ProviderMap.Add("Default",
            new TokenProviderDescriptor(typeof(IUserTwoFactorTokenProvider<ApplicationUser>)));
        }).AddEntityFrameworkStores<ApplicationDbContext>();

        services.AddAuthentication(options =>
        {
            options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
            options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
        }).AddJwtBearer(config =>
            {
                config.RequireHttpsMetadata = false;
                config.SaveToken = true;
                config.TokenValidationParameters = new TokenValidationParameters()
                {
                    ValidIssuer = _config["Tokens:Issuer"],
                    ValidAudience = _config["Tokens:Audience"],
                    ValidateIssuerSigningKey = true,
                    IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Tokens:Key"])),
                    ValidateLifetime = true
                };
            });
        services.AddAuthorization(config =>
        {
            config.AddPolicy("Subscribers", p => p.RequireClaim("Subscriber", "True"));
            config.AddPolicy("Artists", p => p.RequireClaim("Artist", "True"));
            config.AddPolicy("Admins", p => p.RequireClaim("Admin", "True"));
        });

        services.Configure<DataProtectionTokenProviderOptions>(o =>
        {
            o.Name = "Default";
            o.TokenLifespan = TimeSpan.FromHours(1);
        });
        services.Configure<AuthMessageSenderOptions>(_config);

        // Add framework services.
        services.AddMvc(opt =>
        {
            //opt.Filters.Add(new RequireHttpsAttribute());
        }
        ).AddJsonOptions(opt =>
        {
            opt.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
        });
    }

    // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
    public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
    {
        loggerFactory.AddConsole(_config.GetSection("Logging"));
        loggerFactory.AddDebug();

        app.Use(async (context, next) =>
        {
            // just to check the context.User.Claims on request
            var temp = context;
            await next();
        });
        app.UseAuthentication();
        app.UseMvc();
    }
}

This is where the token gets issued (on app login)

AuthController.cs

private async Task<IList<Claim>> CreateUserClaims(ApplicationUser user)
    {
        var userClaims = await _userManager.GetClaimsAsync(user);
        var newClaims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub, user.UserName),
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
            new Claim(JwtRegisteredClaimNames.NameId, user.Id)
        }.Union(userClaims).ToList();
        return newClaims;
    }
    private Object CreateToken(IList<Claim> claims)
    {
        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Tokens:Key"]));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
        var token = new JwtSecurityToken(
            issuer: _config["Tokens:Issuer"],
            audience: _config["Tokens:Audience"],
            claims: claims,
            expires: DateTime.UtcNow.AddDays(29),
            signingCredentials: creds
        );
        return new
        {
            token = new JwtSecurityTokenHandler().WriteToken(token),
            expiration = token.ValidTo
        };
    }
    private async Task<Object> CreateToken(ApplicationUser user)
    {
        var claims = await CreateUserClaims(user);
        var token = CreateToken(claims);
        return token;
    }
[HttpPost("token")]
    [AllowAnonymous]
    public async Task<IActionResult> CreateToken([FromBody] CredentialModel model)
    {
        var user = await _userManager.FindByNameAsync(model.UserName);
        if (user != null)
        {
            if (_hasher.VerifyHashedPassword(user, user.PasswordHash, model.Password)
                == PasswordVerificationResult.Success)
            {
                var token = await CreateToken(user);
                return Ok(token);
            }
        }
        throw new ApiException("Bad email or password.");
    }

I have confirmed through the Chrome debugger Network tab that the JWT in my request is the JWT I want the API to get.

Because of that I will leave the Angular request code out of this post.

Here is a Controller that returns items by UserId

[HttpGet]
    public async Task<IActionResult> Get()
    {
        var artists = await _manageArtistService.GetAllByUser(this.User);
        if (artists == null) return NotFound($"Artists could not be found");
        return Ok(artists);
    }

Here is the service the controller calls

public async Task<IEnumerable<ManageArtistView>> GetAllByUser(ClaimsPrincipal user)
    {
        // gets all artists of a given user, sorted by artist
        var userId = _userService.GetUserId(user);
        var artists = await _manageArtistRepository.GetAllByUser(userId);
        return artists;
    }

In the UserService.cs I have attempted a few different means of accessing the current user. I check the this.User that was passed from the Controller.

I also check the current context in _context - a Singleton you can see in the Startup.cs.

There is also the _caller which is from this line in Startup.cs

services.AddTransient<ClaimsPrincipal>(s => s.GetService<IHttpContextAccessor>().HttpContext.User);

When I inspect any of those variables, the Claims object does not contain the same claims as the JWT that was sent during the request.

I have verified the claims do not match by checking the claims at jwt.io.

To be specific, I'll give a scenario:

I sign into my app with email user@example.com. That email is then set as a claim (Sub) as user.UserName inside the CreateUserClaims() function in the AuthController.cs:

var newClaims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub, user.UserName),
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
            new Claim(JwtRegisteredClaimNames.NameId, user.Id)
        }.Union(userClaims).ToList();

Then some other properties are set and eventually the token is returned to the client. The client stores it in localStorage.

The client then makes a request, including the JWT in the header and adds it to the request options like this (Angular service):

private headers = new Headers(
    {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + this.authService.token
    });
private options = new RequestOptions({ headers: this.headers });

I check the Header in the Network tab and it contains the JWT - I check it on jwt.io and it looks good - has the proper email and other claims.

Now I can logout of the app, sign in as a different user, get a new JWT, and make the request to that same controller shown above and the JWT will have the previous email, not the new one that I just signed in as.

And I did go through the same checks, checking the JWT in the Header on the Network tab to ensure the claims contain the new email as the sub as well as the other claims.

So that means I was issued the proper JWT on the new sign in, but somehow the API is still looking at the old JWT.

How crazy is that?

Something else I have noticed is that even on that first login (pretend I just started the API with dotnet run and then I make my first request to the same controller shown above it will be missing the nameid claim. I can go check the JWT that was sent in the Header request and it does have the nameid claim. So, again, the api will issue the proper JWT but when I send it back over HTTP in a request the API does not have the same JWT that I sent in the request.

ONE MORE THING I log the JWT in the console for simplicity. I went back and found the first one I started using today, at 9am. Its jti is the same as the one that is currently in the .net core API. It's now 4:45pm. I have 9 different JTWs in my console between those two times (9am and 4:45pm), all issued from the API. But the API seems to have kept the first one it created this morning - even after I have stopped and started the project numerous times.

Please help me understand what I am doing wrong. I must not be fully understanding how JWTs are handled.

回答1:

I have figured out part of my problem.

I was wrong in saying the token coming from the UI was different than what the .net API was receiving. I said I was inspecting the Header in the Network tab, and I was, but just not the correct request. My UI was sending several requests - from different Angular modules. I was injecting a new authentication service (where my token is stored) in each module. On logout, not ever module was getting refreshed, so those that were not kept their old copy of the token. Therefore, upon login, only the affected modules (in my case, my main app.module.ts) were getting refreshed. The ones that had not been touched kept their same copy of the authentication service.

I removed the injection from each module and let them inherit from the main app.module.ts That fixed the issue of the UI and API appearing to have different tokens.

The other issue I mentioned of not being able to see the nameid claim is partially resolved. I have a total of 10 Claims inside User. When I decode the JWT it says I have sub and nameid. However, when I inspect Claims in my UserService.cs they are not listed as nameid and sub, but rather http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier and http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier. Each have the correct Value. I am not sure where or how this happens. I created the following custom middleware code to see what the token was when coming in and it has the Claim as sub and nameid.

app.Use(async (context, next) =>
        {
            var authHeader = context.Request.Headers["Authorization"].ToString();
            if (authHeader != null && authHeader.StartsWith("bearer", StringComparison.OrdinalIgnoreCase))
            {
                var tokenStr = authHeader.Substring("Bearer ".Length).Trim();
                System.Console.WriteLine(tokenStr);
                var handler = new JwtSecurityTokenHandler();
                var token = handler.ReadToken(tokenStr) as JwtSecurityToken;
                var nameid = token.Claims.First(claim => claim.Type == "nameid").Value;

                var identity = new ClaimsIdentity(token.Claims);
                context.User = new ClaimsPrincipal(identity);
            }
            await next();
        });

So, the variable nameid is right and contains the expected value. Somewhere along the line the Type is changing from nameid and sub to http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier