Implement an OAuth 2.0 Server (Part 06)
Welcome to the sixth part of a series of posts where we will implement an OAuth 2 Server using AspNet.Security.OpenIdConnectServer.
OAuth Client CRUD - Controller and ViewModels
This is the first part of adding our OAuth Client management pages. We’ll set up the controller and the viewmodel here. In next part, we’ll add the html views.
View Models
ViewModels are, at least in the context of ASP.NET
(as opposed to UWP where the MVVM pattern changes what it means slightly), is a way of firewalling our models from our views. Our models may have fields we don’t want to expose all the time. This may get in the way of automatically validating fields, it may lead to extra hidden form fields in our views, and it can generally be a pain to deal with.
To get around that, we create some ViewModels
, which only contain the data we wish to send to and from our views. In our case, we’re going to be using them to avoid sending over the ClientSecret
and Owner
all the time.
Under the Models/
folder, create a new folder called OAuthClientsViewModels/
.
Create View Model
The first class we’re going to create under Models/OAuthClientsViewModels/
is CreateClientViewModel
:
public class CreateClientViewModel {
[Required]
[MinLength(2)]
[MaxLength(100)]
public string ClientName { get; set; }
[Required]
[MinLength(1)]
[MaxLength(500)]
public string ClientDescription { get; set; }
}
Our Create page only requires the user to supply a name
and a description
. A complete OAuthClient
has other fields like ClientId
and ClientSecret
, but we’d be inviting disaster if we let the user supply their own ids. We’ll be generating those values on the server, without user input.
We specify attributes on the fields so that the automatic validation knows what kinds of errors to provide back to the user.
Edit View Model
We’ll send over many of the same fields that make up a regular client, but the only thing we expect back is the client description, which may or may not have changed. The user is allowed to view but not edit the other fields.
We set the RedirectURIs to be a string[]
because we’ll be using Razor Page binding techniques to automatically group submitted urls together into one field.
public class EditClientViewModel {
[Required]
[MinLength(1)]
[MaxLength(500)]
public string ClientDescription { get; set; }
public string ClientName { get; internal set; }
public string ClientId { get; internal set; }
public string ClientSecret { get; internal set; }
public string[] RedirectUris { get; set; } = new string[0];
}
We mark some of these as being internal set
so that the validation doesn’t try to check them. We’ll send the client secret over too, so that a user can regenerate the secret if they need to.
Controller
Next is to add a new controller. Unlike the public API controller, we’ll be generating this one automatically.
Right click on Controllers
, select Add
, and click Controller
.
Choose MVC Controller with views, using Entity Framework
Fill out the Model class
with the OAuthClient
,
Fill out the Data conext class
with ApplicationDbContext
,
and leave the rest as the defaults.
This has the benefit of automatically generating all the necessary views for us. We just need to make a few tweaks to them.
Authorization
First off we need to add an [Authorize]
attribute to the controller. Unauthorized visitors, aka, users that are not signed in, will be redirected to the home page.
[Authorize]
public class OAuthClientsController : Controller
{
private readonly ApplicationDbContext _context;
public OAuthClientsController(ApplicationDbContext context)
{
_context = context;
}
...
}
User Manager
We’ll need to manipulate the users who access these pages, so we need to inject UserManager
into the constructor
[Authorize]
public class OAuthClientsController : Controller {
private readonly ApplicationDbContext _context;
private readonly UserManager<ApplicationUser> _userManager;
public OAuthClientsController(ApplicationDbContext context, UserManager<ApplicationUser> userManager) {
_context = context;
_userManager = userManager;
}
...
}
Index
The Index should only return the clients for which the current user is the Owner
.
This also introduces the Entity Framework
concept of Includes
.
If you’re not familiar with Entity Framework Core, models with additional models on them, like our Owner
on the OAuthClient
are not populated by default when querying. This is to save on bandwidth and I/O costs, but it can be a surprise if you’re not expecting a sudden Null Pointer. The solution is to call .Include()
on the DbSet
with the field we need. In this case, the method chain looks like _context.ClientApplications.Include(x => x.Owner)...
// GET: OAuthClients
public async Task<IActionResult> Index() {
string uid = _userManager.GetUserId(this.User);
return View(await _context.ClientApplications.Include(x => x.Owner).Where(x => x.Owner.Id == uid).ToListAsync());
}
Details
Delete the Details(string id)
method - we won’t be using it, because we’re going to combine it with our Edit
page.
POST Create
We can leave the GET Create method alone and move on to the POST Create method.
This method gets changed quite a bit - we swap out the OAuthClient
for our CreateClientViewModel
, change what fields we’re listening to in the [Bind]
parameter, and we create a new OAuthClient
with generated values for ClientId
and ClientSecret
.
// POST: OAuthClients/Create
// To protect from overposting attacks, please enable the specific properties you want to bind to, for
// more details see http://go.microsoft.com/fwlink/?LinkId=317598.
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create([Bind("ClientName,ClientDescription")] CreateClientViewModel vm)
{
if (ModelState.IsValid)
{
ApplicationUser owner = await _userManager.GetUserAsync(this.User);
OAuthClient client = new OAuthClient() {
ClientDescription = vm.ClientDescription,
ClientName = vm.ClientName,
ClientId = Guid.NewGuid().ToString(),
ClientSecret = Guid.NewGuid().ToString(),
Owner = owner,
};
_context.Add(client);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
return View(vm);
}
GET Edit
This is the GET
version of EDIT
. POST will get its own special treatment.
We’ll be using the EditClientViewModel
from before, along with our standard checks for ownership.
To match the viewmodel’s fields, we transform any existing RedirectURIs
to their string form, then to an array with LINQ.
// GET: OAuthClients/Edit/5
public async Task<IActionResult> Edit(string id)
{
if (String.IsNullOrEmpty(id)) {
return NotFound();
}
string uid = _userManager.GetUserId(this.User);
var oAuthClient = await _context.ClientApplications.Include(x => x.Owner).Include(x=>x.RedirectURIs)
.SingleOrDefaultAsync(m => m.ClientId == id && m.Owner.Id == uid);
if (oAuthClient == null) {
return NotFound();
}
EditClientViewModel vm = new EditClientViewModel() {
ClientName = oAuthClient.ClientName,
ClientDescription = oAuthClient.ClientDescription,
ClientId = oAuthClient.ClientId,
ClientSecret = oAuthClient.ClientSecret,
RedirectUris = oAuthClient.RedirectURIs.Select(x => x.URI).ToArray()
};
return View(vm);
}
POST EDIT
The method is large but not as big as it looks - it contains a nested internal method. It could be extracted out, but it only exists to deal with one specific scenario while editing a client, so it’s been stuffed inside this one.
We’ve edited the Bind parameters to be just the fields that a user can actually edit - the Client Description
and the RedirectUris
.
After our standard ownership checks, we make sure to include the RedirectURIs
while fetching from the context, because we need to perform some operations on the ones that already exist.
The meat of the method is under CheckAndMark
, which just adds re-submitted URIs, creates ones that didn’t exist before, and then uses LINQ’s Except
and Select
to mark any deleted URIs as EntityState.Deleted
for Entity Framework.
// POST: OAuthClients/Edit/5
// To protect from overposting attacks, please enable the specific properties you want to bind to, for
// more details see http://go.microsoft.com/fwlink/?LinkId=317598.
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(string id, [Bind("ClientDescription", "RedirectUris")] EditClientViewModel vm)
{
string uid = _userManager.GetUserId(this.User);
OAuthClient client = await _context.ClientApplications.Include(x => x.Owner).Include(x=>x.RedirectURIs).Where(x => x.ClientId == id && x.Owner.Id == uid).FirstOrDefaultAsync();
if (client == null)
{
return NotFound();
}
if (ModelState.IsValid)
{
try
{
List<RedirectURI> originalUris = client.RedirectURIs;
CheckAndMark(originalUris, vm.RedirectUris);
client.ClientDescription = vm.ClientDescription;
_context.Update(client);
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!OAuthClientExists(vm.ClientId))
{
return NotFound();
}
else
{
throw;
}
}
return RedirectToAction(nameof(Index));
}
return View(vm);
void CheckAndMark(List<RedirectURI> originals, IEnumerable<string> submitted) {
List<RedirectURI> newList = new List<RedirectURI>();
foreach(string s in submitted) {
if (String.IsNullOrWhiteSpace(s)) {
continue;
}
RedirectURI fromOld = originals.FirstOrDefault(x => x.URI == s);
if(fromOld == null) {
// this 's' is new.
RedirectURI rdi = new RedirectURI() { OAuthClient = client, OAuthClientId = client.ClientId, URI = s };
newList.Add(rdi);
} else {
// this 's' was re-submitted
newList.Add(fromOld);
}
}
// Marking deleted Redirect URIs for Deletion.
originals.Except(newList).Select(x => _context.Entry(x).State = EntityState.Deleted);
// Assign the new list back to the client
client.RedirectURIs = newList;
}
}
Get Delete
Delete the Delete (string id)
method. Like our Details
method, we’re going to combine it with Edit
.
This is the GET
version of DELETE
. Like Edit, POST will get its own special treatment.
POST Delete
Attempting to post a delete forces a client/user check.
// POST: OAuthClients/Delete/5
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(string id)
{
if (String.IsNullOrEmpty(id)) {
return NotFound();
}
string uid = _userManager.GetUserId(this.User);
var oAuthClient = await _context.ClientApplications.Include(x => x.Owner)
.SingleOrDefaultAsync(m => m.ClientId == id && m.Owner.Id == uid);
if(oAuthClient == null) {
return NotFound();
}
_context.ClientApplications.Remove(oAuthClient);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
Reset Client Secret
This is a custom method we’re going to add - it did not come generated in the controller.
We supply this method so a user can regenerate their client secret
in the event they mistakenly check it into source control, or if it’s leaked by any other means. The implications of this are that when a user’s authentication tokens come up for renewal, they’ll need to restart the entire process. If the client is on a phone, or is otherwise an installed application as opposed to a web app, the user will need to download a new build of the application containing the new secret.
As a side note, this is why iOS and Android apps seem to update so frequently without adding anything in their release notes - they’re cycling their application keys according to some schedule as a security precaution.
// POST: OAuthClients/ResetSecret/
[HttpPost, ActionName("ResetSecret")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ResetClientSecret(string id) {
string uid = _userManager.GetUserId(this.User);
OAuthClient client = await _context.ClientApplications.Include(x => x.Owner).Include(x => x.RedirectURIs).Where(x => x.ClientId == id && x.Owner.Id == uid).FirstOrDefaultAsync();
if (client == null) {
return NotFound();
}
try {
client.ClientSecret = Guid.NewGuid().ToString();
_context.Update(client);
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException) {
if (!OAuthClientExists(client.ClientId)) {
return NotFound();
}
else {
throw;
}
}
return RedirectToAction(id, "OAuthClients/Edit");
}
Moving On
The demo of this project to this point can be found here on GitHub.
In the next section we’ll deal with the second half of this unit and modify the generated Views. Next
Posts in this series
- Implement an OAuth 2.0 Server (Part 19)
- Implement an OAuth 2.0 Server (Part 18)
- Implement an OAuth 2.0 Server (Part 17)
- Implement an OAuth 2.0 Server (Part 16)
- Implement an OAuth 2.0 Server (Part 15)
- Implement an OAuth 2.0 Server (Part 14)
- Implement an OAuth 2.0 Server (Part 13)
- Implement an OAuth 2.0 Server (Part 12)
- Implement an OAuth 2.0 Server (Part 11)
- Implement an OAuth 2.0 Server (Part 10)
- Implement an OAuth 2.0 Server (Part 09)
- Implement an OAuth 2.0 Server (Part 08)
- Implement an OAuth 2.0 Server (Part 07)
- Implement an OAuth 2.0 Server (Part 06)
- Implement an OAuth 2.0 Server (Part 05)
- Implement an OAuth 2.0 Server (Part 04)
- Implement an OAuth 2.0 Server (Part 03)
- Implement an OAuth 2.0 Server (Part 02)
- Implement an OAuth 2.0 Server (Part 01)