0

I have a WebApi and Razor frontend project (both using .Net 8.0). I'm trying to learn to implement authentication using JWT and cookies.

As of now it is working well:

  • Login on my razor website, which will send the credentials to my webapi
  • if login success, the api returns with jwt cookies (access and refresh)

But there is another thing that I'm trying to do on my Razor website, I want to get some info of the logged-in account by sending request to the API before the page loads. Which means changing the page elements on server-side (e.g. only show certain menus based on account permissions).

I tried this on OnGetAsync(), but the http result is 401 Unauthorized. I checked and the jwt cookies are not sent. I was assuming that it will be similar to ajax (withCredentials: true) that it will automatically include the cookies from the browser.

[BindProperty]
public Account Account { get; set; }

public async Task<IActionResult> OnGetAsync()
{
    string api = "http://localhost:5192/api/";
    string path = "accounts/info";

    var httpClient = new HttpClient();
    httpClient.BaseAddress = new Uri(api);

    // send GET request of the logged-in account's info (id, name, permissions, etc.)
    // but this results in 401 Unauthorized, so the jwt cookies are not included in the request?
    var message = await httpClient.GetAsync(path);
    Account = await message.Content.ReadFromJsonAsync<Account>();

    return Page(); // after the request finishes, then load the page
}

TESTING

Calling API request via AJAX (result OK, also sending cookies)

$.ajax({
    url: "http://localhost:5192/api/accounts/check", // directly call the api
    type: "get",
    xhrFields: {
        withCredentials: true
    },
    processData: false,
    contentType: false,
    success: (data, textStatus, jqXHR) => {
        console.log(data);
    },
    error: (jqXHR, textStatus, errorThrown) => {
        console.error(jqXHR);
    }
});

Calling API request via HttpClient (result Unauthorized)

$.ajax({
    url: "?handler=Check", // call the page handler method
    type: "get",
    xhrFields: {
        withCredentials: true
    },
    processData: false,
    contentType: false,
    success: (data, textStatus, jqXHR) => {
        console.log(data);
    },
    error: (jqXHR, textStatus, errorThrown) => {
        console.error(jqXHR);
    }
});
// the handler method in Razor page
public class IndexModel : PageModel
{
    public async Task<IActionResult> OnGetCheckAsync()
    {
        string api = "http://localhost:5192/api/";
        string path = "accounts/check";

        var httpClient = new HttpClient();
        httpClient.BaseAddress = new Uri(api);

        var message = await httpClient.GetAsync(path);
        string content = await message.Content.ReadAsStringAsync();

        return StatusCode(((int)message.StatusCode), content);
    }
}

The WebApi code

Program.cs

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.Configure<MyConfiguration>(builder.Configuration);

// some code here...

var authentication = builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
});

authentication.AddJwtBearer(options =>
{
    options.SaveToken = true;

    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidIssuer = config.Tokens?.Issuer,
        ValidAudience = config.Tokens?.Audience,
        ClockSkew = TimeSpan.Zero,
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config.Tokens?.Key!)),
    };

    options.Events = new JwtBearerEvents
    {
        OnMessageReceived = ctx =>
        {
            var cookieName = "";

            if (ctx.Request.Path.Equals("/api/accounts/refresh-token", StringComparison.OrdinalIgnoreCase))
            {
                // get refresh token
                cookieName = config.Cookies?.RefreshFullName;
            }
            else
            {
                // get access token
                cookieName = config.Cookies?.AccessFullName;
            }

            ctx.Request.Cookies.TryGetValue(cookieName, out var token);
            if (!string.IsNullOrEmpty(token))
            {
                ctx.Token = token;
            }

            return Task.CompletedTask;
        },
        OnAuthenticationFailed = ctx =>
        {
            if (ctx.Exception.GetType() == typeof(SecurityTokenExpiredException))
            {
                ctx.Response.Headers.Append("X-Token-Expire", "true");
            }

            return Task.CompletedTask;
        }
    };
});

builder.Services.AddAuthorization();

builder.Services.AddCors(options =>
{
    options.AddDefaultPolicy(builder =>
        builder
            .SetIsOriginAllowed(host => new Uri(host).Host == "localhost")
            .AllowAnyMethod()
            .AllowAnyHeader()
            .AllowCredentials()
    );
});

builder.Services.AddControllers();

// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

builder.Services.AddDbContext<TicketingDbContext>(optionsBuilder =>
{
    optionsBuilder.UseMySql(config.TicketingConnectionString!, ServerVersion.AutoDetect(config.TicketingConnectionString));
});

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseCors();

app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();

app.Run();

AccountsController.cs

[Authorize]
[Route("api/[controller]")]
[ApiController]
public class AccountsController : ControllerBase
{
    private readonly TicketingDbContext _dbContext;
    private readonly IOptions<MyConfiguration> _configuration;
    private readonly ITokenFactory _tokenFactory; // DI service for generating JWT tokens

    public AccountsController(TicketingDbContext dbContext, IOptions<MyConfiguration> configuration, ITokenFactory tokenFactory)
    {
        _dbContext = dbContext;
        _configuration = configuration;
        _tokenFactory = tokenFactory;
    }

    [AllowAnonymous]
    [HttpPost("Login")]
    public async Task<IActionResult> Login([FromForm]LoginModel login)
    {
        var account = await _dbContext.Accounts
            .AsNoTracking()
            .Include(a => a.Role)
            .FirstOrDefaultAsync(x => x.Email == login.Email);

        if (account == null)
        {
            return Unauthorized("Invalid Email/Password");
        }

        if (!account.IsPasswordMatch(login.Password))
        {
            return Unauthorized("Invalid Email/Password");
        }

        // ------------------- begin save login trail -------------------

        // save to database as login session identifier
        Guid guid = Guid.NewGuid();

        // save to database as token lookup
        Guid accessGuid;
        Guid refreshGuid;
        DateTime? refreshExpire;

        // generate new access & refresh tokens and add to cookies
        GenerateAccessRefreshTokens(out accessGuid, out refreshGuid, out refreshExpire);

        _dbContext.TokenTrails
            .Add(new TokenTrail()
            {
                SessionId = guid,
                AccountId = account.Id,
                AccessId = accessGuid,
                RefreshId = refreshGuid,
                RefreshExpire = (DateTime)refreshExpire!,
                UserAgent = GetClientUserAgent(),
                IpAddress = GetClientIpAddress(),
            });

        await _dbContext.SaveChangesAsync();

        return Ok();
    }

    [HttpGet("Check")]
    public async Task<IActionResult> Check()
    {
        return Ok(); // this is only for checking if already login
    }

    [HttpPost("Logout")]
    public async Task<IActionResult> Logout()
    {
        // some code here...
    }

    [HttpPost("Refresh-Token")]
    public async Task<IActionResult> RefreshToken()
    {
        // some code here for refreshing/rotating tokens...
    }

    private void GenerateAccessRefreshTokens(out Guid accessGuid, out Guid refreshGuid, out DateTime? refreshExpire)
    {
        accessGuid = Guid.NewGuid();
        refreshGuid = Guid.NewGuid();

        string accessToken = _tokenFactory.GenerateAccessToken(new[]
        {
            new Claim("jti", accessGuid.ToString())
        }, out _);

        string refreshToken = _tokenFactory.GenerateRefreshToken(new[]
        {
            new Claim("jti", refreshGuid.ToString())
        }, out refreshExpire);

        // add tokens to cookies, send back to client for authentication
        Response.Cookies.Append(
            _configuration.Value.Cookies?.AccessFullName!,
            accessToken,
            new CookieOptions
            {
                IsEssential = true,
                HttpOnly = true,
                Secure = Request.Scheme == "https",
                SameSite = SameSiteMode.Strict,
                Expires = DateTimeOffset.UtcNow.AddYears(1),
                Domain = Request.Host.Host,
                Path = "/api" // limit this cookie to be shown in this specific api path
            }
            );

        Response.Cookies.Append(
            _configuration.Value.Cookies?.RefreshFullName!,
            refreshToken,
            new CookieOptions
            {
                IsEssential = true,
                HttpOnly = true,
                Secure = Request.Scheme == "https",
                SameSite = SameSiteMode.Strict,
                Expires = DateTimeOffset.UtcNow.AddYears(1),
                Domain = Request.Host.Host,
                Path = "/api/accounts/refresh-token" // limit this cookie to be shown in this specific api path
            }
            );
    }

    private string? GetClientIpAddress()
    {
        return Request.HttpContext.Connection.RemoteIpAddress?.MapToIPv4().ToString();
    }

    private string? GetClientUserAgent()
    {
        return Request.Headers.UserAgent.ToString();
    }
}

Note: I did not include the code for generating the tokens, since it is working if called by ajax.

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.