3

There must be an easy solution for such a generic question, so I apologize upfront for my ignorance:

I have a multi-user Web-app (Asp.net MVC5 with EF6) that a.o. allows users to view and/or modify their relevant data stored in several related tables (Company, Csearch, Candidate). (for more details see below). They should NOT see any other data (e.g. by tampering with the URL).

I use Asp.net Identity 2.0 for authentication and would like to use it for the mentioned authorization as well. Userdata is stored in the standard AspNetUser Table. I use only one context for both Identity and my Business Tables.

I guess I have to either use Roles or maybe Claims to solve this, but I cannot find any guidance on how to do that. Can anyone point me in the right direction?

I have currently solved it (for the Company Model) by adding a LINQ condition to the CompanyController, but this does not appear to be a very secure and proper way of solving the problem.

public ActionResult Index(int? id, int? csearchid)
        {
            var companies = db.Companies
              .OrderBy(i => i.CompanyName)
              .Where(t => t.UserName == User.Identity.Name);
        return View(companies);

My DataModel is straightforward and I had it scaffolded using Visual Studio 2017 Through EF6 Code first I have constructed a Relational Datamodel which is roughly as follows:

a COMPANY can have multiple SEARCHES (one to many). Each Search can have multiple CANDIDATES (one to many). A COMPANY can have multiple USERS logging in. Users are save in the AspNetUsers table genberated by ASP.Net Identity.

My Company model looks as follows:

public class Company
{
    public int CompanyID { get; set; }

    // Link naar de Userid in Identity: AspNetUsers.Id
    [Display(Name = "Username")]
    public string UserName { get; set; }        
    public string CompanyName { get; set;}
    public string CompanyContactName { get; set; }
    [DataType(DataType.EmailAddress)]        
    public string CompanyEmail { get; set; }
    public string CompanyPhone { get; set; }        

    [Timestamp]
    public byte[] RowVersion { get; set; }

    //One to Many Navigatie links
    public virtual ICollection<Csearch> Csearches { get; set; }
Charles de M.
  • 633
  • 7
  • 20
  • What you did is part of it. You might want more granular access rights, with roles, use the `Authorize` attribute with roles or custom attribute. – AD.Net Jun 27 '17 at 15:44

3 Answers3

5

Once the user is identified, you can make sure the user can only access its own data. You cannot use roles for that, since that will only define the level of access. But you can use claims.

Out-of-the-box there is a seperation of concerns. Maintain this seperation. You are not meant to query the Identity tables directly. Use the userManager for that. Also never use an Identity object as ViewModel. You may expose more than you mean to. If you keep this seperation, you'll see that it is in fact much easier.

The identity context contains all data to identify the user, the business context contains all business information, including user information. You may think that this is redundant, but the login user has really nothing in common with the business user. The login emailaddress may differ from the business.user.emailaddress (what is the meaning of the emailaddress in both cases?). Also consider the possibility to have users that cannot login (anymore).

As a rule of thumb always consider if the information is part of the identity or part of the business.

When do you need the ApplicationUser? Only for the current user or when managing users. When you query users, always use the business.user. Because all the information you need should be available there.

For the current user, add claims with the information you need. The advantage of claims is that you won't have to query the database on each call to retrieve this information, like the corresponding UserId and the (display)UserName.

How to add claims

You can, without having to extend the ApplicationUser class, add a claim to the user by adding a row to the AspNetUserClaims table. Something like:

userManager.AddClaim(id, new Claim("UserId", UserId));

On login the claim will be automatically added to the ClaimsIdentity.

You can also add claims for properties that extend the ApplicationUser:

public class ApplicationUser : IdentityUser
{
    public int UserId { get; set; }

    public string DisplayUserName { get; set; }

    public async Task<ClaimsIdentity> GenerateUserIdentityAsync(UserManager<ApplicationUser> manager)
    {
        // Note the authenticationType must match the one defined in CookieAuthenticationOptions.AuthenticationType
        var userIdentity = await manager.CreateIdentityAsync(this, DefaultAuthenticationTypes.ApplicationCookie);

        // Add custom user claims here
        userIdentity.AddClaim(new Claim("UserId", UserId));
        userIdentity.AddClaim(new Claim("DisplayUserName", DisplayUserName));

        return userIdentity;
    }
}

How to read claims

In the controller you can read the claim with code like this:

var user = (System.Security.Claims.ClaimsIdentity)User.Identity;
var userId = user.FindFirstValue("UserId");

You can use userId in your queries to filter the data for the current user or even use business.users as the only entry to retrieve data. Like db.Users(u => u.Id == userId).Companies.ToList();

Please note, the code is just an example. I didn't test all of it. It is just to give you an idea. In case something isn't clear, please let me know.

  • Thanks for the very extensive answer. I will start to implement it. As soon as I am successful (which might take a while :)), I will most definitely let you know. – Charles de M. Jun 28 '17 at 11:09
  • If you create a new Asp.Net Mvc project you won't have to implement it. Because it is all part of the template. All you have to do is add the claim(s) and add a Users table to the business context. –  Jun 28 '17 at 18:02
2

It's pretty simple really. To illustrate with the example Company you provided. Note that you should use UserId to join rather than UserName since UserName can change, but UserId will always be unique.)

Instead of having UserName in your Company table, you need to change that to UserId. Then you join the AspNetUsers table with your Company table on UserId.

For example (I prefer to use the query syntax rather than the fluent syntax):

var companies = from c in db.Companies join u in db.AspNetUsers
                on c.UserId equals u.UserId
                orderby c.CompanyName
                where u.UserName = User.Identity.Name 
                select c;

If you need the username as well, then include that in your select

select new { Company = c, User = u.UserName };

However, this model does not work if you want to have multiple users per company. You either need to add CompanyId to the users table (assuming a user can't be a member of more than one company) or create a many-to-many join if a user can be a member of multiple companies.

So rather than linking the user to the company, you link the company to the user. Your current model only allows one user per company.

Another thing I see wrong here is the use of DisplayName in your entity object. That seems to indicate you are using the entity in your MVC view, which you shouldn't do. You should create a separate ViewModel.

Here is how it should look like for multiple users per company:

public class Company
{
    public int CompanyID { get; set; }

    // Link naar de Userid in Identity: AspNetUsers.Id
    // [Display(Name = "Username")] <-- Get rid of these
    // public string UserName { get; set; } <-- get rid of these
...
}

public class ApplicationUser : IdentityUser
{
    public int CompanyId { get; set; }
}

Then change your query to:

var companies = from c in db.Companies join u in db.AspNetUsers
            on c.CompanyId equals u.CompanyId // <-- Change to this
            orderby c.CompanyName
            where u.UserName = User.Identity.Name 
            select c;
Erik Funkenbusch
  • 92,674
  • 28
  • 195
  • 291
  • Thanks Erik! This seems indeed to be the easiest way. Would you by any chance know if this is also a secure solution? – Charles de M. Jun 28 '17 at 11:07
  • I get an error that 'ApplicationDbContext does not contain a definition for AspNetUsers'.... I tried adding a DbSet to the context but that did not solve anything. Suggestions? – Charles de M. Jul 04 '17 at 11:55
  • Thanks Eric. It works for the specific view, but I can still have access to all data on other views. As an example, I can still see non-authorized company-data when I open the Details-view. (.../Company/Details/2) by direct entering it in the Browser command line. – Charles de M. Jul 08 '17 at 13:44
  • @CharlesdeM - That shouldn't be possible if you're correctly joining your users with their companies. You shouldn't even let users specify a company id if they are only tied to a single company. Just automatically join the company with the user. My query above does just that. I'm not sure what "specific view" you're referring to, but in all cases, if you join the user->company it will filter out non-accessible data. – Erik Funkenbusch Jul 10 '17 at 06:03
1

I made it in the following way:

I added UserId property to the Company class. (It is string type because at SQL it is NVARCHAR type)

public class Company
{
    public string UserId { get; set; }

    public int CompanyID { get; set; }
    // Link naar de Userid in Identity: AspNetUsers.Id
    [Display(Name = "Username")]
    public string UserName { get; set; }        
    public string CompanyName { get; set;}
    public string CompanyContactName { get; set; }
    [DataType(DataType.EmailAddress)]        
    public string CompanyEmail { get; set; }
    public string CompanyPhone { get; set; }        

    [Timestamp]
    public byte[] RowVersion { get; set; }

    //One to Many Navigatie links
    public virtual ICollection<Csearch> Csearches { get; set; }
}

In the Create controller for getting current logged in user id I used How to get the current logged in user ID in ASP.NET Core? post. In brief UserId = User.FindFirstValue(ClaimTypes.NameIdentifier)

public class CompanyController : Controller
{
    private readonly ApplicationDbContext _context;
    private readonly IWebHostEnvironment webHostEnvironment;
    public CompanyController (ApplicationDbContext context, IWebHostEnvironment hostEnvironment)
        {
            _context = context;
            webHostEnvironment = hostEnvironment;
        }
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(RecordViewModel model)
   {
       if (ModelState.IsValid)
       {                
            Company company = new Company 
            {
                 UserId = User.FindFirstValue(ClaimTypes.NameIdentifier),
                 FirstName = model.FirstName,
                 CompanyName = model.CompanyName,
                 CompanyContactName = model.CompanyContactName,
                 CompanyEmail = model.CompanyEmail,
                 CompanyPhone = model.CompanyPhone 
            };  
                _context.Add(company);
                await _context.SaveChangesAsync();
                return RedirectToAction(nameof(Index));
        }
        return View(model);
    }
}

And for the displaying only records of the current logged in user I use following action:

public async Task<IActionResult> Index()
{
    var companyLoggedInUser = from c in _context.Company
                             where c.UserId == 
                             User.FindFirstValue(ClaimTypes.NameIdentifier)
                             select c;
    return View(companyLoggedInUser);                
}