I'm fairly new to ASP.NET Core, Angular HttpClient and Observables, so I need some help.
I have created and published a web-based application that is not public facing which has an Angular 9 client and a Microsoft ASP.NET Core 2.2 API controller backend that uses Entity Framework Core ORM to access SQL Server. I use Microsoft ASP.NET Core Identity for authentication, authorization, Identity Model Tokens (JWT) and AntiForgery as security.
I can login (from Angular UI) with credentials and retrieve my required ORM Entity data using Http Get requests for my various tables. But when I try to Post, Put or Delete an entity in a table, generally only the first attempts are successful (200 status) and further attempts fail (302 status). I have to logout/login again to continue making updates. I am not sending credentials in my requests after the initial login request.
I'm including my Angular component, repository and datasource code plus my StartUp.cs and API controller.
Note that I am only generating a JWT as a bearer token once upon login, returning it to the client, and sending it back to API in subsequent requests for comparison. I am not decoding the bearer token for the username value at this time, but I did in the past with same result.
I think the status code 302 is telling me that my Put request found the URL, but that I am no longer authenticated on the server at this point, and I am not sure what to do about that scenario.
Three options that I am considering are (1) sending login credentials with destructive http requests, (2) looking into refresh tokens, or (3) refreshing my JWT token after each Put, Post, & Delete request & returning it to client as a new token. My problem is that I am not sure if any of these options is designed to work with Microsoft ASP.NET Core Identity.
Any help in pointing out what I'm missing or doing wrong would be greatly appreciated.
Update from Angular 9 table works 1st time, not 2nd time:
Browser network options shows:
Angular 9 component method:
saveMember(member: Member): boolean {
this.repo.saveMember(member).subscribe(result => this.members.splice(this.members.
findIndex(m => m.memberID == member.memberID), 1, result),
err => console.log('From saveMember(): ', err),
() => console.log('From saveMember(): Completed'));
}
Angular 9 repository Observable method includes Http HEAD request:
// Repository Method: creates new member or updates existing member
saveMember(theMember: Member): Observable<Member> {
if (theMember.memberID == null || theMember.memberID == 0) {
this.dataSource.saveMemberHeader().subscribe(result => { this.showError = !result },
err => console.log('From Head() Request: ', err),
() => console.log('From Head() Request: Completed'));
return this.dataSource.saveMember(theMember).pipe(map(response => {
if (response) {
this.members.push(response);
}
return response;
}));
}
else {
this.dataSource.updateMemberHeader().subscribe(result => { this.showError = !result },
err => console.log('From Head() Request: ', err),
() => console.log('From Head() Request: Completed'));
return this.dataSource.updateMember(theMember).pipe(map(response => {
if (response) {
let index = this.members.findIndex(item => this.locator(item, response.memberID));
this.members.splice(index, 1, response);
}
return response;
}));
}
}
Angular 9 Datasource:
// Datasource http calls to server
update: string = "update";
updateMember(member: Member): Observable<Member> {
return this.sendPutRequest<Member>(`${this.url}/${this.update}`, member);
}
private sendPutRequest<T>(url: string, member: Member): Observable<T> {
let myHeaders = new HttpHeaders();
if (this.authCookie.JWTauthcookie == null) {
myHeaders = myHeaders.set("Access-Key", "<secret>");
} else {
myHeaders = myHeaders.set("Authorization", "Bearer<" + this.authCookie.JWTauthcookie + ">");
}
myHeaders = myHeaders.set("Application-Names", ["ClientApp", "SPA"]);
return this.http.put<T>(url,
{
member: member
},
{
headers: myHeaders
}).pipe(map(response => {
return response; // response object is a Member entity
}))
.pipe(catchError((error: Response) =>
throwError(`An Error Occurred: ${error.statusText} (${error.status})`)
));
}
updateMemberHeader(member?: Member): Observable<any> {
return this.sendRequest<Member>("HEAD", `${this.url}/${this.update}`, member);
}
private sendRequest<T>(verb: string, url: string, body?: Member): Observable<T> {
let myHeaders = new HttpHeaders();
if (this.authCookie.JWTauthcookie == null) {
myHeaders = myHeaders.set("Access-Key", "<secret>");
} else {
myHeaders = myHeaders.set("Authorization", "Bearer<" + this.authCookie.JWTauthcookie + ">");
}
myHeaders = myHeaders.set("Application-Names", ["ClientApp", "SPA"]);
return this.http.request<T>(verb, url, {
body: body,
headers: myHeaders
}).pipe(map(response => {
return response;
})).pipe(catchError((error: Response) =>
throwError(`An Error Occurred: ${error.statusText} (${error.status})`)
));
}
ASP.NET Core Web Account controller:
using System;
using System.ComponentModel.DataAnnotations;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
using SignInResult = Microsoft.AspNetCore.Identity.SignInResult;
using Microsoft.AspNetCore.Mvc;
using ServerApp.Infrastructure; // contains the code for "Jwt_GenerateToken"
namespace ServerApp.Controllers
{
[ValidateAntiForgeryToken]
public class AccountController : Controller
{
private UserManager<IdentityUser> userManager;
private SignInManager<IdentityUser> signInManager;
private RoleManager<IdentityRole> roleManager;
public static string bearerJWTokenString;
private string bearerToken;
public SignInResult signInResult;
private string plainName = "";
private string plainLoginPwd = "";
public AccountController(UserManager<IdentityUser> userMgr,
SignInManager<IdentityUser> signInMgr, RoleManager<IdentityRole> roleMgr)
{
userManager = userMgr;
signInManager = signInMgr;
roleManager = roleMgr;
}
[HttpPost("/api/account/login")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> Login([FromBody] LoginViewModel creds)
{
if (ModelState.IsValid && await DoLogin(creds))
{
string isAuthenticated = signInResult.Succeeded.ToString();
object myJWT = "{ " + '\n' + " " + '"' + "success" + '"' + ": " + '"' + isAuthenticated + '"' + "," + '\n' +
" " + '"' + "token" + '"' + ':' + '"' + _token + '"' + "," + '\n' +
"}";
return Ok(myJWT);
}
return BadRequest();
}
public string _token = null;
public async Task<bool> DoLogin(LoginViewModel creds)
{
plainName = creds.Name;
plainLoginPwd = creds.Password;
IdentityUser loginuser = await userManager.FindByNameAsync(plainName);
if (loginuser != null)
{
await signInManager.SignOutAsync();
signInResult =
await signInManager.PasswordSignInAsync(loginuser, plainLoginPwd, false, false);
if (signInResult.Succeeded)
{
// generates a signed Json Web Token with current user name
_token = Jwt_GenerateToken.GenerateToken(loginuser.ToString(), 90); // in Infrastructure folder
bearerJWTokenString = _token;
}
return signInResult.Succeeded;
}
return false;
}
}
public class LoginViewModel
{
[Required]
public string Name { get; set; }
[Required]
public string Password { get; set; }
}
}
ASP.NET Core Web API controller:
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using ServerApp.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authorization;
using System.ComponentModel.DataAnnotations;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using ServerApp.Infrastructure;
namespace ServerApp.Controllers {
[Route("api/members")]
[ApiController]
[Authorize]
[AutoValidateAntiforgeryToken]
public class MemberValuesController : Controller
{
private string bearerToken;
private SecurityToken validatedToken;
private JwtSecurityTokenHandler tokenHandler = new JwtSecurityTokenHandler();
private ClaimsPrincipal vtoken = new ClaimsPrincipal();
private ApplicationDbContext context;
public MemberValuesController(ApplicationDbContext ctx)
{
context = ctx;
}
public class MemberModel
{
[Required]
public Member Member { get; set; } // must capitalize or creates naming convention error!
}
[HttpPut("/api/members/update")]
[Authorize(Roles = "Admin, SuperUser, User")]
public async Task<Member> Put([FromBody] MemberModel MbrModel)
{
bearerToken = Request.Headers["Authorization"];
var bearer = bearerToken.Substring(7, bearerToken.Length - 8);
vtoken = tokenHandler.ValidateToken(bearer, Jwt_GenerateToken.vParms, out validatedToken);
if (vtoken.Identity.Name.ToString() == Jwt_GenerateToken.cPrince.Name.ToString())
{
if (vtoken.Identity.IsAuthenticated == true)
{
Member UpdatedMember = new Member();
if (ModelState.IsValid)
{
UpdatedMember = await DoMemberUpdate(MbrModel);
return UpdatedMember;
}
return UpdatedMember;
}
else
{
return ViewBag;
}
}
else
{
return ViewBag;
}
}
private async Task<Member> DoMemberUpdate(MemberModel MbrModel)
{
Member member = new Member();
member = MbrModel.Member;
context.Members.Update(member);
var saved = false;
while (!saved)
{
try
{
await context.SaveChangesAsync();
saved = true;
return member;
}
catch (DbUpdateConcurrencyException ex)
{
foreach (var entry in ex.Entries)
{
if (entry.Entity is Member)
{
var proposedValues = entry.CurrentValues;
var databaseValues = entry.GetDatabaseValues();
foreach (var property in proposedValues.Properties)
{
var proposedValue = proposedValues[property];
proposedValues[property] = proposedValue; //<value to be saved>;
}
entry.OriginalValues.SetValues(databaseValues);
}
}
}
}
return member;
}
[HttpHead("/api/members/update")]
public async Task<IActionResult> GetUpdateHeader()
{
bearerToken = Request.Headers["Authorization"];
var bearer = bearerToken.Substring(7, bearerToken.Length - 8);
if (AccountController.bearerJWTokenString == bearer)
{
int MbrCount = 0;
MbrCount = await DoGetMembersHeader();
if (MbrCount > -1)
{
Response.Headers.Add("Items-total", MbrCount.ToString());
}
else
{
Response.Headers.Add("Items-total", "Bad result");
}
return Ok();
}
else
{
return BadRequest();
}
}
private async Task<int> DoGetMembersHeader()
{
return await context.Members.Include(m => m.MemberID)
.OrderBy(m => m.Last_Name + m.First_Name + m.MidInit)
.Select(m => new
{
m.MemberID,
m.Last_Name,
m.First_Name,
m.MidInit,
m.Email_Address,
m.Cell_Phone,
// ..... more properties
m.GUIDKey,
m.RowVersion
}).CountAsync();
}
}
Infrastructure class that generates token:
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.IdentityModel.Tokens;
namespace ServerApp.Infrastructure
{
public class Jwt_GenerateToken
{
public static ClaimsIdentity cPrince;
public static TokenValidationParameters vParms = new TokenValidationParameters();
private const string Beans = "bhmYDKCEN4pGSEoJcI6t ... more ... ==";
public static string GenerateToken(string username, int expireMinutes)
{
// "expireMinutes" value along with "username" comes from "DoLogin()" method in AccountController.cs
byte[] symmetricKey = Convert.FromBase64String(Beans);
var tokenHandler = new JwtSecurityTokenHandler();
var now = DateTime.UtcNow;
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Name, username)
}),
Expires = now.AddMinutes(Convert.ToDouble(expireMinutes)),
SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(symmetricKey),
SecurityAlgorithms.HmacSha256Signature)
};
var stoken = tokenHandler.CreateToken(tokenDescriptor);
var token = tokenHandler.WriteToken(stoken);
// creating a static public ClaimsPrincipal object to compare to JWT token in client request
// https://stackoverflow.com/questions/40281050/jwt-authentication-for-asp-net-web-api?rq=1
vParms.IssuerSigningKey = tokenDescriptor.SigningCredentials.Key;
vParms.RequireExpirationTime = true;
vParms.ValidateIssuer = false;
vParms.ValidateAudience = false;
cPrince = tokenDescriptor.Subject; // the Claims Identity object to compare to future requests
// end update
return token;
}
}
}
StartUp.cs:
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.DependencyInjection;
using ServerApp.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Antiforgery;
namespace ServerApp
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthentication(); // is this necessary or redundant here?
string conString_A = Configuration["TheDatabaseProd:AConnectionString"];
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(conString_A));
string conString_I = Configuration["TheDatabaseProd:IConnectionString"];
services.AddDbContext<IdentityDataContext>(options =>
options.UseSqlServer(conString_I));
services.AddIdentity<IdentityUser, IdentityRole>()
.AddEntityFrameworkStores<IdentityDataContext>();
services.AddMvc()
.SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
services.AddAntiforgery(options =>
{
options.HeaderName = "X-XSRF-TOKEN";
});
services.AddMvc(options =>
{
options.Filters.Add(new ValidateAntiForgeryTokenAttribute());
});
services.AddMemoryCache(); // is this necessary or redundant?
services.AddDistributedMemoryCache();
services.AddSession();
services.AddSession(options =>
{
options.Cookie.Name = "MyApp.Session";
options.IdleTimeout = TimeSpan.FromMinutes(10);
options.Cookie.IsEssential = true;
options.Cookie.SameSite = SameSiteMode.Lax;
});
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env,
IServiceProvider services, IAntiforgery antiforgery)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseStaticFiles();
app.UseStaticFiles(new StaticFileOptions
{
RequestPath = "",
FileProvider = new PhysicalFileProvider(
Path.Combine(Directory.GetCurrentDirectory(), "./wwwroot/app"))
});
app.UseAuthentication();
app.UseSession();
app.Use(nextDelegate => context =>
{
string path = context.Request.Path.Value;
string[] directUrls = { "/appmenu", "/table", "/form", "/form/edit", "/form/create",
"/queryTable", "/oneMemberTable", "/retreatTable", "/coreTeamMemberTable" };
if (path.StartsWith("/api") || string.Equals("/", path) || directUrls.Any(url => path.StartsWith(url)))
{
var tokens = antiforgery.GetAndStoreTokens(context);
context.Response.Cookies.Append("XSRF-REQUEST-TOKEN", tokens.RequestToken,
new CookieOptions()
{
HttpOnly = false, // HttpOnly must be false or the x-xsrf-token header is not filled in the Request
Secure = true,
IsEssential = true // was true, 2023.06.06
});
}
return nextDelegate(context);
});
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
});
}
}
}

