Part-4 |Blazor WebAssembly[.NET 7] JWT Authentication Series | Implement Refresh Token & User Logout
The main objectives of this article are:
- Implement Refresh Token
- User Logout
Refresh Token:
When the JWT access token expires to renew it without user credentials we will use the Refresh Token.
- The user sends a valid 'User Name' and 'Password' to the server, then the server will generate JWT Access Token and Refresh Token sent as a response.
- The JWT Access Token is a short-lived token(eg: 20 minutes) and Refresh Token is a long live token(eg: 7 days).
- Now client application sends a JWT access token in the request header that makes the user authenticated.
- If the JWT token expires then the server returns a 401 authorized response.
- Then the client sends the refresh token to the server to regenerate the JWT Access Token. The server validates the refresh token and returns a new JWT Access Token and a new Refresh Token as a response.
SQL Script To Create UserRefreshToken Table:
Let's run the below sql script to create 'UserRefreshToken' table that contains columns like 'Token', 'UserId', and 'ExpirationDate'.
CREATE TABLE [dbo].[UserRefreshToken]( [Id] INT IDENTITY(1,1) NOT NULL, [Token] VARCHAR(100) NOT NULL, [UserId] INT NOT NULL, [ExpirationDate] DATETIME NOT NULL CONSTRAINT PK_UserRefreshToken PRIMARY KEY (Id) )
Refresh Token In Login Endpoint Response:
Let's implement a refresh token as a login endpoint response.
In 'UserService.cs' add a private method like 'GenerateRefreshToken()'.
API_Project/Services/UserServices.cs:
private async Task<string> GenerateRefreshToken(int userId) { byte[] bytetoken = new byte[32]; using (var randomGenerator = RandomNumberGenerator.Create()) { randomGenerator.GetBytes(bytetoken); } var token = Convert.ToBase64String(bytetoken); var newRefreshToken = new UserRefreshToken { UserId = userId, Token = token, ExpirationDate = DateTime.UtcNow.AddDays(3), }; _dbContext.UserRefreshToken.Add(newRefreshToken); await _dbContext.SaveChangesAsync(); return token; }
- (Line: 3-8) Generated the random string which we use as a refresh token.
- (Line: 9-16) Saving the refresh token in the database along with expiration and user id value.
API_Project/Dtos/JWTTokenResponseDto.cs:
namespace JWT.Auth.BlazorUI.ViewModels.Account { public class JWTTokenResponseVM { public string AccessToken { get; set; } public string RefreshToken { get; set; } } }
- Added 'RefreshToken' property.
API_Project/Services/UserServices.cs:
public async Task<(bool IsLoginSucess, JWTTokenResponseDto TokenResponse)> LoginAsync(LoginDto loginpayload) { if (string.IsNullOrEmpty(loginpayload.Email) || string.IsNullOrEmpty(loginpayload.Password)) { return (false, null); } var user = await _dbContext.User.Where(_ => _.Email.ToLower() == loginpayload.Email.ToLower()) .FirstOrDefaultAsync(); if (user == null) { return (false, null); } bool validUserPassowrd = PasswordVerification(loginpayload.Password, user.Password); if (!validUserPassowrd) { return (false, null); } string jwtAccessToken = GenerateJwtAccessToken(user); string refreshToken = await GenerateRefreshToken(user.Id); var result = new JWTTokenResponseDto { AccessToken = jwtAccessToken, RefreshToken = refreshToken, }; return (true, result); }
- (Line: 19) Fetching the Refresh Token
Store Refresh Token In Browser LocalStorage:
Let's update the 'JWTTokenResponseVm' as below.
BlazorWasm_App/ViewModels/Account/JWTTokenResponseVm.cs:
namespace JWT.Auth.BlazorUI.ViewModels.Account { public class JWTTokenResponseVM { public string AccessToken { get; set; } public string RefreshToken { get; set; } } }
- Add the 'RefreshToken' property.
BlazorWasm_App/Pages/Account/Login.razor:(LoginAsync method)
else if (response.StatusCode == System.Net.HttpStatusCode.OK) { var tokenResponse = await response.Content.ReadFromJsonAsync<JWTTokenResponseVM>(); await _localStorageService.SetItemAsync<string>("jwt-access-token", tokenResponse.AccessToken); await _localStorageService.SetItemAsync<string>("refresh-token",tokenResponse.RefreshToken); (_authStateProvider as CustomAuthProvider).NotifyAuthState(); _navigationManager.NavigateTo("/fetchdata"); }
- (Line: 5) Saving refresh token value to browser local storage.
Implement Refresh Token Endpoint:
Let's create a new request object like 'RenewTokenRequestDto.cs'.
API_Project/Dtos/RenewTokenRequestDto.cs:
namespace JWT.Auth.API.Dtos { public class RenewTokenRequestDto { public int UserId { get; set; } public string RefreshToken { get; set; } } }
- Here is our payload object for the refresh token endpoint.
API_Project/Services/IUserService.cs:
Task<(string ErrorsMessage, JWTTokenResponseDto JWTTokenResponse)> RenewTokenAsync(RenewTokenRequestDto renewTokenRequest);In 'UserService' let's implement the following method.
API_Project/Services/UserService.cs:
public async Task<(string ErrorsMessage,JWTTokenResponseDto JWTTokenResponse)> RenewTokenAsync(RenewTokenRequestDto renewTokenRequest) { var existingRefreshToken = await _dbContext.UserRefreshToken .Where(_ => _.UserId == renewTokenRequest.UserId && _.Token == renewTokenRequest.RefreshToken && _.ExpirationDate > DateTime.Now).FirstOrDefaultAsync(); if(existingRefreshToken == null) { return ("Invalid Refresh Token", null); } _dbContext.Remove(existingRefreshToken); await _dbContext.SaveChangesAsync(); var user = await _dbContext.User.Where(_ => _.Id == renewTokenRequest.UserId).FirstOrDefaultAsync(); string jwtAccessToken = GenerateJwtAccessToken(user); string refreshToken = await GenerateRefreshToken(user.Id); var result = new JWTTokenResponseDto { AccessToken = jwtAccessToken, RefreshToken = refreshToken, }; return ("", result); }
- (Line: 3-6) Fetching the refresh token record from the 'UserRefreshToken' table.
- (Line: 8-11) If a record is not found then return an error message.
- (Line: 12-13) If a record exists then we delete the record because we will create a new refresh token along with the JWT access token.
- (Line: 15-25) Generating the new JWT Access token and new Refresh token.
API_Project/Controllers/UserController.cs:
[HttpPost("renew-tokens")] public async Task<IActionResult> RenewTokensAsync(RenewTokenRequestDto renewTokenRequest) { var result = await _userService.RenewTokenAsync(renewTokenRequest); if (!string.IsNullOrEmpty(result.ErrorsMessage)) { return BadRequest(result.ErrorsMessage); } return Ok(result.JWTTokenResponse); }
Use Refresh Token In Blazor WebAssembly Application:
In our 'CustomAuthProvider' class we have methods like 'ParseCliamsFromJwt'(make this method public) , 'ParseBase64WithoutPadding()' move this method into a new file like 'Utility.cs'(new file under 'Providers' folder)
BlazorWasm/Shared/Providers/Utility.cs:
using JWT.Auth.BlazorUI.ViewModels.Account; using Microsoft.AspNetCore.Components.Authorization; using System.Security.Claims; using System.Text.Json; namespace JWT.Auth.BlazorUI.Shared.Providers { public class Utility { public static IEnumerable<Claim> ParseClaimsFromJwt(string jwt) { var claims = new List<Claim>(); var payload = jwt.Split('.')[1]; var jsonBytes = ParseBase64WithoutPadding(payload); var keyValuePairs = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonBytes); claims.AddRange(keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString()))); return claims; } private static byte[] ParseBase64WithoutPadding(string base64) { switch (base64.Length % 4) { case 2: base64 += "=="; break; case 3: base64 += "="; break; } return Convert.FromBase64String(base64); } } }Now in our 'CustomAuthProvider.cs' consume this method from the 'Utility.cs'
BlazorWasm/Shared/Providers/CustomAuthProvider.cs:
return new AuthenticationState( new ClaimsPrincipal(new ClaimsIdentity(Utility.ParseClaimsFromJwt(jwtToken), "jwtAuth")));In the Blazor application let's create a new model like 'RenewTokenRequestVm'.
BlazorWasm_App/ViewModels/Account/RenewTokenRequest.cs:
namespace JWT.Auth.BlazorUI.ViewModels.Account { public class RenewTokenRequestVm { public int UserId { get; set; } public string RefreshToken { get; set; } } }Now in 'CustomHttpHandler' let's update the logic in such a way that if the existing jwt access token expires then invoke the refresh token endpoint.
BlazorWasm/Shared/Providers/CustomHttpHandler.cs:
using Blazored.LocalStorage; using JWT.Auth.BlazorUI.ViewModels.Account; using System.Text.Json; using System.Text; using System.Net.Http.Json; using Microsoft.AspNetCore.Components.Authorization; using System.Threading; using System.Net; namespace JWT.Auth.BlazorUI.Shared.Providers { public class CustomHttpHandler: DelegatingHandler { private readonly ILocalStorageService _localStorageService; private readonly IHttpClientFactory _httpClientFactory; private readonly AuthenticationStateProvider _authStateProvider; public CustomHttpHandler(ILocalStorageService localStorageService, IHttpClientFactory httpClientFactory, AuthenticationStateProvider authenticationStateProvider) { _localStorageService = localStorageService; _httpClientFactory = httpClientFactory; _authStateProvider = authenticationStateProvider; } protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { if(request.RequestUri.AbsolutePath.ToLower().Contains("login") || request.RequestUri.AbsolutePath.ToLower().Contains("registration") || request.RequestUri.AbsolutePath.ToLower().Contains("renew-tokens")) { return await base.SendAsync(request, cancellationToken); } var jwtToken = await _localStorageService.GetItemAsync<string>("jwt-access-token"); if (!string.IsNullOrEmpty(jwtToken)) { request.Headers.Add("Authorization", $"bearer {jwtToken}"); } var originalResponse = await base.SendAsync(request, cancellationToken); if(originalResponse.StatusCode == HttpStatusCode.Unauthorized) { return await InvokeRefreshCall(request, originalResponse,jwtToken, cancellationToken); } return originalResponse; } private async Task<HttpResponseMessage> InvokeRefreshCall(HttpRequestMessage originalRequest, HttpResponseMessage originalResponse, string expiredJwtToken, CancellationToken cancellationToken) { var refreshToken = await _localStorageService.GetItemAsync<string>("refresh-token"); var userCliams = Utility.ParseClaimsFromJwt(expiredJwtToken); var renewTokenRequest = new RenewTokenRequestVm(); renewTokenRequest.UserId= userCliams.ToList().Where(_ => _.Type.ToLower() == "sub").Select(_ => Convert.ToInt32(_.Value)).FirstOrDefault(); renewTokenRequest.RefreshToken = refreshToken; var jsonPayload = JsonSerializer.Serialize(renewTokenRequest); var requestContent = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); var httpClient = _httpClientFactory.CreateClient("API"); var refreshTokenresponse = await httpClient.PostAsync("/api/User/renew-tokens", requestContent); if(refreshTokenresponse.StatusCode == HttpStatusCode.OK) { var regeneratedTokenResponse = await refreshTokenresponse.Content.ReadFromJsonAsync<JWTTokenResponseVM>(); await _localStorageService.SetItemAsync<string>("jwt-access-token", regeneratedTokenResponse.AccessToken); await _localStorageService.SetItemAsync<string>("refresh-token", regeneratedTokenResponse.RefreshToken); (_authStateProvider as CustomAuthProvider).NotifyAuthState(); originalRequest.Headers.Remove("Authorization"); originalRequest.Headers.Add("Authorization", $"bearer {regeneratedTokenResponse.AccessToken}"); return await base.SendAsync(originalRequest, cancellationToken); } return originalResponse; } } }
- (Line: 18&19) Injected 'AuthenticationStateProvider' & 'IHttpClientFactory'.
- (Line: 29) Here we checking our refresh token endpoint, because the refresh token endpoint not need authentication.
- (Line: 41) Storing the actual request response to 'originalResponse' variable
- (Line: 41-46) If the original response status is 401 unauthorized then we execute the 'InvokeRefreshCall' method. Other than 401 status original response will be return as output.
- (Line: 49-53) Here 'InvokeRefreshCall' method contains input parameters like previous failed requests and responses.
- (Line: 54) Fetch the refresh token from the browser's local storage.
- (Line: 56) Get 'UserId' value from jwt access token
- (Line: 58-63) Prepare payload for the refresh token endpoint.
- (Line: 64-66) Invoke the refresh token endpoint.
- (Line: 68-80) Check refresh token endpoint success. Then store the new jwt access token and new refresh token in the browser's local storage. Also refreshes the authentication state of the Blazor web assembly application by invoking 'NotifyAuthState()' method. Refresh the authorization header value inside of the 'originalRequest' object and again invoke the original request with new jwt access token.
Logout Endpoint In API Project:
Let's create a logout endpoint in our API project.
Let's create a new request object like 'Dtos/LogoutRequest.cs'.
API_Project/Dtos/LogourRequest.cs:
namespace JWT.Auth.API.Dtos { public class LogoutRequestDto { public int UserId { get; set; } public string? RefreshToken { get; set; } } }In 'IUserService' let's add the following method definition.
API_Project/Services/IUserService.cs:
Task UserLogoutAsync(LogoutRequestDto logoutRequest);In 'UserService' let's add the following method implementation.
API_Project/Services/UserService.cs:
public async Task UserLogoutAsync(LogoutRequestDto logoutRequest) { var tokenToDelete = await _dbContext.UserRefreshToken .Where(_ => _.UserId == logoutRequest.UserId && _.Token == logoutRequest.RefreshToken).FirstOrDefaultAsync(); if(tokenToDelete != null) { _dbContext.Remove(tokenToDelete); await _dbContext.SaveChangesAsync(); } }Here we clear our refresh token from the database to make the user log out.
Let's add the endpoint in our user controller.
API_Project/Services/UserController.cs:
[HttpPost("logout")] public async Task<IActionResult> Logout(LogoutRequestDto logoutRequest) { await _userService.UserLogoutAsync(logoutRequest); return Ok(); }
Consume Logout Endpoint From BlazorWebAssembly Application:
In the Blazor application let's create a view model like 'LogoutVm'.
BlazorWasm/ViewModels/LogoutVm.cs:
namespace JWT.Auth.BlazorUI.ViewModels.Account { public class LogoutVm { public int UserId { get; set; } public string? RefreshToken { get; set; } } }Now invoke the logout api from the 'MainLayout.razor' component.
BlazorWasm/Shared/MainLayout.razor:(HTML Part)
@using JWT.Auth.BlazorUI.Shared.Providers; @using System.Security.Claims @using Blazored.LocalStorage; @using System.Text.Json; @using System.Text; @inject IHttpClientFactory _httpClientFactory @inherits LayoutComponentBase @inject ILocalStorageService _localStorageService @inject AuthenticationStateProvider _authStateProvider; @inject NavigationManager _navigationManager <MudThemeProvider /> <MudDialogProvider /> <MudSnackbarProvider /> <MudLayout> <MudAppBar Color="Color.Primary" > <MudLink Underline="Underline.None" Color="Color.Inherit" Href="/">JWT Auth Demo</MudLink> <AuthorizeView> <Authorized> <MudLink Underline="Underline.None" Color="Color.Inherit" Href="/fetchdata" Class="ml-2">Weather Forecast</MudLink> </Authorized> </AuthorizeView> <MudSpacer /> <AuthorizeView> <Authorized> <span class="mr-2">@(UserDisplayName(context.User.Claims.ToList()))</span> <MudLink Underline="Underline.None" Color="Color.Inherit" OnClick="(() => LogoutAsync(context.User.Claims.ToList()))">Logout</MudLink> </Authorized> <NotAuthorized> <MudLink Underline="Underline.None" Color="Color.Inherit" Href="/login" Class="mr-2">Login</MudLink> <MudLink Underline="Underline.None" Color="Color.Inherit" Href="/registration">Registration</MudLink> </NotAuthorized> </AuthorizeView> </MudAppBar> <MudMainContent> @Body </MudMainContent> </MudLayout>
- (Line: 6) Inject the 'IHttpClientFactory'.
- (Line: 8) Inject the 'ILocalStorageService'.
- (Line: 9) Inject the 'AuthenticationStateProvider'.
- (Line: 10) Inject the 'NavigationManager'.
- (Line: 28) Registered logout click functionality.
@code { private string UserDisplayName(List<Claim> claims) { var firstName = claims.Where(_ => _.Type == "FirstName").Select(_ => _.Value).FirstOrDefault(); var lastName = claims.Where(_ => _.Type == "LastName").Select(_ => _.Value).FirstOrDefault(); if(!string.IsNullOrEmpty(firstName) || !string.IsNullOrEmpty(lastName)) { return $"{firstName} {lastName}"; } var email = claims.Where(_ => _.Type == "Email").Select(_ => _.Value).FirstOrDefault(); return email; } private async Task LogoutAsync(List<Claim> claims) { var logout = new LogoutVm(); logout.UserId = Convert.ToInt32(claims.Where(_ => _.Type == "Sub").Select(_ => _.Value).FirstOrDefault() ?? "0"); logout.RefreshToken = await _localStorageService.GetItemAsync<string>("refresh-token"); var jsonPayload = JsonSerializer.Serialize(logout); var requestContent = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); var httpClient = _httpClientFactory.CreateClient("API"); var response = await httpClient.PostAsync("/api/user/logout", requestContent); await _localStorageService.RemoveItemAsync("refresh-token"); await _localStorageService.RemoveItemAsync("jwt-access-token"); (_authStateProvider as CustomAuthProvider).NotifyAuthState(); _navigationManager.NavigateTo("/login"); } }
- (Line: 15-32) Here we implemented the 'LogoutAsync' method.
- (Line: 18-25) For the logout endpoint we pass the 'userid', 'refreshtoken' values as the payload.
- (Line: 27-31) Clear the browser local storage values and then navigate to the login page.
Support Me!
Buy Me A Coffee
PayPal Me
Wrapping Up:
Hopefully, I think this article delivered some useful information on the.NET7 Blazor WebAssembly JWT Authentication. using I love to have your feedback, suggestions, and better techniques in the comment section below.
Comments
Post a Comment