├── .gitignore ├── .vscode ├── launch.json └── tasks.json ├── Controllers ├── AccountController.cs ├── HomeController.cs └── TokenController.cs ├── DataAccess ├── User.cs └── UsersDb.cs ├── LICENSE ├── Migrations ├── 20180529222831_Initial.Designer.cs ├── 20180529222831_Initial.cs └── UsersDbModelSnapshot.cs ├── Program.cs ├── README.md ├── RefreshTokensWebApiExample.csproj ├── Services ├── IPasswordHasher.cs ├── ITokenService.cs ├── PasswordHasher.cs └── TokenService.cs ├── Startup.cs ├── Views └── Home │ └── Index.cshtml ├── appsettings.json ├── users.sqlite └── wwwroot ├── index.js └── styles.css /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.userosscache 8 | *.sln.docstates 9 | 10 | # User-specific files (MonoDevelop/Xamarin Studio) 11 | *.userprefs 12 | 13 | # Build results 14 | [Dd]ebug/ 15 | [Dd]ebugPublic/ 16 | [Rr]elease/ 17 | [Rr]eleases/ 18 | x64/ 19 | x86/ 20 | build/ 21 | bld/ 22 | [Bb]in/ 23 | [Oo]bj/ 24 | 25 | # Visual Studio 2015 cache/options directory 26 | .vs/ 27 | # Uncomment if you have tasks that create the project's static files in wwwroot 28 | #wwwroot/ 29 | 30 | # MSTest test Results 31 | [Tt]est[Rr]esult*/ 32 | [Bb]uild[Ll]og.* 33 | 34 | # NUNIT 35 | *.VisualState.xml 36 | TestResult.xml 37 | 38 | # Build Results of an ATL Project 39 | [Dd]ebugPS/ 40 | [Rr]eleasePS/ 41 | dlldata.c 42 | 43 | # DNX 44 | project.lock.json 45 | artifacts/ 46 | 47 | *_i.c 48 | *_p.c 49 | *_i.h 50 | *.ilk 51 | *.meta 52 | *.obj 53 | *.pch 54 | *.pdb 55 | *.pgc 56 | *.pgd 57 | *.rsp 58 | *.sbr 59 | *.tlb 60 | *.tli 61 | *.tlh 62 | *.tmp 63 | *.tmp_proj 64 | *.log 65 | *.vspscc 66 | *.vssscc 67 | .builds 68 | *.pidb 69 | *.svclog 70 | *.scc 71 | 72 | # Chutzpah Test files 73 | _Chutzpah* 74 | 75 | # Visual C++ cache files 76 | ipch/ 77 | *.aps 78 | *.ncb 79 | *.opendb 80 | *.opensdf 81 | *.sdf 82 | *.cachefile 83 | 84 | # Visual Studio profiler 85 | *.psess 86 | *.vsp 87 | *.vspx 88 | *.sap 89 | 90 | # TFS 2012 Local Workspace 91 | $tf/ 92 | 93 | # Guidance Automation Toolkit 94 | *.gpState 95 | 96 | # ReSharper is a .NET coding add-in 97 | _ReSharper*/ 98 | *.[Rr]e[Ss]harper 99 | *.DotSettings.user 100 | 101 | # JustCode is a .NET coding add-in 102 | .JustCode 103 | 104 | # TeamCity is a build add-in 105 | _TeamCity* 106 | 107 | # DotCover is a Code Coverage Tool 108 | *.dotCover 109 | 110 | # NCrunch 111 | _NCrunch_* 112 | .*crunch*.local.xml 113 | nCrunchTemp_* 114 | 115 | # MightyMoose 116 | *.mm.* 117 | AutoTest.Net/ 118 | 119 | # Web workbench (sass) 120 | .sass-cache/ 121 | 122 | # Installshield output folder 123 | [Ee]xpress/ 124 | 125 | # DocProject is a documentation generator add-in 126 | DocProject/buildhelp/ 127 | DocProject/Help/*.HxT 128 | DocProject/Help/*.HxC 129 | DocProject/Help/*.hhc 130 | DocProject/Help/*.hhk 131 | DocProject/Help/*.hhp 132 | DocProject/Help/Html2 133 | DocProject/Help/html 134 | 135 | # Click-Once directory 136 | publish/ 137 | 138 | # Publish Web Output 139 | *.[Pp]ublish.xml 140 | *.azurePubxml 141 | # TODO: Comment the next line if you want to checkin your web deploy settings 142 | # but database connection strings (with potential passwords) will be unencrypted 143 | *.pubxml 144 | *.publishproj 145 | 146 | # NuGet Packages 147 | *.nupkg 148 | # The packages folder can be ignored because of Package Restore 149 | **/packages/* 150 | # except build/, which is used as an MSBuild target. 151 | !**/packages/build/ 152 | # Uncomment if necessary however generally it will be regenerated when needed 153 | #!**/packages/repositories.config 154 | 155 | # Microsoft Azure Build Output 156 | csx/ 157 | *.build.csdef 158 | 159 | # Microsoft Azure Emulator 160 | ecf/ 161 | rcf/ 162 | 163 | # Microsoft Azure ApplicationInsights config file 164 | ApplicationInsights.config 165 | 166 | # Windows Store app package directory 167 | AppPackages/ 168 | BundleArtifacts/ 169 | 170 | # Visual Studio cache files 171 | # files ending in .cache can be ignored 172 | *.[Cc]ache 173 | # but keep track of directories ending in .cache 174 | !*.[Cc]ache/ 175 | 176 | # Others 177 | ClientBin/ 178 | ~$* 179 | *~ 180 | *.dbmdl 181 | *.dbproj.schemaview 182 | *.pfx 183 | *.publishsettings 184 | node_modules/ 185 | orleans.codegen.cs 186 | 187 | # RIA/Silverlight projects 188 | Generated_Code/ 189 | 190 | # Backup & report files from converting an old project file 191 | # to a newer Visual Studio version. Backup files are not needed, 192 | # because we have git ;-) 193 | _UpgradeReport_Files/ 194 | Backup*/ 195 | UpgradeLog*.XML 196 | UpgradeLog*.htm 197 | 198 | # SQL Server files 199 | *.mdf 200 | *.ldf 201 | 202 | # Business Intelligence projects 203 | *.rdl.data 204 | *.bim.layout 205 | *.bim_*.settings 206 | 207 | # Microsoft Fakes 208 | FakesAssemblies/ 209 | 210 | # GhostDoc plugin setting file 211 | *.GhostDoc.xml 212 | 213 | # Node.js Tools for Visual Studio 214 | .ntvs_analysis.dat 215 | 216 | # Visual Studio 6 build log 217 | *.plg 218 | 219 | # Visual Studio 6 workspace options file 220 | *.opt 221 | 222 | # Visual Studio LightSwitch build output 223 | **/*.HTMLClient/GeneratedArtifacts 224 | **/*.DesktopClient/GeneratedArtifacts 225 | **/*.DesktopClient/ModelManifest.xml 226 | **/*.Server/GeneratedArtifacts 227 | **/*.Server/ModelManifest.xml 228 | _Pvt_Extensions 229 | 230 | # Paket dependency manager 231 | .paket/paket.exe 232 | 233 | # FAKE - F# Make 234 | .fake/ 235 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to find out which attributes exist for C# debugging 3 | // Use hover for the description of the existing attributes 4 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": ".NET Core Launch (web)", 9 | "type": "coreclr", 10 | "request": "launch", 11 | "preLaunchTask": "build", 12 | // If you have changed target frameworks, make sure to update the program path. 13 | "program": "${workspaceFolder}/bin/Debug/netcoreapp2.0/RefreshTokensWebApiExample.dll", 14 | "args": [], 15 | "cwd": "${workspaceFolder}", 16 | "stopAtEntry": false, 17 | "internalConsoleOptions": "openOnSessionStart", 18 | "launchBrowser": { 19 | "enabled": true, 20 | "args": "${auto-detect-url}", 21 | "windows": { 22 | "command": "cmd.exe", 23 | "args": "/C start ${auto-detect-url}" 24 | }, 25 | "osx": { 26 | "command": "open" 27 | }, 28 | "linux": { 29 | "command": "xdg-open" 30 | } 31 | }, 32 | "env": { 33 | "ASPNETCORE_ENVIRONMENT": "Development" 34 | }, 35 | "sourceFileMap": { 36 | "/Views": "${workspaceFolder}/Views" 37 | } 38 | }, 39 | { 40 | "name": ".NET Core Attach", 41 | "type": "coreclr", 42 | "request": "attach", 43 | "processId": "${command:pickProcess}" 44 | } 45 | ,] 46 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "args": [ 9 | "build", 10 | "${workspaceFolder}/RefreshTokensWebApiExample.csproj" 11 | ], 12 | "problemMatcher": "$msCompile", 13 | "group": { 14 | "kind": "build", 15 | "isDefault": true 16 | } 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /Controllers/AccountController.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Security.Claims; 3 | using System.Threading.Tasks; 4 | using Microsoft.AspNetCore.Mvc; 5 | using RefreshTokensWebApiExample.DataAccess; 6 | using RefreshTokensWebApiExample.Services; 7 | 8 | namespace RefreshTokensWebApiExample.Controllers 9 | { 10 | public class AccountController : Controller 11 | { 12 | private readonly UsersDb _usersDb; 13 | private readonly IPasswordHasher _passwordHasher; 14 | private readonly ITokenService _tokenService; 15 | public AccountController(UsersDb usersDb, IPasswordHasher passwordHasher, ITokenService tokenService) 16 | { 17 | _usersDb = usersDb; 18 | _passwordHasher = passwordHasher; 19 | _tokenService = tokenService; 20 | } 21 | [HttpPost] 22 | public async Task Signup(string username, string password) 23 | { 24 | var user = _usersDb.Users.SingleOrDefault(u => u.Username == username); 25 | if (user != null) return StatusCode(409); 26 | 27 | _usersDb.Users.Add(new User 28 | { 29 | Username = username, 30 | Password = _passwordHasher.GenerateIdentityV3Hash(password) 31 | }); 32 | 33 | 34 | await _usersDb.SaveChangesAsync(); 35 | 36 | return Ok(user); 37 | } 38 | 39 | [HttpPost] 40 | public async Task Login(string username, string password) 41 | { 42 | var user = _usersDb.Users.SingleOrDefault(u => u.Username == username); 43 | if (user == null || !_passwordHasher.VerifyIdentityV3Hash(password, user.Password)) return BadRequest(); 44 | 45 | var usersClaims = new [] 46 | { 47 | new Claim(ClaimTypes.Name, user.Username), 48 | new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()) 49 | }; 50 | 51 | var jwtToken = _tokenService.GenerateAccessToken(usersClaims); 52 | var refreshToken = _tokenService.GenerateRefreshToken(); 53 | 54 | user.RefreshToken = refreshToken; 55 | await _usersDb.SaveChangesAsync(); 56 | 57 | return new ObjectResult(new { 58 | token = jwtToken, 59 | refreshToken = refreshToken 60 | }); 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /Controllers/HomeController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.AspNetCore.Authorization; 3 | using Microsoft.AspNetCore.Mvc; 4 | using Microsoft.Extensions.Configuration; 5 | 6 | namespace RefreshTokensWebApiExample.Controllers 7 | { 8 | public class HomeController : Controller 9 | { 10 | private readonly IConfiguration _configuration; 11 | public HomeController(IConfiguration configuration) 12 | { 13 | _configuration = configuration; 14 | } 15 | 16 | public IActionResult Index() 17 | { 18 | return View(int.Parse(_configuration["accessTokenDurationInMinutes"])); 19 | } 20 | 21 | [Authorize] 22 | public IActionResult Test() 23 | { 24 | return Content($"The user: {User.Identity.Name} made an authenticated call at {DateTime.Now.ToString("HH:mm:ss")}", "text/plain"); 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /Controllers/TokenController.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Threading.Tasks; 3 | using Microsoft.AspNetCore.Authorization; 4 | using Microsoft.AspNetCore.Mvc; 5 | using RefreshTokensWebApiExample.DataAccess; 6 | using RefreshTokensWebApiExample.Services; 7 | 8 | namespace RefreshTokensWebApiExample.Controllers 9 | { 10 | public class TokenController : Controller 11 | { 12 | private readonly ITokenService _tokenService; 13 | private readonly UsersDb _usersDb; 14 | public TokenController(ITokenService tokenService, UsersDb usersDb) 15 | { 16 | _tokenService = tokenService; 17 | _usersDb = usersDb; 18 | } 19 | 20 | [HttpPost] 21 | public async Task Refresh(string token, string refreshToken) 22 | { 23 | var principal = _tokenService.GetPrincipalFromExpiredToken(token); 24 | var username = principal.Identity.Name; //this is mapped to the Name claim by default 25 | 26 | var user = _usersDb.Users.SingleOrDefault(u => u.Username == username); 27 | if (user == null || user.RefreshToken != refreshToken) return BadRequest(); 28 | 29 | var newJwtToken = _tokenService.GenerateAccessToken(principal.Claims); 30 | var newRefreshToken = _tokenService.GenerateRefreshToken(); 31 | 32 | user.RefreshToken = newRefreshToken; 33 | await _usersDb.SaveChangesAsync(); 34 | 35 | return new ObjectResult(new 36 | { 37 | token = newJwtToken, 38 | refreshToken = newRefreshToken 39 | }); 40 | } 41 | 42 | [HttpPost, Authorize] 43 | public async Task Revoke() 44 | { 45 | var username = User.Identity.Name; 46 | 47 | var user = _usersDb.Users.SingleOrDefault(u => u.Username == username); 48 | if (user == null) return BadRequest(); 49 | 50 | user.RefreshToken = null; 51 | 52 | await _usersDb.SaveChangesAsync(); 53 | 54 | return NoContent(); 55 | } 56 | 57 | } 58 | } -------------------------------------------------------------------------------- /DataAccess/User.cs: -------------------------------------------------------------------------------- 1 | namespace RefreshTokensWebApiExample.DataAccess 2 | { 3 | public class User 4 | { 5 | public int Id { get; set; } 6 | public string Username { get; set; } 7 | public string Password { get; set; } 8 | public string RefreshToken { get; set; } 9 | } 10 | } -------------------------------------------------------------------------------- /DataAccess/UsersDb.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace RefreshTokensWebApiExample.DataAccess 4 | { 5 | public class UsersDb : DbContext 6 | { 7 | public DbSet Users { get; set; } 8 | 9 | public UsersDb(DbContextOptions options): base(options) { } 10 | } 11 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Rui Figueiredo 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 | -------------------------------------------------------------------------------- /Migrations/20180529222831_Initial.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Infrastructure; 4 | using Microsoft.EntityFrameworkCore.Migrations; 5 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 6 | using RefreshTokensWebApiExample.DataAccess; 7 | 8 | namespace RefreshTokensWebApiExample.Migrations 9 | { 10 | [DbContext(typeof(UsersDb))] 11 | [Migration("20180529222831_Initial")] 12 | partial class Initial 13 | { 14 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 15 | { 16 | #pragma warning disable 612, 618 17 | modelBuilder 18 | .HasAnnotation("ProductVersion", "2.1.0-rtm-30799"); 19 | 20 | modelBuilder.Entity("RefreshTokensWebApiExample.DataAccess.User", b => 21 | { 22 | b.Property("Id") 23 | .ValueGeneratedOnAdd(); 24 | 25 | b.Property("Password"); 26 | 27 | b.Property("RefreshToken"); 28 | 29 | b.Property("Username"); 30 | 31 | b.HasKey("Id"); 32 | 33 | b.ToTable("Users"); 34 | }); 35 | #pragma warning restore 612, 618 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Migrations/20180529222831_Initial.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | namespace RefreshTokensWebApiExample.Migrations 4 | { 5 | public partial class Initial : Migration 6 | { 7 | protected override void Up(MigrationBuilder migrationBuilder) 8 | { 9 | migrationBuilder.CreateTable( 10 | name: "Users", 11 | columns: table => new 12 | { 13 | Id = table.Column(nullable: false) 14 | .Annotation("Sqlite:Autoincrement", true), 15 | Username = table.Column(nullable: true), 16 | Password = table.Column(nullable: true), 17 | RefreshToken = table.Column(nullable: true) 18 | }, 19 | constraints: table => 20 | { 21 | table.PrimaryKey("PK_Users", x => x.Id); 22 | }); 23 | } 24 | 25 | protected override void Down(MigrationBuilder migrationBuilder) 26 | { 27 | migrationBuilder.DropTable( 28 | name: "Users"); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Migrations/UsersDbModelSnapshot.cs: -------------------------------------------------------------------------------- 1 | // 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Infrastructure; 4 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 5 | using RefreshTokensWebApiExample.DataAccess; 6 | 7 | namespace RefreshTokensWebApiExample.Migrations 8 | { 9 | [DbContext(typeof(UsersDb))] 10 | partial class UsersDbModelSnapshot : ModelSnapshot 11 | { 12 | protected override void BuildModel(ModelBuilder modelBuilder) 13 | { 14 | #pragma warning disable 612, 618 15 | modelBuilder 16 | .HasAnnotation("ProductVersion", "2.1.0-rtm-30799"); 17 | 18 | modelBuilder.Entity("RefreshTokensWebApiExample.DataAccess.User", b => 19 | { 20 | b.Property("Id") 21 | .ValueGeneratedOnAdd(); 22 | 23 | b.Property("Password"); 24 | 25 | b.Property("RefreshToken"); 26 | 27 | b.Property("Username"); 28 | 29 | b.HasKey("Id"); 30 | 31 | b.ToTable("Users"); 32 | }); 33 | #pragma warning restore 612, 618 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore; 7 | using Microsoft.AspNetCore.Hosting; 8 | using Microsoft.Extensions.Configuration; 9 | using Microsoft.Extensions.Logging; 10 | 11 | namespace RefreshTokensWebApiExample 12 | { 13 | public class Program 14 | { 15 | public static void Main(string[] args) 16 | { 17 | BuildWebHost(args).Run(); 18 | } 19 | 20 | public static IWebHost BuildWebHost(string[] args) => 21 | WebHost.CreateDefaultBuilder(args) 22 | .UseStartup() 23 | .UseUrls("http://localhost:8080") 24 | .Build(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Refresh Tokens in ASP.NET Web Api Core Demo Project 2 | 3 | Example of a Web Api built using ASP.NET Core that uses refresh tokens to keep the user signed in. 4 | 5 | To find out more about using Refresh and JSON Web Tokens in ASP.NET Core read the [blog post](https://www.blinkingcaret.com/2018/05/30/refresh-tokens-in-asp-net-core-web-api/) for which this repo is the sample project. -------------------------------------------------------------------------------- /RefreshTokensWebApiExample.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | netcoreapp2.0 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /Services/IPasswordHasher.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Cryptography.KeyDerivation; 2 | 3 | namespace RefreshTokensWebApiExample.Services 4 | { 5 | public interface IPasswordHasher 6 | { 7 | string GenerateIdentityV3Hash(string password, KeyDerivationPrf prf = KeyDerivationPrf.HMACSHA256, int iterationCount = 10000, int saltSize = 16); 8 | bool VerifyIdentityV3Hash(string password, string passwordHash); 9 | } 10 | } -------------------------------------------------------------------------------- /Services/ITokenService.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Security.Claims; 3 | 4 | namespace RefreshTokensWebApiExample.Services 5 | { 6 | public interface ITokenService 7 | { 8 | string GenerateAccessToken(IEnumerable claims); 9 | string GenerateRefreshToken(); 10 | ClaimsPrincipal GetPrincipalFromExpiredToken(string token); 11 | } 12 | } -------------------------------------------------------------------------------- /Services/PasswordHasher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Security.Cryptography; 4 | using Microsoft.AspNetCore.Cryptography.KeyDerivation; 5 | 6 | namespace RefreshTokensWebApiExample.Services 7 | { 8 | //More information about PasswordHasher here: https://www.blinkingcaret.com/2017/11/29/asp-net-identity-passwordhash/ 9 | public class PasswordHasher : IPasswordHasher 10 | { 11 | public string GenerateIdentityV3Hash(string password, KeyDerivationPrf prf = KeyDerivationPrf.HMACSHA256, int iterationCount = 10000, int saltSize = 16) 12 | { 13 | using (var rng = RandomNumberGenerator.Create()){ 14 | var salt = new byte[saltSize]; 15 | rng.GetBytes(salt); 16 | 17 | var pbkdf2Hash = KeyDerivation.Pbkdf2(password, salt, prf, iterationCount, 32); 18 | return Convert.ToBase64String(ComposeIdentityV3Hash(salt, (uint)iterationCount, pbkdf2Hash)); 19 | } 20 | } 21 | public bool VerifyIdentityV3Hash(string password, string passwordHash) 22 | { 23 | var identityV3HashArray = Convert.FromBase64String(passwordHash); 24 | if (identityV3HashArray[0] != 1) throw new InvalidOperationException("passwordHash is not Identity V3"); 25 | 26 | var prfAsArray = new byte[4]; 27 | Buffer.BlockCopy(identityV3HashArray, 1, prfAsArray, 0, 4); 28 | var prf = (KeyDerivationPrf)ConvertFromNetworOrder(prfAsArray); 29 | 30 | var iterationCountAsArray = new byte[4]; 31 | Buffer.BlockCopy(identityV3HashArray, 5, iterationCountAsArray, 0, 4); 32 | var iterationCount = (int)ConvertFromNetworOrder(iterationCountAsArray); 33 | 34 | var saltSizeAsArray = new byte[4]; 35 | Buffer.BlockCopy(identityV3HashArray, 9, saltSizeAsArray, 0, 4); 36 | var saltSize = (int)ConvertFromNetworOrder(saltSizeAsArray); 37 | 38 | var salt = new byte[saltSize]; 39 | Buffer.BlockCopy(identityV3HashArray, 13, salt, 0, saltSize); 40 | 41 | var savedHashedPassword = new byte[identityV3HashArray.Length - 1 - 4 - 4 - 4 - saltSize]; 42 | Buffer.BlockCopy(identityV3HashArray, 13 + saltSize, savedHashedPassword, 0, savedHashedPassword.Length); 43 | 44 | var hashFromInputPassword = KeyDerivation.Pbkdf2(password, salt, prf, iterationCount, 32); 45 | 46 | return AreByteArraysEqual(hashFromInputPassword, savedHashedPassword); 47 | } 48 | private byte[] ComposeIdentityV3Hash(byte[] salt, uint iterationCount, byte[] passwordHash) 49 | { 50 | var hash = new byte[1 + 4/*KeyDerivationPrf value*/ + 4/*Iteration count*/ + 4/*salt size*/ + salt.Length /*salt*/ + 32 /*password hash size*/]; 51 | hash[0] = 1; //Identity V3 marker 52 | 53 | Buffer.BlockCopy(ConvertToNetworkOrder((uint)KeyDerivationPrf.HMACSHA256), 0, hash, 1, sizeof(uint)); 54 | Buffer.BlockCopy(ConvertToNetworkOrder((uint)iterationCount), 0, hash, 1 + sizeof(uint), sizeof(uint)); 55 | Buffer.BlockCopy(ConvertToNetworkOrder((uint)salt.Length), 0, hash, 1 + 2 * sizeof(uint), sizeof(uint)); 56 | Buffer.BlockCopy(salt, 0, hash, 1 + 3 * sizeof(uint), salt.Length); 57 | Buffer.BlockCopy(passwordHash, 0, hash, 1 + 3 * sizeof(uint) + salt.Length, passwordHash.Length); 58 | 59 | return hash; 60 | } 61 | 62 | private bool AreByteArraysEqual(byte[] array1, byte[] array2) 63 | { 64 | if (array1.Length != array2.Length) return false; 65 | 66 | var areEqual = true; 67 | for (var i = 0; i < array1.Length; i++) 68 | { 69 | areEqual &= (array1[i] == array2[i]); 70 | } 71 | //If you stop as soon as the arrays don't match you'll be disclosing information about how different they are by the time it takes to compare them 72 | //this way no information is disclosed 73 | return areEqual; 74 | } 75 | 76 | private byte[] ConvertToNetworkOrder(uint number) 77 | { 78 | return BitConverter.GetBytes(number).Reverse().ToArray(); 79 | } 80 | 81 | private uint ConvertFromNetworOrder(byte[] reversedUint) 82 | { 83 | return BitConverter.ToUInt32(reversedUint.Reverse().ToArray(), 0); 84 | } 85 | } 86 | } -------------------------------------------------------------------------------- /Services/TokenService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IdentityModel.Tokens.Jwt; 4 | using System.Security.Claims; 5 | using System.Security.Cryptography; 6 | using System.Text; 7 | using Microsoft.Extensions.Configuration; 8 | using Microsoft.IdentityModel.Tokens; 9 | 10 | namespace RefreshTokensWebApiExample.Services 11 | { 12 | public class TokenService : ITokenService 13 | { 14 | private readonly IConfiguration _configuration; 15 | 16 | public TokenService(IConfiguration configuration) 17 | { 18 | _configuration = configuration; 19 | } 20 | public string GenerateAccessToken(IEnumerable claims) 21 | { 22 | var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["serverSigningPassword"])); 23 | 24 | var jwtToken = new JwtSecurityToken(issuer: "Blinkingcaret", 25 | audience: "Anyone", 26 | claims: claims, 27 | notBefore: DateTime.UtcNow, 28 | expires: DateTime.UtcNow.AddMinutes(int.Parse(_configuration["accessTokenDurationInMinutes"])), 29 | signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256) 30 | ); 31 | 32 | return new JwtSecurityTokenHandler().WriteToken(jwtToken); 33 | } 34 | 35 | public string GenerateRefreshToken() 36 | { 37 | var randomNumber = new byte[32]; 38 | using (var rng = RandomNumberGenerator.Create()) 39 | { 40 | rng.GetBytes(randomNumber); 41 | return Convert.ToBase64String(randomNumber); 42 | } 43 | } 44 | public ClaimsPrincipal GetPrincipalFromExpiredToken(string token) 45 | { 46 | var tokenValidationParameters = new TokenValidationParameters 47 | { 48 | ValidateAudience = false, //you might want to validate the audience and issuer depending on your use case 49 | ValidateIssuer = false, 50 | ValidateIssuerSigningKey = true, 51 | IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["serverSigningPassword"])), 52 | ValidateLifetime = false //here we are saying that we don't care about the token's expiration date 53 | }; 54 | 55 | var tokenHandler = new JwtSecurityTokenHandler(); 56 | SecurityToken securityToken; 57 | var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out securityToken); 58 | var jwtSecurityToken = securityToken as JwtSecurityToken; 59 | if (jwtSecurityToken == null || !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase)) 60 | throw new SecurityTokenException("Invalid token"); 61 | 62 | return principal; 63 | } 64 | } 65 | } -------------------------------------------------------------------------------- /Startup.cs: -------------------------------------------------------------------------------- 1 |  2 | using System; 3 | using System.Text; 4 | using System.Threading.Tasks; 5 | using Microsoft.AspNetCore.Authentication.JwtBearer; 6 | using Microsoft.AspNetCore.Builder; 7 | using Microsoft.AspNetCore.Hosting; 8 | using Microsoft.Extensions.Configuration; 9 | using Microsoft.Extensions.DependencyInjection; 10 | using Microsoft.IdentityModel.Tokens; 11 | using Microsoft.EntityFrameworkCore; 12 | using RefreshTokensWebApiExample.DataAccess; 13 | using RefreshTokensWebApiExample.Services; 14 | 15 | namespace RefreshTokensWebApiExample 16 | { 17 | public class Startup 18 | { 19 | public IConfiguration Configuration { get; set; } 20 | 21 | public Startup(IHostingEnvironment env) 22 | { 23 | Configuration = new ConfigurationBuilder() 24 | .SetBasePath(env.ContentRootPath) 25 | .AddJsonFile("appsettings.json") 26 | .Build(); 27 | } 28 | 29 | public void ConfigureServices(IServiceCollection services) 30 | { 31 | services.AddSingleton(provider => Configuration); 32 | services.AddDbContext(options => options.UseSqlite("Data Source=users.sqlite")); 33 | services.AddTransient(); 34 | services.AddTransient(); 35 | services.AddMvc(); 36 | services.AddAuthentication(options => 37 | { 38 | options.DefaultScheme = "bearer"; 39 | }).AddJwtBearer("bearer", options => 40 | { 41 | options.TokenValidationParameters = new TokenValidationParameters 42 | { 43 | ValidateAudience = false, 44 | ValidateIssuer = false, 45 | ValidateIssuerSigningKey = true, 46 | IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["serverSigningPassword"])), 47 | ValidateLifetime = true, 48 | ClockSkew = TimeSpan.Zero //the default for this setting is 5 minutes 49 | }; 50 | 51 | options.Events = new JwtBearerEvents 52 | { 53 | OnAuthenticationFailed = context => 54 | { 55 | if (context.Exception.GetType() == typeof(SecurityTokenExpiredException)) 56 | { 57 | context.Response.Headers.Add("Token-Expired", "true"); 58 | } 59 | return Task.CompletedTask; 60 | } 61 | }; 62 | }); 63 | } 64 | 65 | public void Configure(IApplicationBuilder app, IHostingEnvironment env, UsersDb usersDb) 66 | { 67 | if (env.IsDevelopment()) 68 | { 69 | app.UseDeveloperExceptionPage(); 70 | usersDb.Database.Migrate(); 71 | } 72 | 73 | app.UseAuthentication(); 74 | 75 | app.UseStaticFiles(); 76 | 77 | app.UseMvcWithDefaultRoute(); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Views/Home/Index.cshtml: -------------------------------------------------------------------------------- 1 | @model int 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |

Refresh Tokens in ASP.NET Web Api Core Demo Project

15 |

Access Token lifetime is set to @Model minute(s).

16 |

Signed in As: Anonymous

17 | 18 | 19 | 20 |
21 | Create New User 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 31 |
32 | Login 33 | 34 | 35 | 36 | 37 | 38 | 39 |
40 | 41 | 42 | 43 |
44 | 45 | 46 | -------------------------------------------------------------------------------- /appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "serverSigningPassword": "This is the password used for the key that will be used to sign the tokens. The server signing password needs to be at least 16 chars and should be stored safely, not in a json file as here. This is just an example. Use for example user-secrets or azure key vault.", 3 | "accessTokenDurationInMinutes": 1 4 | } -------------------------------------------------------------------------------- /users.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ruidfigueiredo/RefreshTokensWebApiExample/e394513254f0d1eed674fd866a6ff4aaf6712ec0/users.sqlite -------------------------------------------------------------------------------- /wwwroot/index.js: -------------------------------------------------------------------------------- 1 | window.addEventListener('load', function (event) { 2 | localStorage.clear(); //if the user refreshes the page the tokens are discarded 3 | 4 | document.getElementById('btCreate').addEventListener('click', onAddNewUserClicked); 5 | document.getElementById('btLogin').addEventListener('click', onLoginClicked); 6 | document.getElementById('btLogout').addEventListener('click', onLogoutClicked); 7 | document.getElementById('btAuthenticatedRequest').addEventListener('click', onPerformAuthenticatedRequestClicked); 8 | document.getElementById('btRevoke').addEventListener('click', onRevokeClicked); 9 | }); 10 | 11 | function writeFeedback(feedback) { 12 | document.getElementById('feedbackContainer').textContent = feedback; 13 | } 14 | 15 | function onAddNewUserClicked() { 16 | var username = document.getElementById('newUsername').value; 17 | var password = document.getElementById('password').value; 18 | var repassword = document.getElementById('repassword').value; 19 | 20 | if (username === '') { 21 | writeFeedback("Username is required"); 22 | return; 23 | } 24 | 25 | if (password != repassword) { 26 | writeFeedback("Passwords don't match"); 27 | return; 28 | } 29 | 30 | fetch('/account/signup', { 31 | method: 'POST', 32 | headers: { 33 | 'Content-Type': 'application/x-www-form-urlencoded' 34 | }, 35 | body: `username=${username}&password=${password}` 36 | }).then(response => { 37 | if (response.ok) 38 | writeFeedback(`User ${username} was created, you can now login`); 39 | else 40 | writeFeedback("Error creating new username, make sure you haven't used this username before"); 41 | }); 42 | } 43 | 44 | async function onLoginClicked() { 45 | var username = document.getElementById('username').value; 46 | var password = document.getElementById('loginPassword').value; 47 | 48 | try { 49 | await login(username, password); 50 | writeFeedback(''); 51 | document.getElementById('signedInAs').textContent = username; 52 | } catch { 53 | writeFeedback('Wrong username/password'); 54 | } 55 | } 56 | 57 | async function login(username, password) { 58 | var loginResponse = await fetch('/account/login', { 59 | method: 'POST', 60 | body: "username=" + username + "&password=" + password, 61 | headers: { 62 | 'Content-Type': 'application/x-www-form-urlencoded' 63 | } 64 | }); 65 | if (loginResponse.ok) { 66 | var tokens = await loginResponse.json(); 67 | saveJwtToken(tokens.token); 68 | saveRefreshToken(tokens.refreshToken); 69 | } else { 70 | throw new Error("Failed to login"); 71 | } 72 | } 73 | 74 | function onLogoutClicked() { //wouldn't be a bad idea to send a request to the server that would invalidate the refresh token 75 | localStorage.clear(); 76 | document.getElementById('signedInAs').textContent = 'Anonymous'; 77 | writeFeedback(''); 78 | } 79 | 80 | function getJwtToken() { 81 | return localStorage.getItem('token'); 82 | } 83 | function saveJwtToken(token) { 84 | localStorage.setItem('token', token); 85 | } 86 | 87 | function saveRefreshToken(refreshToken) { 88 | localStorage.setItem('refreshToken', refreshToken); 89 | } 90 | 91 | function getRefreshToken() { 92 | return localStorage.getItem('refreshToken'); 93 | } 94 | 95 | async function onPerformAuthenticatedRequestClicked() { 96 | writeFeedback(''); 97 | var response = await fetchWithCredentials('/home/test'); 98 | if (response.ok) { 99 | writeFeedback(await response.text()); 100 | } else { 101 | writeFeedback(`Request failed with status code: ${response.status}`); 102 | } 103 | } 104 | 105 | async function onRevokeClicked(){ 106 | writeFeedback(''); 107 | var revokeResponse = await revoke(); 108 | if (revokeResponse.ok) { 109 | writeFeedback('Refresh token was revoked, when the access token (JWT) expires authenticated requests will start to fail'); 110 | } else { 111 | writeFeedback(`Revoke failed with status code: ${revokeResponse.status}`); 112 | } 113 | } 114 | 115 | async function fetchWithCredentials(url, options) { 116 | var jwtToken = getJwtToken(); 117 | options = options || {}; 118 | options.headers = options.headers || {}; 119 | options.headers['Authorization'] = 'Bearer ' + jwtToken; 120 | var response = await fetch(url, options); 121 | if (response.ok) { //all is good, return the response 122 | return response; 123 | } 124 | 125 | if (response.status === 401 && response.headers.has('Token-Expired')) { 126 | var refreshToken = getRefreshToken(); 127 | 128 | var refreshResponse = await refresh(jwtToken, refreshToken); 129 | if (!refreshResponse.ok) { 130 | return response; //failed to refresh so return original 401 response 131 | } 132 | var jsonRefreshResponse = await refreshResponse.json(); //read the json with the new tokens 133 | 134 | saveJwtToken(jsonRefreshResponse.token); 135 | saveRefreshToken(jsonRefreshResponse.refreshToken); 136 | return await fetchWithCredentials(url, options); //repeat the original request 137 | } else { //status is not 401 and/or there's no Token-Expired header 138 | return response; //return the original 401 response 139 | } 140 | } 141 | 142 | function refresh(jwtToken, refreshToken) { 143 | return fetch('token/refresh', { 144 | method: 'POST', 145 | body: `token=${encodeURIComponent(jwtToken)}&refreshToken=${encodeURIComponent(getRefreshToken())}`, 146 | headers: { 147 | 'Content-Type': 'application/x-www-form-urlencoded' 148 | } 149 | }); 150 | } 151 | 152 | function revoke() { 153 | return fetchWithCredentials('token/revoke', { 154 | method: 'POST' 155 | }); 156 | } -------------------------------------------------------------------------------- /wwwroot/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Arial, Helvetica, sans-serif; 3 | font-size: 18px; 4 | } 5 | .container { 6 | max-width: 80rem; 7 | margin: auto; 8 | } 9 | 10 | .feedback { 11 | margin: 5px 0; 12 | background: rgb(223, 220, 220); 13 | font-size: large; 14 | font-weight: bold; 15 | } 16 | 17 | fieldset { 18 | margin: 30px 0; 19 | padding: 20px; 20 | } 21 | 22 | button { 23 | background: rgb(54, 107, 33); 24 | border: 0; 25 | padding: 10px; 26 | color: white; 27 | font-size: 1rem; 28 | text-decoration: none; 29 | } 30 | button:active { 31 | transform: translate(1px, 1px); 32 | } 33 | 34 | --------------------------------------------------------------------------------