简体   繁体   中英

Implement Remember Me utilizing Cookie with expiration time instead of LocalStorage

I have been trying to get this working now for quite a some time but can't figure out how to do it properly. I am able to implement Rememeber Me with LocalStorage. However I want to implement Remember Me functionality with JWT using cookie where I would be able to set expiration time. I think I have messed up the login logic? Can somebody point out what is wrong here?

I can also add other parts from my application if necessary.

AuthorizeController.cs:

[HttpPost]
public async Task<IActionResult> Login([FromBody] LoginModel login)
{
  ApplicationUser user = await this.SignInManager.UserManager.FindByEmailAsync(login.Email);

  if (user == null)
  {
    List<string> errors = new List<string>();
    errors.Add("No such user has been found.");
    return BadRequest(new LoginResult
    {
      Successful = false,
      Errors = errors,
    });
  }

  bool emailConfirmed = await this.UserManager.IsEmailConfirmedAsync(user);

  if (!emailConfirmed)
  {
    List<string> errors = new List<string>();
    errors.Add("Email not confirmed.");
    return BadRequest(new LoginResult
    {
      Successful = false,
      Errors = errors,
    });
  }

  Microsoft.AspNetCore.Identity.SignInResult result =
    await this.SignInManager.PasswordSignInAsync(login.Email, login.Password, login.RememberMe, false);

  if (!result.Succeeded)
  {
    List<string> errors = new List<string>();
    errors.Add("Email and password are invalid.");
    return BadRequest(new LoginResult
    {
      Successful = false,
      Errors = errors,
    });
  }

  IList<string> roles = await this.SignInManager.UserManager.GetRolesAsync(user);

  List<Claim> claims = new List<Claim>
  {
    new Claim(ClaimTypes.Name, login.Email)
  };

  ClaimsIdentity identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
  ClaimsPrincipal principal = new ClaimsPrincipal(identity);
  AuthenticationProperties props = new AuthenticationProperties
  {
    IsPersistent = true,
    ExpiresUtc = DateTime.UtcNow.AddMonths(1)
  };

  // to register the cookie to the browser
  this.HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal, props).Wait();

  foreach (string role in roles)
  {
    claims.Add(new Claim(ClaimTypes.Role, role));
  }

  SymmetricSecurityKey key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(this.Configuration["JwtSecurityKey"]));
  SigningCredentials creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
  DateTime expiry = DateTime.Now.AddDays(Convert.ToInt32(this.Configuration["JwtExpiryInDays"]));

  JwtSecurityToken token = new JwtSecurityToken(
    this.Configuration["JwtIssuer"],
    this.Configuration["JwtAudience"],
    claims,
    expires: expiry,
    signingCredentials: creds
  );

  return Ok(new LoginResult
  {
    Successful = true,
    Token = new JwtSecurityTokenHandler().WriteToken(token),
  });
}

Startup.cs:

  services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
      options.TokenValidationParameters = new TokenValidationParameters
      {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidIssuer = Configuration["JwtIssuer"],
        ValidAudience = Configuration["JwtAudience"],
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["JwtSecurityKey"]))
      };
    })
    .AddCookie(options =>
     {
       options.Cookie.Name = "MySpecialCookie";
       options.LoginPath = "/login";
       //options.LogoutPath = "/Home/Index";
       //options.AccessDeniedPath = "AccessDenied";
       options.ExpireTimeSpan = TimeSpan.FromDays(30);
       options.SlidingExpiration = true; // the cookie would be re-issued on any request half way through the ExpireTimeSpan
                                         //options.Cookie.Expiration = TimeSpan.FromDays(5);
       options.EventsType = typeof(CookieAuthEvent);
     });
  services.AddScoped<CookieAuthEvent>();

  services.AddAuthorization(config =>
  {
    config.AddPolicy(Policies.IsAdmin, Policies.IsAdminPolicy());
    config.AddPolicy(Policies.IsUser, Policies.IsUserPolicy());
  });

  services.ConfigureApplicationCookie(options =>
  {
    options.Cookie.HttpOnly = true;
    options.Events.OnRedirectToLogin = context =>
    {
      context.Response.StatusCode = 401;
      return Task.CompletedTask;
    };
  });

On Client side I am currently using AuthorizeApi with LocalStorage. This is working but I want to move this to Cookie.

AuthorizeApi.cs:

public async Task<LoginResult> Login(LoginModel loginModel)
{
  //var stringContent = new StringContent(JsonSerializer.Serialize(LoginModel), Encoding.UTF8, "application/json");
  HttpResponseMessage responseMessage = await this.HttpClient.PostAsJsonAsync("Authorize/Login", loginModel);
  LoginResult result = await responseMessage.Content.ReadFromJsonAsync<LoginResult>();

  if (result.Successful)
  {
    if (loginModel.RememberMe)
    {
      await this.LocalStorage.SetItemAsync("MySpecialToken", result.Token);
    }

    ((ApiAuthenticationStateProvider)this.AuthenticationStateProvider).MarkUserAsAuthenticated(result.Token);
    this.HttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("bearer", result.Token);

    return result;
  }

  return result;
}

ApiAuthenticationStateProvider.cs:

public void MarkUserAsAuthenticated(string token)
{
  ClaimsPrincipal authenticatedUser = new ClaimsPrincipal(new ClaimsIdentity(ParseClaimsFromJwt(token), "jwt"));
  Task<AuthenticationState> authState = Task.FromResult(new AuthenticationState(authenticatedUser));
  NotifyAuthenticationStateChanged(authState);
}

On an older project I created custom authentication that used cookies to store the session identifier.

The process was simple. Start by adding a service to house the tokens for the current user session, that will probably be sent to your API, or alternatively update your injected HttpClient right after authentication or on your MainLayout once you've retrieved the value from the cookie:

if (!httpClient.DefaultRequestHeaders.Contains("SessionID"))
    httpClient.DefaultRequestHeaders.Add("SessionID", await JSRuntime.InvokeAsync<string>("MyJs.Cookies.Get", "SessionID"));

Then on your MainLayout check if the cookie has a value before you navigate to authentication. To do this, I used the following JavaScript:

window.MyJs = {
    Cookies: {
        Set: function (name, value, date) {
            var d = new Date(date);
            var expires = "expires=" + d.toUTCString();
            document.cookie = name + "=" + value + ";" + expires + ";path=/";
        },
        Get: function (name) {
            name = name + "=";
            var ca = document.cookie.split(';');
            for (var i = 0; i < ca.length; i++) {
                var c = ca[i];
                while (c.charAt(0) == ' ') {
                    c = c.substring(1);
                }
                if (c.indexOf(name) == 0) {
                    return c.substring(name.length, c.length);
                }
            }
            return "";
        },
        Remove: function (name) {
            RadixTrie.Cookies.Set(name, "", "01 Jan 1970 00:00:00 UTC");
        }
    }
}

Do the check in MainLayout like this:

protected override async Task OnAfterRenderAsync(bool firstRender) {
    if (firstRender)
    {
        ...
        var sessionID = await JSRuntime.InvokeAsync<string>("MyJs.Cookies.Get", "SessionID");

        if (string.IsNullOrWhiteSpace(sessionID))
            NavigationManager.NavigateTo("Auth/Login", true);
        ...
    }
}

Once you've authenticated, you can just set the cookie:

internal async Task Login() {
    ...
    await JSRuntime.InvokeVoidAsync("MyJs.Cookies.Set", "SessionID", loginResponse.Token, loginResponse.Expires);
    //{loginResponse.Token:string} {loginResponse.Expires:datetime}
    ...
}

EDIT:

I wasn't satisfied with this answer and took some time to consider alternatives. I don't have a fully coded solution, but a better way of implementing a token for authentication, and in your case to keep a session live, would be using Set-Cookie header in your responses from your API.

I suggest creating middleware to handle the reading and resetting of the token.

But let's start with the login. Once a user authenticated, you can update the response in your endpoint like:

[HttpPost]
public async Task<IActionResult> Login([FromBody] LoginModel login)
{
    ...
    Response.Headers.Add("Set-Cookie", $"SessionID={Guid.NewGuid()}; Expires={DateTime.Now.AddMonths(1).ToString("dd MMM yyyy hh:mm:ss") + " UTC"}; HttpOnly"); //Valid for 1 month, HttpOnly
    ...
    return Ok();
}

It would be a good idea to make the token and cookie string generation reusable at this point. Consider encryption too.

Thereafter, add middleware to your API and on each request read the cookie to get the token:

public async Task Invoke(HttpContext context)
{
    ...
    context.Request.Cookies.TryGetValue("SessionID", out string sessionID);
    ...
    await _next(context);
    ...
    //Reset the token after each request for improved security
    context.Response.Headers.Add("Set-Cookie", $"SessionID={Guid.NewGuid()}; Expires={DateTime.Now.AddMonths(1).ToString("dd MMM yyyy hh:mm:ss") + " UTC"}; HttpOnly"); //Valid for 1 month, HttpOnly
}

EDIT 2:

If a cookie is reset and there are asynchronous work being done using the API, some requests might fail if a request is made just after the cookie change before being updated. This is a very small window and should be low in chance. I will take some time in the future to test this theory and update this answer.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM