ASP.NET Core 3.1 - Concurrency Conflicts
This article will demonstrate the implementation of trapping and handling concurrency conflicts when editing and updating a user. I will assume you have downloaded the ASP.NET Core 3.1 - Users Without Identity Project or created a new ASP.NET Core 3.1 Razor Pages project. See Tutorial: Get started with Razor Pages in ASP.NET Core. You should review the earlier articles of the Users Without Identity Project series.
Users Without Identity Project and Article Series
Creation Series
- ASP.NET Core 3.1 - Users Without Identity
- ASP.NET Core 3.1 - User Entity
- ASP.NET Core 3.1 - Password Hasher
- ASP.NET Core 3.1 - User Management
- ASP.NET Core 3.1 - Admin Role
- ASP.NET Core 3.1 - Cookie Validator
- ASP.NET Core 3.1 - Concurrency Conflicts
- ASP.NET Core 3.1 - Must Change Password
- ASP.NET Core 3.1 - User Database Service
- ASP.NET Core 3.1 - Rename Related Entities
2FA Series
- ASP.NET Core 3.1 - 2FA Without Identity
- ASP.NET Core 3.1 - 2FA User Tokens
- ASP.NET Core 3.1 - 2FA Cookie Schemes
- ASP.NET Core 3.1 - 2FA Authenticating
- ASP.NET Core 3.1 - 2FA Sign In Service
- ASP.NET Core 3.1 - 2FA QR Code Generator
- ASP.NET Core 3.1 - Admin 2FA Requirement
- ASP.NET Core 3.1 - 2FA Admin Override
Enhanced User Series
- ASP.NET Core 3.1 - Enhanced User Without Identity
- ASP.NET Core 3.1 - 2FA Recovery Codes
- ASP.NET Core 3.1 - Login Lockout
- ASP.NET Core 3.1 - Created And Last Login Date
- ASP.NET Core 3.1 - Security Stamp
- ASP.NET Core 3.1 - Token Service
- ASP.NET Core 3.1 - Confirmed Email Address
- ASP.NET Core 3.1 - Password Recovery
You try to catch a DbUpdateConcurrencyException when you save the user to the database after an update. If the DbUpdateConcurrencyException is caught, you throw the exception. You still need to configure a concurrency check and we can do a better job warning the editor.
We already have a RowVersion byte[] property with the [TimeStamp] data annotation which will automatically update on SQL Update. You can use the RowVersion as a concurrency check if you add it to the InputModel.
Edit Admin/ Users/ Edit.cshtml.cs > InputModel:
public class InputModel { [Key] public int Id { get; set; } [Required] [StringLength(32, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] [Display(Name = "Login Name")] public string LoginName { get; set; } [StringLength(32, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 8)] public string Password { get; set; } [Display(Name = "Admin")] public bool IsAdmin { get; set; } [Timestamp] public byte[] RowVersion { get; set; } }
Add the user's RowVersion to the InputModel in the edit page's OnGetAsync method and add a hidden input for RowVersion on the edit page for binding.
Edit Admin/ Users/ Edit.cshtml:
<input type="hidden" asp-for="Input.RowVersion" />
In the edit user page's OnPostAsync method, we get a fresh representation of the user from the database. If another editor has updated the record, the RowVersion will have been automatically updated. We update this fresh representation of the user from the InputModel. You need to update the RowVersion in OriginalValues for SQL to detect a concurrency conflict.
When we catch a DbUpdateConcurrencyException, you can use the ModelState.AddModelError to inform the editor the update was canceled and display the current values from the database. You can extract the current database values from the exception.
Edit Admin/ Users/ Edit.cshtml.cs > OnPostAsync:
_context.Attach(user).State = EntityState.Modified; try { _context.Entry(user).OriginalValues["RowVersion"] = Input.RowVersion; await _context.SaveChangesAsync(); } catch (DbUpdateConcurrencyException ex) { var entry = ex.Entries.Single(); var databaseEntry = entry.GetDatabaseValues(); if (databaseEntry == null) { ModelState.AddModelError(string.Empty, "Unable to save changes. The User was deleted by another user. Click Cancel."); } else { ModelState.Clear(); // required to update Input model ModelState.AddModelError(string.Empty, "The record you attempted to edit " + "was modified by another user after you got the original values. The " + "edit operation was canceled and the current values in the database " + "have been displayed. You can continue to edit then Save again. " + "Otherwise click Cancel."); var databaseValues = (AppUser)databaseEntry.ToObject(); Input = new InputModel() { Id = databaseValues.Id, LoginName = databaseValues.LoginName, IsAdmin = databaseValues.IsAdmin, RowVersion = databaseValues.RowVersion }; } return Page(); } catch (RetryLimitExceededException /* dex */) { // Retry Limit = 6 ModelState.AddModelError("", "Unable to save changes. Try again, and if the problem persists, see your system administrator."); } catch (DbUpdateException) { throw new InvalidOperationException("DbUpdateException occurred creating a new user."); }
Notice the InputModel will not update without ModelState.Clear(). You can do a concurrency check before you make the update call to the database. I do both.
Edit Admin/ Users/ Edit.cshtml.cs > OnPostAsync:
// Test for concurrency conflict if (!user.RowVersion.SequenceEqual(Input.RowVersion)) { ModelState.Clear(); // required to update Input model ModelState.AddModelError(string.Empty, "The record you attempted to edit " + "was modified by another user after you got the original values. The " + "edit operation was canceled and the current values in the database " + "have been displayed. You can continue to edit then Save again. " + "Otherwise click Cancel."); Input = new InputModel() { Id = user.Id, LoginName = user.LoginName, IsAdmin = user.IsAdmin, RowVersion = user.RowVersion }; return Page(); }
The delete user page is a little different. Add a validation summary and hidden input for AppUser.RowVersion.
Edit Admin/ Users/ Delete.cshtml:
<form class="" method="post"> <div asp-validation-summary="ModelOnly" class="text-danger"></div> <input type="hidden" asp-for="AppUser.Id" /> <input type="hidden" asp-for="AppUser.RowVersion" /> <input type="submit" value="Delete" class="btn btn-danger" /> <a class="btn btn-primary" asp-page="/Admin/Users/Index">Cancel</a> </form>
Edit Admin/ Users/ Delete.cshtml.cs > OnPostAsync:
var user = await _context.AppUsers .AsNoTracking() .FirstOrDefaultAsync(m => m.Id == AppUser.Id); if (user == null) { ModelState.AddModelError(string.Empty, "Unable to save changes. The User was deleted by another user. Click Cancel."); return Page(); } _context.AppUsers.Remove(user); try { _context.Entry(user).OriginalValues["RowVersion"] = AppUser.RowVersion; await _context.SaveChangesAsync(); } catch (DbUpdateConcurrencyException ex) { var entry = ex.Entries.Single(); var databaseEntry = entry.GetDatabaseValues(); if (databaseEntry == null) { ModelState.AddModelError(string.Empty, "Unable to save changes. The User was deleted by another user."); } else { ModelState.Clear(); // required to update the model ModelState.AddModelError(string.Empty, "The record you attempted to delete " + "was modified by another user after you got the original values. The " + "delete operation was canceled and the current values in the database " + "have been displayed. You can continue to Delete again. " + "Otherwise click Cancel."); AppUser = (AppUser)databaseEntry.ToObject(); } return Page(); } catch (RetryLimitExceededException /* dex */) { // Retry Limit = 6 ModelState.AddModelError("", "Unable to save changes. Try again, and if the problem persists, see your system administrator."); } catch (DbUpdateException) { throw new InvalidOperationException("DbUpdateException occurred deleting a user."); }
Build, run and test.
Update 02/23/2021
I added the Enhanced User Series' article links.
References:
- Tracking vs. No-Tracking Queries
- Optimistic Concurrency Management in EntityFramework Core
- What is the purpose of the ConcurrencyStamp column in the AspNetUsers table in the new ASP.NET MVC 6 identity?
- Why is Entity Framework ConcurrencyStamp not using ROWVERSION/TIMESTAMP?
- Tutorial: Handle Concurrency with EF in an ASP.NET MVC 5 app
Comments(0)