4
\$\begingroup\$

I use ASP.NET Core 2.1 and would like to fetch User at a service level.

I've seen examples when HttpContextAccessor gets injected into some service and then we fetch the current User via UserManager

var user = await _userManager.GetUserAsync(accessor.HttpContext.User);

or in controller

var user = await _userManager.GetUserAsync(User);

Problems:

  • Injecting HttpContextAccessor into service seems to be wrong - simply because we violate SRP and the Service Layer isn't isolated (it is dependant on http context).

  • We can of course fetch user in a controller (a somewhat better approach), but we face a dilemma - we simply don't want to pass User as parameter in every single service method


I spent a few hours thinking about how best to implement it and have come up with a solution.

I'm just not entirely sure my approach is adequate and doesn't violate any of the software-design principles.

Sharing my code in hopes to get recommendations from StackOverflow community.

The idea is the following:

First, I introduce SessionProvider which is registered as Singleton.

services.AddSingleton<SessionProvider>();

SessionProvider has a Session property which holds User, Tenant, etc.

Secondly, I introduce SessionMiddleware and register it

app.UseMiddleware<SessionMiddleware>();

In the Invoke method I resolve HttpContext, SessionProvider & UserManager.

  • I fetch User

  • Then I initialise Session property of ServiceProvider singleton:

sessionProvider.Initialise(user);

At this stage ServiceProvider has Session object containing the info we need.

Now we inject SessionProvider into any service and its Session object is ready for use.


Code:

SessionProvider:

public class SessionProvider
{
    public Session Session;

    public SessionProvider(
        )
    {
        Session = new Session();
    }

    public void Initialise(ApplicationUser user)
    {
        Session.User = user;
        Session.UserId = user.Id;
        Session.Tenant = user.Tenant;
        Session.TenantId = user.TenantId;
        Session.Subdomain = user.Tenant.HostName;
    }
}

Session:

public class Session
{
    public ApplicationUser User { get; set; }

    public Tenant Tenant { get; set; }

    public long? UserId { get; set; }

    public int? TenantId { get; set; }

    public string Subdomain { get; set; }
}

SessionMiddleware:

public class SessionMiddleware
{
    private readonly RequestDelegate next;

    public SessionMiddleware(RequestDelegate next)
    {
        this.next = next ?? throw new ArgumentNullException(nameof(next));
    }

    public async Task Invoke(
        HttpContext context,
        SessionProvider sessionProvider,
        MultiTenancyUserManager<ApplicationUser> userManager
        )
    {
        await next(context);

        var user = await userManager.GetUserAsync(context.User);

        if (user != null)
        {
            sessionProvider.Initialise(user);
        }
    }
}

And now Service Layer code:

public class BaseService
{
    public readonly AppDbContext Context;
    public Session Session;

    public BaseService(
        AppDbContext context,
        SessionProvider sessionProvider
        )
    {
        Context = context;
        Session = sessionProvider.Session;
    }
}

So this is the base class for any service, as you can see we can now fetch Session object easily and it's ready for use:

public class VocabularyService : BaseService, IVocabularyService
{
    private readonly IVocabularyHighPerformanceService _vocabularyHighPerformanceService;
    private readonly IMapper _mapper;

    public VocabularyService(
        AppDbContext context,
        IVocabularyHighPerformanceService vocabularyHighPerformanceService,
        SessionProvider sessionProvider,
        IMapper mapper
        ) : base(
              context,
              sessionProvider
              )
    {
        _vocabularyHighPerformanceService = vocabularyHighPerformanceService;
        _mapper = mapper; 
    }

    public async Task<List<VocabularyDto>> GetAll()
    {
        List<VocabularyDto> dtos = _vocabularyHighPerformanceService.GetAll(Session.TenantId.Value);
        dtos = dtos.OrderBy(x => x.Name).ToList();
        return await Task.FromResult(dtos);
    }
}

Focus on the following bit:

.GetAll(Session.TenantId.Value);

also, we can easily get current user

Session.UserId.Value

or

Session.User

Update 1 - VocabularyController.cs

[Route("api/[controller]/[action]")]
[Authorize]
public class VocabularyController : ControllerBase
{
    private readonly IVocabularyService _vocabularyService;

    public VocabularyController(
        IVocabularyService vocabularyService
        )
    {
        _vocabularyService = vocabularyService;
    }

    public async Task<List<VocabularyDto>> GetAll()
    {
        var result = await _vocabularyService.GetAll();
        return result;
    }
}

So, that's it.

I tested my code and it works well when several tabs are open - each tab has different subdomain in url.

(Tenant is resolved from subdomain - the data is being fetched correctly)


Maybe some of you have a better idea of how to implement this?

Thanks in advance!

\$\endgroup\$
  • 1
    \$\begingroup\$ from StackOverflow community - have you cross-posted it? This is Code Review ;-) \$\endgroup\$ – t3chb0t Aug 4 '18 at 17:40
  • \$\begingroup\$ @t3chb0t I've got a recommendation to use codereview portal for questions like this. \$\endgroup\$ – Alex Herman Aug 4 '18 at 17:41
  • 2
    \$\begingroup\$ What's with that: ...? It looks like you have removed parts of you code. Please don't do it. It'll backfire - it always does. Post what you have unchanged. \$\endgroup\$ – t3chb0t Aug 4 '18 at 17:42
  • \$\begingroup\$ Well, this is still wrong. You're using variables that aren't even initialized :-| On Code Review we require complete and working code. Pseudocode or incomplete code is off-topic. \$\endgroup\$ – t3chb0t Aug 4 '18 at 17:46
  • 4
    \$\begingroup\$ Generally you just need to post what is relevant to your question. If you think other parts might be important then why not, although Startup.csis mostly just registration. If you're not doing anything weird there, then you might skip it. BUT when you post something, please make sure it's not simplified or edited in anyway that would make it incomplete. The best you can do is just copy/paste your actual code ;-) \$\endgroup\$ – t3chb0t Aug 4 '18 at 18:01
2
\$\begingroup\$

Here's a better workaround in my opinion - we no longer make a DB call per every single request, we just retrieve UserID & TenantID from Claims instead:

Please note that the lifetime of SessionContext is Per Request - when the request starts we hook into it, resolve SessionContext instance, then populating it with UserID & TenantID - after this wherever we inject our SessionContext (given the same request) - it will contain the values we need.

services.AddScoped<SessionContext>();

SessionContext.cs

public class SessionContext
{
    public long? UserId { get; set; }

    public int? TenantId { get; set; }

    public string Subdomain { get; set; }
}

SessionFilter.cs

public class SessionFilter : IAsyncActionFilter
{
    public async Task OnActionExecutionAsync(
        ActionExecutingContext context,
        ActionExecutionDelegate next
        )
    {
        var services = context.HttpContext.RequestServices;
        var session = services.GetService(typeof(SessionContext)) as SessionContext;

        var claimsIdentity = (ClaimsIdentity)context.HttpContext.User.Identity;

        var userIdClaim = claimsIdentity.Claims.SingleOrDefault(c => c.Type == CustomClaims.UserId);
        if (userIdClaim != null)
        {
            session.UserId = !string.IsNullOrEmpty(userIdClaim.Value) ? long.Parse(userIdClaim.Value) : (long?)null;
        }

        var tenantIdClaim = claimsIdentity.Claims.SingleOrDefault(c => c.Type == CustomClaims.TenantId);
        if (tenantIdClaim != null)
        {
            session.TenantId = !string.IsNullOrEmpty(tenantIdClaim.Value) ? int.Parse(tenantIdClaim.Value) : (int?)null;
        }

        session.Subdomain = context.HttpContext.Request.GetSubDomain();

        var resultContext = await next();
    }
}

AuthController.cs

[AllowAnonymous]
[HttpPost]
public async Task<AuthenticateOutput> Authenticate([FromBody] AuthenticateInput input)
{
    var expires = input.RememberMe ? DateTime.UtcNow.AddDays(5) : DateTime.UtcNow.AddMinutes(20);

    string subdomain = HttpContext.Request.GetSubDomain();
    _tenantService.SetSubDomain(subdomain);
    var user = await _userService.Authenticate(input.UserName, input.Password);

    if (user == null)
    {
        throw new Exception("Unauthorised");
    }

    int? tenantId = _tenantService.GetTenantId();
    string strTenantId = tenantId.HasValue ? tenantId.ToString() : string.Empty;

    var tokenHandler = new JwtSecurityTokenHandler();

    var tokenDescriptor = new SecurityTokenDescriptor
    {
        Expires = expires,
        Issuer = _config.GetValidIssuer(),
        Audience = _config.GetValidAudience(),
        SigningCredentials = new SigningCredentials(_config.GetSymmetricSecurityKey(), SecurityAlgorithms.HmacSha256),
        Subject = new ClaimsIdentity(new[]
        {
            new Claim(ClaimTypes.Name, user.UserName),
            new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
            // claims required for SessionContext
            new Claim(CustomClaims.UserId, user.Id.ToString()),
            new Claim(CustomClaims.TenantId, strTenantId)
        })
    };

    var token = tokenHandler.CreateToken(tokenDescriptor);
    string tokenString = tokenHandler.WriteToken(token);

    return new AuthenticateOutput() { Token = tokenString };
}
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.