├── .gitignore ├── Authorization ├── AllowAnonymousAttribute.cs ├── AuthorizeAttribute.cs ├── JwtMiddleware.cs └── JwtUtils.cs ├── Controllers └── UsersController.cs ├── Entities ├── RefreshToken.cs └── User.cs ├── Helpers ├── AppException.cs ├── AppSettings.cs ├── DataContext.cs └── ErrorHandlerMiddleware.cs ├── LICENSE ├── Models └── Users │ ├── AuthenticateRequest.cs │ ├── AuthenticateResponse.cs │ └── RevokeTokenRequest.cs ├── Program.cs ├── README.md ├── Services └── UserService.cs ├── WebApi.csproj ├── appsettings.json └── omnisharp.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | typings 33 | 34 | # Optional npm cache directory 35 | .npm 36 | 37 | # Optional REPL history 38 | .node_repl_history 39 | 40 | # .NET compiled files 41 | bin 42 | obj -------------------------------------------------------------------------------- /Authorization/AllowAnonymousAttribute.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Authorization; 2 | 3 | [AttributeUsage(AttributeTargets.Method)] 4 | public class AllowAnonymousAttribute : Attribute 5 | { } -------------------------------------------------------------------------------- /Authorization/AuthorizeAttribute.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Authorization; 2 | 3 | using Microsoft.AspNetCore.Mvc; 4 | using Microsoft.AspNetCore.Mvc.Filters; 5 | using WebApi.Entities; 6 | 7 | [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] 8 | public class AuthorizeAttribute : Attribute, IAuthorizationFilter 9 | { 10 | public void OnAuthorization(AuthorizationFilterContext context) 11 | { 12 | // skip authorization if action is decorated with [AllowAnonymous] attribute 13 | var allowAnonymous = context.ActionDescriptor.EndpointMetadata.OfType().Any(); 14 | if (allowAnonymous) 15 | return; 16 | 17 | // authorization 18 | var user = (User)context.HttpContext.Items["User"]; 19 | if (user == null) 20 | context.Result = new JsonResult(new { message = "Unauthorized" }) { StatusCode = StatusCodes.Status401Unauthorized }; 21 | } 22 | } -------------------------------------------------------------------------------- /Authorization/JwtMiddleware.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Authorization; 2 | 3 | using Microsoft.Extensions.Options; 4 | using WebApi.Helpers; 5 | using WebApi.Services; 6 | 7 | public class JwtMiddleware 8 | { 9 | private readonly RequestDelegate _next; 10 | private readonly AppSettings _appSettings; 11 | 12 | public JwtMiddleware(RequestDelegate next, IOptions appSettings) 13 | { 14 | _next = next; 15 | _appSettings = appSettings.Value; 16 | } 17 | 18 | public async Task Invoke(HttpContext context, IUserService userService, IJwtUtils jwtUtils) 19 | { 20 | var token = context.Request.Headers["Authorization"].FirstOrDefault()?.Split(" ").Last(); 21 | var userId = jwtUtils.ValidateJwtToken(token); 22 | if (userId != null) 23 | { 24 | // attach user to context on successful jwt validation 25 | context.Items["User"] = userService.GetById(userId.Value); 26 | } 27 | 28 | await _next(context); 29 | } 30 | } -------------------------------------------------------------------------------- /Authorization/JwtUtils.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Authorization; 2 | 3 | using Microsoft.Extensions.Options; 4 | using Microsoft.IdentityModel.Tokens; 5 | using System.IdentityModel.Tokens.Jwt; 6 | using System.Security.Claims; 7 | using System.Security.Cryptography; 8 | using System.Text; 9 | using WebApi.Entities; 10 | using WebApi.Helpers; 11 | 12 | public interface IJwtUtils 13 | { 14 | public string GenerateJwtToken(User user); 15 | public int? ValidateJwtToken(string token); 16 | public RefreshToken GenerateRefreshToken(string ipAddress); 17 | } 18 | 19 | public class JwtUtils : IJwtUtils 20 | { 21 | private DataContext _context; 22 | private readonly AppSettings _appSettings; 23 | 24 | public JwtUtils( 25 | DataContext context, 26 | IOptions appSettings) 27 | { 28 | _context = context; 29 | _appSettings = appSettings.Value; 30 | } 31 | 32 | public string GenerateJwtToken(User user) 33 | { 34 | // generate token that is valid for 15 minutes 35 | var tokenHandler = new JwtSecurityTokenHandler(); 36 | var key = Encoding.ASCII.GetBytes(_appSettings.Secret); 37 | var tokenDescriptor = new SecurityTokenDescriptor 38 | { 39 | Subject = new ClaimsIdentity(new[] { new Claim("id", user.Id.ToString()) }), 40 | Expires = DateTime.UtcNow.AddMinutes(15), 41 | SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature) 42 | }; 43 | var token = tokenHandler.CreateToken(tokenDescriptor); 44 | return tokenHandler.WriteToken(token); 45 | } 46 | 47 | public int? ValidateJwtToken(string token) 48 | { 49 | if (token == null) 50 | return null; 51 | 52 | var tokenHandler = new JwtSecurityTokenHandler(); 53 | var key = Encoding.ASCII.GetBytes(_appSettings.Secret); 54 | try 55 | { 56 | tokenHandler.ValidateToken(token, new TokenValidationParameters 57 | { 58 | ValidateIssuerSigningKey = true, 59 | IssuerSigningKey = new SymmetricSecurityKey(key), 60 | ValidateIssuer = false, 61 | ValidateAudience = false, 62 | // set clockskew to zero so tokens expire exactly at token expiration time (instead of 5 minutes later) 63 | ClockSkew = TimeSpan.Zero 64 | }, out SecurityToken validatedToken); 65 | 66 | var jwtToken = (JwtSecurityToken)validatedToken; 67 | var userId = int.Parse(jwtToken.Claims.First(x => x.Type == "id").Value); 68 | 69 | // return user id from JWT token if validation successful 70 | return userId; 71 | } 72 | catch 73 | { 74 | // return null if validation fails 75 | return null; 76 | } 77 | } 78 | 79 | public RefreshToken GenerateRefreshToken(string ipAddress) 80 | { 81 | var refreshToken = new RefreshToken 82 | { 83 | Token = getUniqueToken(), 84 | // token is valid for 7 days 85 | Expires = DateTime.UtcNow.AddDays(7), 86 | Created = DateTime.UtcNow, 87 | CreatedByIp = ipAddress 88 | }; 89 | 90 | return refreshToken; 91 | 92 | string getUniqueToken() 93 | { 94 | // token is a cryptographically strong random sequence of values 95 | var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)); 96 | // ensure token is unique by checking against db 97 | var tokenIsUnique = !_context.Users.Any(u => u.RefreshTokens.Any(t => t.Token == token)); 98 | 99 | if (!tokenIsUnique) 100 | return getUniqueToken(); 101 | 102 | return token; 103 | } 104 | } 105 | } -------------------------------------------------------------------------------- /Controllers/UsersController.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Controllers; 2 | 3 | using Microsoft.AspNetCore.Mvc; 4 | using WebApi.Authorization; 5 | using WebApi.Models.Users; 6 | using WebApi.Services; 7 | 8 | [Authorize] 9 | [ApiController] 10 | [Route("[controller]")] 11 | public class UsersController : ControllerBase 12 | { 13 | private IUserService _userService; 14 | 15 | public UsersController(IUserService userService) 16 | { 17 | _userService = userService; 18 | } 19 | 20 | [AllowAnonymous] 21 | [HttpPost("authenticate")] 22 | public IActionResult Authenticate(AuthenticateRequest model) 23 | { 24 | var response = _userService.Authenticate(model, ipAddress()); 25 | setTokenCookie(response.RefreshToken); 26 | return Ok(response); 27 | } 28 | 29 | [AllowAnonymous] 30 | [HttpPost("refresh-token")] 31 | public IActionResult RefreshToken() 32 | { 33 | var refreshToken = Request.Cookies["refreshToken"]; 34 | var response = _userService.RefreshToken(refreshToken, ipAddress()); 35 | setTokenCookie(response.RefreshToken); 36 | return Ok(response); 37 | } 38 | 39 | [HttpPost("revoke-token")] 40 | public IActionResult RevokeToken(RevokeTokenRequest model) 41 | { 42 | // accept refresh token in request body or cookie 43 | var token = model.Token ?? Request.Cookies["refreshToken"]; 44 | 45 | if (string.IsNullOrEmpty(token)) 46 | return BadRequest(new { message = "Token is required" }); 47 | 48 | _userService.RevokeToken(token, ipAddress()); 49 | return Ok(new { message = "Token revoked" }); 50 | } 51 | 52 | [HttpGet] 53 | public IActionResult GetAll() 54 | { 55 | var users = _userService.GetAll(); 56 | return Ok(users); 57 | } 58 | 59 | [HttpGet("{id}")] 60 | public IActionResult GetById(int id) 61 | { 62 | var user = _userService.GetById(id); 63 | return Ok(user); 64 | } 65 | 66 | [HttpGet("{id}/refresh-tokens")] 67 | public IActionResult GetRefreshTokens(int id) 68 | { 69 | var user = _userService.GetById(id); 70 | return Ok(user.RefreshTokens); 71 | } 72 | 73 | // helper methods 74 | 75 | private void setTokenCookie(string token) 76 | { 77 | // append cookie with refresh token to the http response 78 | var cookieOptions = new CookieOptions 79 | { 80 | HttpOnly = true, 81 | Expires = DateTime.UtcNow.AddDays(7) 82 | }; 83 | Response.Cookies.Append("refreshToken", token, cookieOptions); 84 | } 85 | 86 | private string ipAddress() 87 | { 88 | // get source ip address for the current request 89 | if (Request.Headers.ContainsKey("X-Forwarded-For")) 90 | return Request.Headers["X-Forwarded-For"]; 91 | else 92 | return HttpContext.Connection.RemoteIpAddress.MapToIPv4().ToString(); 93 | } 94 | } -------------------------------------------------------------------------------- /Entities/RefreshToken.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Entities; 2 | 3 | using Microsoft.EntityFrameworkCore; 4 | using System.ComponentModel.DataAnnotations; 5 | using System.Text.Json.Serialization; 6 | 7 | [Owned] 8 | public class RefreshToken 9 | { 10 | [Key] 11 | [JsonIgnore] 12 | public int Id { get; set; } 13 | public string Token { get; set; } 14 | public DateTime Expires { get; set; } 15 | public DateTime Created { get; set; } 16 | public string CreatedByIp { get; set; } 17 | public DateTime? Revoked { get; set; } 18 | public string RevokedByIp { get; set; } 19 | public string ReplacedByToken { get; set; } 20 | public string ReasonRevoked { get; set; } 21 | public bool IsExpired => DateTime.UtcNow >= Expires; 22 | public bool IsRevoked => Revoked != null; 23 | public bool IsActive => !IsRevoked && !IsExpired; 24 | } -------------------------------------------------------------------------------- /Entities/User.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Entities; 2 | 3 | using System.Text.Json.Serialization; 4 | 5 | public class User 6 | { 7 | public int Id { get; set; } 8 | public string FirstName { get; set; } 9 | public string LastName { get; set; } 10 | public string Username { get; set; } 11 | 12 | [JsonIgnore] 13 | public string PasswordHash { get; set; } 14 | 15 | [JsonIgnore] 16 | public List RefreshTokens { get; set; } 17 | } -------------------------------------------------------------------------------- /Helpers/AppException.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Helpers; 2 | 3 | using System.Globalization; 4 | 5 | // custom exception class for throwing application specific exceptions (e.g. for validation) 6 | // that can be caught and handled within the application 7 | public class AppException : Exception 8 | { 9 | public AppException() : base() {} 10 | 11 | public AppException(string message) : base(message) { } 12 | 13 | public AppException(string message, params object[] args) 14 | : base(String.Format(CultureInfo.CurrentCulture, message, args)) 15 | { 16 | } 17 | } -------------------------------------------------------------------------------- /Helpers/AppSettings.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Helpers; 2 | 3 | public class AppSettings 4 | { 5 | public string Secret { get; set; } 6 | 7 | // refresh token time to live (in days), inactive tokens are 8 | // automatically deleted from the database after this time 9 | public int RefreshTokenTTL { get; set; } 10 | } -------------------------------------------------------------------------------- /Helpers/DataContext.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Helpers; 2 | 3 | using Microsoft.EntityFrameworkCore; 4 | using WebApi.Entities; 5 | 6 | public class DataContext : DbContext 7 | { 8 | public DbSet Users { get; set; } 9 | 10 | private readonly IConfiguration Configuration; 11 | 12 | public DataContext(IConfiguration configuration) 13 | { 14 | Configuration = configuration; 15 | } 16 | 17 | protected override void OnConfiguring(DbContextOptionsBuilder options) 18 | { 19 | // in memory database used for simplicity, change to a real db for production applications 20 | options.UseInMemoryDatabase("TestDb"); 21 | } 22 | } -------------------------------------------------------------------------------- /Helpers/ErrorHandlerMiddleware.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Helpers; 2 | 3 | using System.Net; 4 | using System.Text.Json; 5 | 6 | public class ErrorHandlerMiddleware 7 | { 8 | private readonly RequestDelegate _next; 9 | 10 | public ErrorHandlerMiddleware(RequestDelegate next) 11 | { 12 | _next = next; 13 | } 14 | 15 | public async Task Invoke(HttpContext context) 16 | { 17 | try 18 | { 19 | await _next(context); 20 | } 21 | catch (Exception error) 22 | { 23 | var response = context.Response; 24 | response.ContentType = "application/json"; 25 | 26 | switch(error) 27 | { 28 | case AppException e: 29 | // custom application error 30 | response.StatusCode = (int)HttpStatusCode.BadRequest; 31 | break; 32 | case KeyNotFoundException e: 33 | // not found error 34 | response.StatusCode = (int)HttpStatusCode.NotFound; 35 | break; 36 | default: 37 | // unhandled error 38 | response.StatusCode = (int)HttpStatusCode.InternalServerError; 39 | break; 40 | } 41 | 42 | var result = JsonSerializer.Serialize(new { message = error?.Message }); 43 | await response.WriteAsync(result); 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Jason Watmore 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Models/Users/AuthenticateRequest.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Models.Users; 2 | 3 | using System.ComponentModel.DataAnnotations; 4 | 5 | public class AuthenticateRequest 6 | { 7 | [Required] 8 | public string Username { get; set; } 9 | 10 | [Required] 11 | public string Password { get; set; } 12 | } -------------------------------------------------------------------------------- /Models/Users/AuthenticateResponse.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Models.Users; 2 | 3 | using System.Text.Json.Serialization; 4 | using WebApi.Entities; 5 | 6 | public class AuthenticateResponse 7 | { 8 | public int Id { get; set; } 9 | public string FirstName { get; set; } 10 | public string LastName { get; set; } 11 | public string Username { get; set; } 12 | public string JwtToken { get; set; } 13 | 14 | [JsonIgnore] // refresh token is returned in http only cookie 15 | public string RefreshToken { get; set; } 16 | 17 | public AuthenticateResponse(User user, string jwtToken, string refreshToken) 18 | { 19 | Id = user.Id; 20 | FirstName = user.FirstName; 21 | LastName = user.LastName; 22 | Username = user.Username; 23 | JwtToken = jwtToken; 24 | RefreshToken = refreshToken; 25 | } 26 | } -------------------------------------------------------------------------------- /Models/Users/RevokeTokenRequest.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Models.Users; 2 | 3 | public class RevokeTokenRequest 4 | { 5 | public string Token { get; set; } 6 | } -------------------------------------------------------------------------------- /Program.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | using WebApi.Authorization; 3 | using WebApi.Entities; 4 | using WebApi.Helpers; 5 | using WebApi.Services; 6 | 7 | var builder = WebApplication.CreateBuilder(args); 8 | 9 | // add services to DI container 10 | { 11 | var services = builder.Services; 12 | var env = builder.Environment; 13 | 14 | services.AddDbContext(); 15 | services.AddCors(); 16 | services.AddControllers() 17 | .AddJsonOptions(x => x.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull); 18 | 19 | // configure strongly typed settings object 20 | services.Configure(builder.Configuration.GetSection("AppSettings")); 21 | 22 | // configure DI for application services 23 | services.AddScoped(); 24 | services.AddScoped(); 25 | } 26 | 27 | var app = builder.Build(); 28 | 29 | // add hardcoded test user to db on startup 30 | using (var scope = app.Services.CreateScope()) 31 | { 32 | var context = scope.ServiceProvider.GetRequiredService(); 33 | var testUser = new User 34 | { 35 | FirstName = "Test", 36 | LastName = "User", 37 | Username = "test", 38 | PasswordHash = BCrypt.Net.BCrypt.HashPassword("test") 39 | }; 40 | context.Users.Add(testUser); 41 | context.SaveChanges(); 42 | } 43 | 44 | // configure HTTP request pipeline 45 | { 46 | // global cors policy 47 | app.UseCors(x => x 48 | .SetIsOriginAllowed(origin => true) 49 | .AllowAnyMethod() 50 | .AllowAnyHeader() 51 | .AllowCredentials()); 52 | 53 | // global error handler 54 | app.UseMiddleware(); 55 | 56 | // custom jwt auth middleware 57 | app.UseMiddleware(); 58 | 59 | app.MapControllers(); 60 | } 61 | 62 | app.Run("http://localhost:4000"); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dotnet-6-jwt-refresh-tokens-api 2 | 3 | .NET 6.0 - JWT Authentication with Refresh Tokens Tutorial with Example API 4 | 5 | Documentation at https://jasonwatmore.com/post/2022/01/24/net-6-jwt-authentication-with-refresh-tokens-tutorial-with-example-api 6 | -------------------------------------------------------------------------------- /Services/UserService.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Services; 2 | 3 | using BCrypt.Net; 4 | using Microsoft.Extensions.Options; 5 | using WebApi.Entities; 6 | using WebApi.Helpers; 7 | using WebApi.Models.Users; 8 | using WebApi.Authorization; 9 | 10 | public interface IUserService 11 | { 12 | AuthenticateResponse Authenticate(AuthenticateRequest model, string ipAddress); 13 | AuthenticateResponse RefreshToken(string token, string ipAddress); 14 | void RevokeToken(string token, string ipAddress); 15 | IEnumerable GetAll(); 16 | User GetById(int id); 17 | } 18 | 19 | public class UserService : IUserService 20 | { 21 | private DataContext _context; 22 | private IJwtUtils _jwtUtils; 23 | private readonly AppSettings _appSettings; 24 | 25 | public UserService( 26 | DataContext context, 27 | IJwtUtils jwtUtils, 28 | IOptions appSettings) 29 | { 30 | _context = context; 31 | _jwtUtils = jwtUtils; 32 | _appSettings = appSettings.Value; 33 | } 34 | 35 | public AuthenticateResponse Authenticate(AuthenticateRequest model, string ipAddress) 36 | { 37 | var user = _context.Users.SingleOrDefault(x => x.Username == model.Username); 38 | 39 | // validate 40 | if (user == null || !BCrypt.Verify(model.Password, user.PasswordHash)) 41 | throw new AppException("Username or password is incorrect"); 42 | 43 | // authentication successful so generate jwt and refresh tokens 44 | var jwtToken = _jwtUtils.GenerateJwtToken(user); 45 | var refreshToken = _jwtUtils.GenerateRefreshToken(ipAddress); 46 | user.RefreshTokens.Add(refreshToken); 47 | 48 | // remove old refresh tokens from user 49 | removeOldRefreshTokens(user); 50 | 51 | // save changes to db 52 | _context.Update(user); 53 | _context.SaveChanges(); 54 | 55 | return new AuthenticateResponse(user, jwtToken, refreshToken.Token); 56 | } 57 | 58 | public AuthenticateResponse RefreshToken(string token, string ipAddress) 59 | { 60 | var user = getUserByRefreshToken(token); 61 | var refreshToken = user.RefreshTokens.Single(x => x.Token == token); 62 | 63 | if (refreshToken.IsRevoked) 64 | { 65 | // revoke all descendant tokens in case this token has been compromised 66 | revokeDescendantRefreshTokens(refreshToken, user, ipAddress, $"Attempted reuse of revoked ancestor token: {token}"); 67 | _context.Update(user); 68 | _context.SaveChanges(); 69 | } 70 | 71 | if (!refreshToken.IsActive) 72 | throw new AppException("Invalid token"); 73 | 74 | // replace old refresh token with a new one (rotate token) 75 | var newRefreshToken = rotateRefreshToken(refreshToken, ipAddress); 76 | user.RefreshTokens.Add(newRefreshToken); 77 | 78 | // remove old refresh tokens from user 79 | removeOldRefreshTokens(user); 80 | 81 | // save changes to db 82 | _context.Update(user); 83 | _context.SaveChanges(); 84 | 85 | // generate new jwt 86 | var jwtToken = _jwtUtils.GenerateJwtToken(user); 87 | 88 | return new AuthenticateResponse(user, jwtToken, newRefreshToken.Token); 89 | } 90 | 91 | public void RevokeToken(string token, string ipAddress) 92 | { 93 | var user = getUserByRefreshToken(token); 94 | var refreshToken = user.RefreshTokens.Single(x => x.Token == token); 95 | 96 | if (!refreshToken.IsActive) 97 | throw new AppException("Invalid token"); 98 | 99 | // revoke token and save 100 | revokeRefreshToken(refreshToken, ipAddress, "Revoked without replacement"); 101 | _context.Update(user); 102 | _context.SaveChanges(); 103 | } 104 | 105 | public IEnumerable GetAll() 106 | { 107 | return _context.Users; 108 | } 109 | 110 | public User GetById(int id) 111 | { 112 | var user = _context.Users.Find(id); 113 | if (user == null) throw new KeyNotFoundException("User not found"); 114 | return user; 115 | } 116 | 117 | // helper methods 118 | 119 | private User getUserByRefreshToken(string token) 120 | { 121 | var user = _context.Users.SingleOrDefault(u => u.RefreshTokens.Any(t => t.Token == token)); 122 | 123 | if (user == null) 124 | throw new AppException("Invalid token"); 125 | 126 | return user; 127 | } 128 | 129 | private RefreshToken rotateRefreshToken(RefreshToken refreshToken, string ipAddress) 130 | { 131 | var newRefreshToken = _jwtUtils.GenerateRefreshToken(ipAddress); 132 | revokeRefreshToken(refreshToken, ipAddress, "Replaced by new token", newRefreshToken.Token); 133 | return newRefreshToken; 134 | } 135 | 136 | private void removeOldRefreshTokens(User user) 137 | { 138 | // remove old inactive refresh tokens from user based on TTL in app settings 139 | user.RefreshTokens.RemoveAll(x => 140 | !x.IsActive && 141 | x.Created.AddDays(_appSettings.RefreshTokenTTL) <= DateTime.UtcNow); 142 | } 143 | 144 | private void revokeDescendantRefreshTokens(RefreshToken refreshToken, User user, string ipAddress, string reason) 145 | { 146 | // recursively traverse the refresh token chain and ensure all descendants are revoked 147 | if(!string.IsNullOrEmpty(refreshToken.ReplacedByToken)) 148 | { 149 | var childToken = user.RefreshTokens.SingleOrDefault(x => x.Token == refreshToken.ReplacedByToken); 150 | if (childToken.IsActive) 151 | revokeRefreshToken(childToken, ipAddress, reason); 152 | else 153 | revokeDescendantRefreshTokens(childToken, user, ipAddress, reason); 154 | } 155 | } 156 | 157 | private void revokeRefreshToken(RefreshToken token, string ipAddress, string reason = null, string replacedByToken = null) 158 | { 159 | token.Revoked = DateTime.UtcNow; 160 | token.RevokedByIp = ipAddress; 161 | token.ReasonRevoked = reason; 162 | token.ReplacedByToken = replacedByToken; 163 | } 164 | } -------------------------------------------------------------------------------- /WebApi.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | net6.0 4 | enable 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "AppSettings": { 3 | "Secret": "THIS IS USED TO SIGN AND VERIFY JWT TOKENS, REPLACE IT WITH YOUR OWN SECRET, IT CAN BE ANY STRING", 4 | "RefreshTokenTTL": 2 5 | }, 6 | "Logging": { 7 | "LogLevel": { 8 | "Default": "Information", 9 | "Microsoft.AspNetCore": "Warning" 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /omnisharp.json: -------------------------------------------------------------------------------- 1 | { 2 | "msbuild": { 3 | "useBundledOnly": true 4 | } 5 | } --------------------------------------------------------------------------------