[
  {
    "path": ".gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\n\n# Runtime data\npids\n*.pid\n*.seed\n\n# Directory for instrumented libs generated by jscoverage/JSCover\nlib-cov\n\n# Coverage directory used by tools like istanbul\ncoverage\n\n# nyc test coverage\n.nyc_output\n\n# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)\n.grunt\n\n# node-waf configuration\n.lock-wscript\n\n# Compiled binary addons (http://nodejs.org/api/addons.html)\nbuild/Release\n\n# Dependency directories\nnode_modules\njspm_packages\ntypings\n\n# Optional npm cache directory\n.npm\n\n# Optional REPL history\n.node_repl_history\n\n# .NET compiled files\nbin\nobj"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n    // Use IntelliSense to learn about possible attributes.\n    // Hover to view descriptions of existing attributes.\n    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387\n    \"version\": \"0.2.0\",\n    \"configurations\": [\n        {\n            \"name\": \".NET Core Launch (web)\",\n            \"type\": \"coreclr\",\n            \"request\": \"launch\",\n            \"preLaunchTask\": \"build\",\n            // If you have changed target frameworks, make sure to update the program path.\n            \"program\": \"${workspaceFolder}/bin/Debug/netcoreapp3.1/WebApi.dll\",\n            \"args\": [],\n            \"cwd\": \"${workspaceFolder}\",\n            \"stopAtEntry\": false,\n            \"internalConsoleOptions\": \"openOnSessionStart\",\n            \"env\": {\n                \"ASPNETCORE_ENVIRONMENT\": \"Development\"\n            },\n            \"sourceFileMap\": {\n                \"/Views\": \"${workspaceFolder}/Views\"\n            }\n        },\n        {\n            \"name\": \".NET Core Attach\",\n            \"type\": \"coreclr\",\n            \"request\": \"attach\",\n            \"processId\": \"${command:pickProcess}\"\n        }\n    ]\n}"
  },
  {
    "path": ".vscode/tasks.json",
    "content": "{\n    \"version\": \"2.0.0\",\n    \"tasks\": [\n        {\n            \"label\": \"build\",\n            \"command\": \"dotnet\",\n            \"type\": \"process\",\n            \"args\": [\n                \"build\",\n                \"${workspaceFolder}/WebApi.csproj\",\n                \"/property:GenerateFullPaths=true\",\n                \"/consoleloggerparameters:NoSummary\"\n            ],\n            \"problemMatcher\": \"$msCompile\"\n        }\n    ]\n}"
  },
  {
    "path": "Controllers/UsersController.cs",
    "content": "﻿using Microsoft.AspNetCore.Mvc;\nusing Microsoft.AspNetCore.Authorization;\nusing WebApi.Services;\nusing WebApi.Models;\nusing Microsoft.AspNetCore.Http;\nusing System;\n\nnamespace WebApi.Controllers\n{\n    [Authorize]\n    [ApiController]\n    [Route(\"[controller]\")]\n    public class UsersController : ControllerBase\n    {\n        private IUserService _userService;\n\n        public UsersController(IUserService userService)\n        {\n            _userService = userService;\n        }\n\n        [AllowAnonymous]\n        [HttpPost(\"authenticate\")]\n        public IActionResult Authenticate([FromBody] AuthenticateRequest model)\n        {\n            var response = _userService.Authenticate(model, ipAddress());\n\n            if (response == null)\n                return BadRequest(new { message = \"Username or password is incorrect\" });\n\n            setTokenCookie(response.RefreshToken);\n\n            return Ok(response);\n        }\n\n        [AllowAnonymous]\n        [HttpPost(\"refresh-token\")]\n        public IActionResult RefreshToken()\n        {\n            var refreshToken = Request.Cookies[\"refreshToken\"];\n            var response = _userService.RefreshToken(refreshToken, ipAddress());\n\n            if (response == null)\n                return Unauthorized(new { message = \"Invalid token\" });\n\n            setTokenCookie(response.RefreshToken);\n\n            return Ok(response);\n        }\n\n        [HttpPost(\"revoke-token\")]\n        public IActionResult RevokeToken([FromBody] RevokeTokenRequest model)\n        {\n            // accept token from request body or cookie\n            var token = model.Token ?? Request.Cookies[\"refreshToken\"];\n\n            if (string.IsNullOrEmpty(token))\n                return BadRequest(new { message = \"Token is required\" });\n\n            var response = _userService.RevokeToken(token, ipAddress());\n\n            if (!response)\n                return NotFound(new { message = \"Token not found\" });\n\n            return Ok(new { message = \"Token revoked\" });\n        }\n\n        [HttpGet]\n        public IActionResult GetAll()\n        {\n            var users = _userService.GetAll();\n            return Ok(users);\n        }\n\n        [HttpGet(\"{id}\")]\n        public IActionResult GetById(int id)\n        {\n            var user = _userService.GetById(id);\n            if (user == null) return NotFound();\n\n            return Ok(user);\n        }\n\n        [HttpGet(\"{id}/refresh-tokens\")]\n        public IActionResult GetRefreshTokens(int id)\n        {\n            var user = _userService.GetById(id);\n            if (user == null) return NotFound();\n\n            return Ok(user.RefreshTokens);\n        }\n\n        // helper methods\n\n        private void setTokenCookie(string token)\n        {\n            var cookieOptions = new CookieOptions\n            {\n                HttpOnly = true,\n                Expires = DateTime.UtcNow.AddDays(7)\n            };\n            Response.Cookies.Append(\"refreshToken\", token, cookieOptions);\n        }\n\n        private string ipAddress()\n        {\n            if (Request.Headers.ContainsKey(\"X-Forwarded-For\"))\n                return Request.Headers[\"X-Forwarded-For\"];\n            else\n                return HttpContext.Connection.RemoteIpAddress.MapToIPv4().ToString();\n        }\n    }\n}\n"
  },
  {
    "path": "Entities/RefreshToken.cs",
    "content": "using System;\nusing System.ComponentModel.DataAnnotations;\nusing System.Text.Json.Serialization;\nusing Microsoft.EntityFrameworkCore;\n\nnamespace WebApi.Entities\n{\n    [Owned]\n    public class RefreshToken\n    {\n        [Key]\n        [JsonIgnore]\n        public int Id { get; set; }\n        \n        public string Token { get; set; }\n        public DateTime Expires { get; set; }\n        public bool IsExpired => DateTime.UtcNow >= Expires;\n        public DateTime Created { get; set; }\n        public string CreatedByIp { get; set; }\n        public DateTime? Revoked { get; set; }\n        public string RevokedByIp { get; set; }\n        public string ReplacedByToken { get; set; }\n        public bool IsActive => Revoked == null && !IsExpired;\n    }\n}"
  },
  {
    "path": "Entities/User.cs",
    "content": "using System.Text.Json.Serialization;\nusing System.Collections.Generic;\n\nnamespace WebApi.Entities\n{\n    public class User\n    {\n        public int Id { get; set; }\n        public string FirstName { get; set; }\n        public string LastName { get; set; }\n        public string Username { get; set; }\n\n        [JsonIgnore]\n        public string Password { get; set; }\n\n        [JsonIgnore]\n        public List<RefreshToken> RefreshTokens { get; set; }\n    }\n}"
  },
  {
    "path": "Helpers/AppSettings.cs",
    "content": "namespace WebApi.Helpers\n{\n    public class AppSettings\n    {\n        public string Secret { get; set; }\n    }\n}"
  },
  {
    "path": "Helpers/DataContext.cs",
    "content": "using Microsoft.EntityFrameworkCore;\nusing WebApi.Entities;\n\nnamespace WebApi.Helpers\n{\n    public class DataContext : DbContext\n    {\n        public DbSet<User> Users { get; set; }\n\n        public DataContext(DbContextOptions<DataContext> options) : base(options) { }\n    }\n}"
  },
  {
    "path": "LICENSE",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2020 Jason Watmore\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "Models/AuthenticateRequest.cs",
    "content": "using System.ComponentModel.DataAnnotations;\n\nnamespace WebApi.Models\n{\n    public class AuthenticateRequest\n    {\n        [Required]\n        public string Username { get; set; }\n\n        [Required]\n        public string Password { get; set; }\n    }\n}"
  },
  {
    "path": "Models/AuthenticateResponse.cs",
    "content": "using System.Text.Json.Serialization;\nusing WebApi.Entities;\n\nnamespace WebApi.Models\n{\n    public class AuthenticateResponse\n    {\n        public int Id { get; set; }\n        public string FirstName { get; set; }\n        public string LastName { get; set; }\n        public string Username { get; set; }\n        public string JwtToken { get; set; }\n\n        [JsonIgnore] // refresh token is returned in http only cookie\n        public string RefreshToken { get; set; }\n\n        public AuthenticateResponse(User user, string jwtToken, string refreshToken)\n        {\n            Id = user.Id;\n            FirstName = user.FirstName;\n            LastName = user.LastName;\n            Username = user.Username;\n            JwtToken = jwtToken;\n            RefreshToken = refreshToken;\n        }\n    }\n}"
  },
  {
    "path": "Models/RevokeTokenRequest.cs",
    "content": "namespace WebApi.Models\n{\n    public class RevokeTokenRequest\n    {\n        public string Token { get; set; }\n    }\n}"
  },
  {
    "path": "Program.cs",
    "content": "﻿using Microsoft.AspNetCore.Hosting;\nusing Microsoft.Extensions.Hosting;\n\nnamespace WebApi\n{\n    public class Program\n    {\n        public static void Main(string[] args)\n        {\n            CreateHostBuilder(args).Build().Run();\n        }\n\n        public static IHostBuilder CreateHostBuilder(string[] args) =>\n            Host.CreateDefaultBuilder(args)\n                .ConfigureWebHostDefaults(webBuilder =>\n                {\n                    webBuilder.UseStartup<Startup>()\n                        .UseUrls(\"http://localhost:4000\");\n                });\n    }\n}\n"
  },
  {
    "path": "README.md",
    "content": "# aspnet-core-3-jwt-refresh-tokens-api\n\nASP.NET Core 3.1 API - JWT Authentication with Refresh Tokens\n\nDocumentation and instructions available at https://jasonwatmore.com/post/2020/05/25/aspnet-core-3-api-jwt-authentication-with-refresh-tokens\n"
  },
  {
    "path": "Services/UserService.cs",
    "content": "using System;\nusing System.Collections.Generic;\nusing System.IdentityModel.Tokens.Jwt;\nusing System.Linq;\nusing System.Security.Claims;\nusing System.Security.Cryptography;\nusing System.Text;\nusing Microsoft.Extensions.Options;\nusing Microsoft.IdentityModel.Tokens;\nusing WebApi.Models;\nusing WebApi.Entities;\nusing WebApi.Helpers;\n\nnamespace WebApi.Services\n{\n    public interface IUserService\n    {\n        AuthenticateResponse Authenticate(AuthenticateRequest model, string ipAddress);\n        AuthenticateResponse RefreshToken(string token, string ipAddress);\n        bool RevokeToken(string token, string ipAddress);\n        IEnumerable<User> GetAll();\n        User GetById(int id);\n    }\n\n    public class UserService : IUserService\n    {\n        private DataContext _context;\n        private readonly AppSettings _appSettings;\n\n        public UserService(\n            DataContext context,\n            IOptions<AppSettings> appSettings)\n        {\n            _context = context;\n            _appSettings = appSettings.Value;\n        }\n\n        public AuthenticateResponse Authenticate(AuthenticateRequest model, string ipAddress)\n        {\n            var user =  _context.Users.SingleOrDefault(x => x.Username == model.Username && x.Password == model.Password);\n\n            // return null if user not found\n            if (user == null) return null;\n\n            // authentication successful so generate jwt and refresh tokens\n            var jwtToken = generateJwtToken(user);\n            var refreshToken = generateRefreshToken(ipAddress);\n\n            // save refresh token\n            user.RefreshTokens.Add(refreshToken);\n            _context.Update(user);\n            _context.SaveChanges();\n\n            return new AuthenticateResponse(user, jwtToken, refreshToken.Token);\n        }\n\n        public AuthenticateResponse RefreshToken(string token, string ipAddress)\n        {\n            var user = _context.Users.SingleOrDefault(u => u.RefreshTokens.Any(t => t.Token == token));\n            \n            // return null if no user found with token\n            if (user == null) return null;\n\n            var refreshToken = user.RefreshTokens.Single(x => x.Token == token);\n\n            // return null if token is no longer active\n            if (!refreshToken.IsActive) return null;\n\n            // replace old refresh token with a new one and save\n            var newRefreshToken = generateRefreshToken(ipAddress);\n            refreshToken.Revoked = DateTime.UtcNow;\n            refreshToken.RevokedByIp = ipAddress;\n            refreshToken.ReplacedByToken = newRefreshToken.Token;\n            user.RefreshTokens.Add(newRefreshToken);\n            _context.Update(user);\n            _context.SaveChanges();\n\n            // generate new jwt\n            var jwtToken = generateJwtToken(user);\n\n            return new AuthenticateResponse(user, jwtToken, newRefreshToken.Token);\n        }\n\n        public bool RevokeToken(string token, string ipAddress)\n        {\n            var user = _context.Users.SingleOrDefault(u => u.RefreshTokens.Any(t => t.Token == token));\n            \n            // return false if no user found with token\n            if (user == null) return false;\n\n            var refreshToken = user.RefreshTokens.Single(x => x.Token == token);\n\n            // return false if token is not active\n            if (!refreshToken.IsActive) return false;\n\n            // revoke token and save\n            refreshToken.Revoked = DateTime.UtcNow;\n            refreshToken.RevokedByIp = ipAddress;\n            _context.Update(user);\n            _context.SaveChanges();\n\n            return true;\n        }\n\n        public IEnumerable<User> GetAll()\n        {\n            return _context.Users;\n        }\n\n        public User GetById(int id)\n        {\n            return _context.Users.Find(id);\n        }\n\n        // helper methods\n\n        private string generateJwtToken(User user)\n        {\n            var tokenHandler = new JwtSecurityTokenHandler();\n            var key = Encoding.ASCII.GetBytes(_appSettings.Secret);\n            var tokenDescriptor = new SecurityTokenDescriptor\n            {\n                Subject = new ClaimsIdentity(new Claim[] \n                {\n                    new Claim(ClaimTypes.Name, user.Id.ToString())\n                }),\n                Expires = DateTime.UtcNow.AddMinutes(15),\n                SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)\n            };\n            var token = tokenHandler.CreateToken(tokenDescriptor);\n            return tokenHandler.WriteToken(token);\n        }\n\n        private RefreshToken generateRefreshToken(string ipAddress)\n        {\n            using(var rngCryptoServiceProvider = new RNGCryptoServiceProvider())\n            {\n                var randomBytes = new byte[64];\n                rngCryptoServiceProvider.GetBytes(randomBytes);\n                return new RefreshToken\n                {\n                    Token = Convert.ToBase64String(randomBytes),\n                    Expires = DateTime.UtcNow.AddDays(7),\n                    Created = DateTime.UtcNow,\n                    CreatedByIp = ipAddress\n                };\n            }\n        }\n    }\n}"
  },
  {
    "path": "Startup.cs",
    "content": "﻿using Microsoft.AspNetCore.Builder;\nusing Microsoft.AspNetCore.Hosting;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing WebApi.Helpers;\nusing WebApi.Services;\nusing Microsoft.IdentityModel.Tokens;\nusing System.Text;\nusing Microsoft.AspNetCore.Authentication.JwtBearer;\nusing Microsoft.EntityFrameworkCore;\nusing WebApi.Entities;\nusing System;\n\nnamespace WebApi\n{\n    public class Startup\n    {\n        public Startup(IConfiguration configuration)\n        {\n            Configuration = configuration;\n        }\n\n        public IConfiguration Configuration { get; }\n\n        // This method gets called by the runtime. Use this method to add services to the container.\n        public void ConfigureServices(IServiceCollection services)\n        {\n            // in memory database used for simplicity, change to a real db for production applications\n            services.AddDbContext<DataContext>(x => x.UseInMemoryDatabase(\"TestDb\"));\n\n            services.AddCors();\n            services.AddControllers().AddJsonOptions(x => x.JsonSerializerOptions.IgnoreNullValues = true);\n\n            // configure strongly typed settings objects\n            var appSettingsSection = Configuration.GetSection(\"AppSettings\");\n            services.Configure<AppSettings>(appSettingsSection);\n\n            // configure jwt authentication\n            var appSettings = appSettingsSection.Get<AppSettings>();\n            var key = Encoding.ASCII.GetBytes(appSettings.Secret);\n            services.AddAuthentication(x =>\n            {\n                x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;\n                x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;\n            })\n            .AddJwtBearer(x =>\n            {\n                x.RequireHttpsMetadata = false;\n                x.SaveToken = true;\n                x.TokenValidationParameters = new TokenValidationParameters\n                {\n                    ValidateIssuerSigningKey = true,\n                    IssuerSigningKey = new SymmetricSecurityKey(key),\n                    ValidateIssuer = false,\n                    ValidateAudience = false,\n                    // set clockskew to zero so tokens expire exactly at token expiration time (instead of 5 minutes later)\n                    ClockSkew = TimeSpan.Zero\n                };\n            });\n\n            // configure DI for application services\n            services.AddScoped<IUserService, UserService>();\n        }\n\n        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.\n        public void Configure(IApplicationBuilder app, IWebHostEnvironment env, DataContext context)\n        {\n            // add hardcoded test user to db on startup\n            // plain text password is used for simplicity, hashed passwords should be used in production applications\n            context.Users.Add(new User { FirstName = \"Test\", LastName = \"User\", Username = \"test\", Password = \"test\" });\n            context.SaveChanges();\n\n            app.UseRouting();\n\n            // global cors policy\n            app.UseCors(x => x\n                .SetIsOriginAllowed(origin => true)\n                .AllowAnyMethod()\n                .AllowAnyHeader()\n                .AllowCredentials());\n\n            app.UseAuthentication();\n            app.UseAuthorization();\n\n            app.UseEndpoints(x => x.MapControllers());\n        }\n    }\n}\n"
  },
  {
    "path": "WebApi.csproj",
    "content": "﻿<Project Sdk=\"Microsoft.NET.Sdk.Web\">\n  <PropertyGroup>\n    <TargetFramework>netcoreapp3.1</TargetFramework>\n  </PropertyGroup>\n  <ItemGroup>\n    <PackageReference Include=\"Microsoft.AspNetCore.Authentication.JwtBearer\" Version=\"3.1.4\" />\n    <PackageReference Include=\"Microsoft.EntityFrameworkCore.InMemory\" Version=\"3.1.4\" />\n    <PackageReference Include=\"System.IdentityModel.Tokens.Jwt\" Version=\"6.5.1\" />\n  </ItemGroup>\n</Project>"
  },
  {
    "path": "appsettings.Development.json",
    "content": "﻿{\n    \"Logging\": {\n        \"LogLevel\": {\n            \"Default\": \"Debug\",\n            \"System\": \"Information\",\n            \"Microsoft\": \"Information\"\n        }\n    }\n}"
  },
  {
    "path": "appsettings.json",
    "content": "﻿{\n    \"AppSettings\": {\n        \"Secret\": \"THIS IS USED TO SIGN AND VERIFY JWT TOKENS, REPLACE IT WITH YOUR OWN SECRET, IT CAN BE ANY STRING\"\n    },\n    \"Logging\": {\n        \"LogLevel\": {\n            \"Default\": \"Information\",\n            \"Microsoft\": \"Warning\",\n            \"Microsoft.Hosting.Lifetime\": \"Information\"\n        }\n    },\n    \"AllowedHosts\": \"*\"\n}"
  }
]