├── .gitignore ├── Authorization ├── AllowAnonymousAttribute.cs ├── AuthorizeAttribute.cs ├── JwtMiddleware.cs └── JwtUtils.cs ├── Controllers ├── AccountsController.cs └── BaseController.cs ├── Entities ├── Account.cs ├── RefreshToken.cs └── Role.cs ├── Helpers ├── AppException.cs ├── AppSettings.cs ├── AutoMapperProfile.cs ├── DataContext.cs └── ErrorHandlerMiddleware.cs ├── LICENSE ├── Migrations ├── 20220224040327_InitialCreate.Designer.cs ├── 20220224040327_InitialCreate.cs └── DataContextModelSnapshot.cs ├── Models └── Accounts │ ├── AccountResponse.cs │ ├── AuthenticateRequest.cs │ ├── AuthenticateResponse.cs │ ├── CreateRequest.cs │ ├── ForgotPasswordRequest.cs │ ├── RegisterRequest.cs │ ├── ResetPasswordRequest.cs │ ├── RevokeTokenRequest.cs │ ├── UpdateRequest.cs │ ├── ValidateResetTokenRequest.cs │ └── VerifyEmailRequest.cs ├── Program.cs ├── README.md ├── Services ├── AccountService.cs └── EmailService.cs ├── WebApi.csproj └── appsettings.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 43 | 44 | # Generated SQLite db 45 | WebApiDatabase.db -------------------------------------------------------------------------------- /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 | private readonly IList _roles; 11 | 12 | public AuthorizeAttribute(params Role[] roles) 13 | { 14 | _roles = roles ?? new Role[] { }; 15 | } 16 | 17 | public void OnAuthorization(AuthorizationFilterContext context) 18 | { 19 | // skip authorization if action is decorated with [AllowAnonymous] attribute 20 | var allowAnonymous = context.ActionDescriptor.EndpointMetadata.OfType().Any(); 21 | if (allowAnonymous) 22 | return; 23 | 24 | // authorization 25 | var account = (Account)context.HttpContext.Items["Account"]; 26 | if (account == null || (_roles.Any() && !_roles.Contains(account.Role))) 27 | { 28 | // not logged in or role not authorized 29 | context.Result = new JsonResult(new { message = "Unauthorized" }) { StatusCode = StatusCodes.Status401Unauthorized }; 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /Authorization/JwtMiddleware.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Authorization; 2 | 3 | using Microsoft.Extensions.Options; 4 | using WebApi.Helpers; 5 | 6 | public class JwtMiddleware 7 | { 8 | private readonly RequestDelegate _next; 9 | private readonly AppSettings _appSettings; 10 | 11 | public JwtMiddleware(RequestDelegate next, IOptions appSettings) 12 | { 13 | _next = next; 14 | _appSettings = appSettings.Value; 15 | } 16 | 17 | public async Task Invoke(HttpContext context, DataContext dataContext, IJwtUtils jwtUtils) 18 | { 19 | var token = context.Request.Headers["Authorization"].FirstOrDefault()?.Split(" ").Last(); 20 | var accountId = jwtUtils.ValidateJwtToken(token); 21 | if (accountId != null) 22 | { 23 | // attach account to context on successful jwt validation 24 | context.Items["Account"] = await dataContext.Accounts.FindAsync(accountId.Value); 25 | } 26 | 27 | await _next(context); 28 | } 29 | } -------------------------------------------------------------------------------- /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(Account account); 15 | public int? ValidateJwtToken(string token); 16 | public RefreshToken GenerateRefreshToken(string ipAddress); 17 | } 18 | 19 | public class JwtUtils : IJwtUtils 20 | { 21 | private readonly 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(Account account) 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", account.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 accountId = int.Parse(jwtToken.Claims.First(x => x.Type == "id").Value); 68 | 69 | // return account id from JWT token if validation successful 70 | return accountId; 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 is a cryptographically strong random sequence of values 84 | Token = Convert.ToHexString(RandomNumberGenerator.GetBytes(64)), 85 | // token is valid for 7 days 86 | Expires = DateTime.UtcNow.AddDays(7), 87 | Created = DateTime.UtcNow, 88 | CreatedByIp = ipAddress 89 | }; 90 | 91 | // ensure token is unique by checking against db 92 | var tokenIsUnique = !_context.Accounts.Any(a => a.RefreshTokens.Any(t => t.Token == refreshToken.Token)); 93 | 94 | if (!tokenIsUnique) 95 | return GenerateRefreshToken(ipAddress); 96 | 97 | return refreshToken; 98 | } 99 | } -------------------------------------------------------------------------------- /Controllers/AccountsController.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Controllers; 2 | 3 | using Microsoft.AspNetCore.Mvc; 4 | using WebApi.Authorization; 5 | using WebApi.Entities; 6 | using WebApi.Models.Accounts; 7 | using WebApi.Services; 8 | 9 | [Authorize] 10 | [ApiController] 11 | [Route("[controller]")] 12 | public class AccountsController : BaseController 13 | { 14 | private readonly IAccountService _accountService; 15 | 16 | public AccountsController(IAccountService accountService) 17 | { 18 | _accountService = accountService; 19 | } 20 | 21 | [AllowAnonymous] 22 | [HttpPost("authenticate")] 23 | public ActionResult Authenticate(AuthenticateRequest model) 24 | { 25 | var response = _accountService.Authenticate(model, ipAddress()); 26 | setTokenCookie(response.RefreshToken); 27 | return Ok(response); 28 | } 29 | 30 | [AllowAnonymous] 31 | [HttpPost("refresh-token")] 32 | public ActionResult RefreshToken() 33 | { 34 | var refreshToken = Request.Cookies["refreshToken"]; 35 | var response = _accountService.RefreshToken(refreshToken, ipAddress()); 36 | setTokenCookie(response.RefreshToken); 37 | return Ok(response); 38 | } 39 | 40 | [HttpPost("revoke-token")] 41 | public IActionResult RevokeToken(RevokeTokenRequest model) 42 | { 43 | // accept token from request body or cookie 44 | var token = model.Token ?? Request.Cookies["refreshToken"]; 45 | 46 | if (string.IsNullOrEmpty(token)) 47 | return BadRequest(new { message = "Token is required" }); 48 | 49 | // users can revoke their own tokens and admins can revoke any tokens 50 | if (!Account.OwnsToken(token) && Account.Role != Role.Admin) 51 | return Unauthorized(new { message = "Unauthorized" }); 52 | 53 | _accountService.RevokeToken(token, ipAddress()); 54 | return Ok(new { message = "Token revoked" }); 55 | } 56 | 57 | [AllowAnonymous] 58 | [HttpPost("register")] 59 | public IActionResult Register(RegisterRequest model) 60 | { 61 | _accountService.Register(model, Request.Headers["origin"]); 62 | return Ok(new { message = "Registration successful, please check your email for verification instructions" }); 63 | } 64 | 65 | [AllowAnonymous] 66 | [HttpPost("verify-email")] 67 | public IActionResult VerifyEmail(VerifyEmailRequest model) 68 | { 69 | _accountService.VerifyEmail(model.Token); 70 | return Ok(new { message = "Verification successful, you can now login" }); 71 | } 72 | 73 | [AllowAnonymous] 74 | [HttpPost("forgot-password")] 75 | public IActionResult ForgotPassword(ForgotPasswordRequest model) 76 | { 77 | _accountService.ForgotPassword(model, Request.Headers["origin"]); 78 | return Ok(new { message = "Please check your email for password reset instructions" }); 79 | } 80 | 81 | [AllowAnonymous] 82 | [HttpPost("validate-reset-token")] 83 | public IActionResult ValidateResetToken(ValidateResetTokenRequest model) 84 | { 85 | _accountService.ValidateResetToken(model); 86 | return Ok(new { message = "Token is valid" }); 87 | } 88 | 89 | [AllowAnonymous] 90 | [HttpPost("reset-password")] 91 | public IActionResult ResetPassword(ResetPasswordRequest model) 92 | { 93 | _accountService.ResetPassword(model); 94 | return Ok(new { message = "Password reset successful, you can now login" }); 95 | } 96 | 97 | [Authorize(Role.Admin)] 98 | [HttpGet] 99 | public ActionResult> GetAll() 100 | { 101 | var accounts = _accountService.GetAll(); 102 | return Ok(accounts); 103 | } 104 | 105 | [HttpGet("{id:int}")] 106 | public ActionResult GetById(int id) 107 | { 108 | // users can get their own account and admins can get any account 109 | if (id != Account.Id && Account.Role != Role.Admin) 110 | return Unauthorized(new { message = "Unauthorized" }); 111 | 112 | var account = _accountService.GetById(id); 113 | return Ok(account); 114 | } 115 | 116 | [Authorize(Role.Admin)] 117 | [HttpPost] 118 | public ActionResult Create(CreateRequest model) 119 | { 120 | var account = _accountService.Create(model); 121 | return Ok(account); 122 | } 123 | 124 | [HttpPut("{id:int}")] 125 | public ActionResult Update(int id, UpdateRequest model) 126 | { 127 | // users can update their own account and admins can update any account 128 | if (id != Account.Id && Account.Role != Role.Admin) 129 | return Unauthorized(new { message = "Unauthorized" }); 130 | 131 | // only admins can update role 132 | if (Account.Role != Role.Admin) 133 | model.Role = null; 134 | 135 | var account = _accountService.Update(id, model); 136 | return Ok(account); 137 | } 138 | 139 | [HttpDelete("{id:int}")] 140 | public IActionResult Delete(int id) 141 | { 142 | // users can delete their own account and admins can delete any account 143 | if (id != Account.Id && Account.Role != Role.Admin) 144 | return Unauthorized(new { message = "Unauthorized" }); 145 | 146 | _accountService.Delete(id); 147 | return Ok(new { message = "Account deleted successfully" }); 148 | } 149 | 150 | // helper methods 151 | 152 | private void setTokenCookie(string token) 153 | { 154 | var cookieOptions = new CookieOptions 155 | { 156 | HttpOnly = true, 157 | Expires = DateTime.UtcNow.AddDays(7) 158 | }; 159 | Response.Cookies.Append("refreshToken", token, cookieOptions); 160 | } 161 | 162 | private string ipAddress() 163 | { 164 | if (Request.Headers.ContainsKey("X-Forwarded-For")) 165 | return Request.Headers["X-Forwarded-For"]; 166 | else 167 | return HttpContext.Connection.RemoteIpAddress.MapToIPv4().ToString(); 168 | } 169 | } -------------------------------------------------------------------------------- /Controllers/BaseController.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Controllers; 2 | 3 | using Microsoft.AspNetCore.Mvc; 4 | using WebApi.Entities; 5 | 6 | [Controller] 7 | public abstract class BaseController : ControllerBase 8 | { 9 | // returns the current authenticated account (null if not logged in) 10 | public Account Account => (Account)HttpContext.Items["Account"]; 11 | } -------------------------------------------------------------------------------- /Entities/Account.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Entities; 2 | 3 | public class Account 4 | { 5 | public int Id { get; set; } 6 | public string Title { get; set; } 7 | public string FirstName { get; set; } 8 | public string LastName { get; set; } 9 | public string Email { get; set; } 10 | public string PasswordHash { get; set; } 11 | public bool AcceptTerms { get; set; } 12 | public Role Role { get; set; } 13 | public string VerificationToken { get; set; } 14 | public DateTime? Verified { get; set; } 15 | public bool IsVerified => Verified.HasValue || PasswordReset.HasValue; 16 | public string ResetToken { get; set; } 17 | public DateTime? ResetTokenExpires { get; set; } 18 | public DateTime? PasswordReset { get; set; } 19 | public DateTime Created { get; set; } 20 | public DateTime? Updated { get; set; } 21 | public List RefreshTokens { get; set; } 22 | 23 | public bool OwnsToken(string token) 24 | { 25 | return this.RefreshTokens?.Find(x => x.Token == token) != null; 26 | } 27 | } -------------------------------------------------------------------------------- /Entities/RefreshToken.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Entities; 2 | 3 | using Microsoft.EntityFrameworkCore; 4 | using System.ComponentModel.DataAnnotations; 5 | 6 | [Owned] 7 | public class RefreshToken 8 | { 9 | [Key] 10 | public int Id { get; set; } 11 | public Account Account { get; set; } 12 | public string Token { get; set; } 13 | public DateTime Expires { get; set; } 14 | public DateTime Created { get; set; } 15 | public string CreatedByIp { get; set; } 16 | public DateTime? Revoked { get; set; } 17 | public string RevokedByIp { get; set; } 18 | public string ReplacedByToken { get; set; } 19 | public string ReasonRevoked { get; set; } 20 | public bool IsExpired => DateTime.UtcNow >= Expires; 21 | public bool IsRevoked => Revoked != null; 22 | public bool IsActive => Revoked == null && !IsExpired; 23 | } -------------------------------------------------------------------------------- /Entities/Role.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Entities; 2 | 3 | public enum Role 4 | { 5 | Admin, 6 | User 7 | } -------------------------------------------------------------------------------- /Helpers/AppException.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Helpers; 2 | 3 | using System.Globalization; 4 | 5 | // custom exception class for throwing application specific exceptions 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 | 11 | public string EmailFrom { get; set; } 12 | public string SmtpHost { get; set; } 13 | public int SmtpPort { get; set; } 14 | public string SmtpUser { get; set; } 15 | public string SmtpPass { get; set; } 16 | } -------------------------------------------------------------------------------- /Helpers/AutoMapperProfile.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Helpers; 2 | 3 | using AutoMapper; 4 | using WebApi.Entities; 5 | using WebApi.Models.Accounts; 6 | 7 | public class AutoMapperProfile : Profile 8 | { 9 | // mappings between model and entity objects 10 | public AutoMapperProfile() 11 | { 12 | CreateMap(); 13 | 14 | CreateMap(); 15 | 16 | CreateMap(); 17 | 18 | CreateMap(); 19 | 20 | CreateMap() 21 | .ForAllMembers(x => x.Condition( 22 | (src, dest, prop) => 23 | { 24 | // ignore null & empty string properties 25 | if (prop == null) return false; 26 | if (prop.GetType() == typeof(string) && string.IsNullOrEmpty((string)prop)) return false; 27 | 28 | // ignore null role 29 | if (x.DestinationMember.Name == "Role" && src.Role == null) return false; 30 | 31 | return true; 32 | } 33 | )); 34 | } 35 | } -------------------------------------------------------------------------------- /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 Accounts { 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 | // connect to sqlite database 20 | options.UseSqlite(Configuration.GetConnectionString("WebApiDatabase")); 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 | private readonly ILogger _logger; 10 | 11 | public ErrorHandlerMiddleware(RequestDelegate next, ILogger logger) 12 | { 13 | _next = next; 14 | _logger = logger; 15 | } 16 | 17 | public async Task Invoke(HttpContext context) 18 | { 19 | try 20 | { 21 | await _next(context); 22 | } 23 | catch (Exception error) 24 | { 25 | var response = context.Response; 26 | response.ContentType = "application/json"; 27 | 28 | switch (error) 29 | { 30 | case AppException e: 31 | // custom application error 32 | response.StatusCode = (int)HttpStatusCode.BadRequest; 33 | break; 34 | case KeyNotFoundException e: 35 | // not found error 36 | response.StatusCode = (int)HttpStatusCode.NotFound; 37 | break; 38 | default: 39 | // unhandled error 40 | _logger.LogError(error, error.Message); 41 | response.StatusCode = (int)HttpStatusCode.InternalServerError; 42 | break; 43 | } 44 | 45 | var result = JsonSerializer.Serialize(new { message = error?.Message }); 46 | await response.WriteAsync(result); 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Migrations/20220224040327_InitialCreate.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using WebApi.Helpers; 8 | 9 | #nullable disable 10 | 11 | namespace WebApi.Migrations 12 | { 13 | [DbContext(typeof(DataContext))] 14 | [Migration("20220224040327_InitialCreate")] 15 | partial class InitialCreate 16 | { 17 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 18 | { 19 | #pragma warning disable 612, 618 20 | modelBuilder.HasAnnotation("ProductVersion", "6.0.2"); 21 | 22 | modelBuilder.Entity("WebApi.Entities.Account", b => 23 | { 24 | b.Property("Id") 25 | .ValueGeneratedOnAdd() 26 | .HasColumnType("INTEGER"); 27 | 28 | b.Property("AcceptTerms") 29 | .HasColumnType("INTEGER"); 30 | 31 | b.Property("Created") 32 | .HasColumnType("TEXT"); 33 | 34 | b.Property("Email") 35 | .HasColumnType("TEXT"); 36 | 37 | b.Property("FirstName") 38 | .HasColumnType("TEXT"); 39 | 40 | b.Property("LastName") 41 | .HasColumnType("TEXT"); 42 | 43 | b.Property("PasswordHash") 44 | .HasColumnType("TEXT"); 45 | 46 | b.Property("PasswordReset") 47 | .HasColumnType("TEXT"); 48 | 49 | b.Property("ResetToken") 50 | .HasColumnType("TEXT"); 51 | 52 | b.Property("ResetTokenExpires") 53 | .HasColumnType("TEXT"); 54 | 55 | b.Property("Role") 56 | .HasColumnType("INTEGER"); 57 | 58 | b.Property("Title") 59 | .HasColumnType("TEXT"); 60 | 61 | b.Property("Updated") 62 | .HasColumnType("TEXT"); 63 | 64 | b.Property("VerificationToken") 65 | .HasColumnType("TEXT"); 66 | 67 | b.Property("Verified") 68 | .HasColumnType("TEXT"); 69 | 70 | b.HasKey("Id"); 71 | 72 | b.ToTable("Accounts"); 73 | }); 74 | 75 | modelBuilder.Entity("WebApi.Entities.Account", b => 76 | { 77 | b.OwnsMany("WebApi.Entities.RefreshToken", "RefreshTokens", b1 => 78 | { 79 | b1.Property("Id") 80 | .ValueGeneratedOnAdd() 81 | .HasColumnType("INTEGER"); 82 | 83 | b1.Property("AccountId") 84 | .HasColumnType("INTEGER"); 85 | 86 | b1.Property("Created") 87 | .HasColumnType("TEXT"); 88 | 89 | b1.Property("CreatedByIp") 90 | .HasColumnType("TEXT"); 91 | 92 | b1.Property("Expires") 93 | .HasColumnType("TEXT"); 94 | 95 | b1.Property("ReasonRevoked") 96 | .HasColumnType("TEXT"); 97 | 98 | b1.Property("ReplacedByToken") 99 | .HasColumnType("TEXT"); 100 | 101 | b1.Property("Revoked") 102 | .HasColumnType("TEXT"); 103 | 104 | b1.Property("RevokedByIp") 105 | .HasColumnType("TEXT"); 106 | 107 | b1.Property("Token") 108 | .HasColumnType("TEXT"); 109 | 110 | b1.HasKey("Id"); 111 | 112 | b1.HasIndex("AccountId"); 113 | 114 | b1.ToTable("RefreshToken"); 115 | 116 | b1.WithOwner("Account") 117 | .HasForeignKey("AccountId"); 118 | 119 | b1.Navigation("Account"); 120 | }); 121 | 122 | b.Navigation("RefreshTokens"); 123 | }); 124 | #pragma warning restore 612, 618 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Migrations/20220224040327_InitialCreate.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | namespace WebApi.Migrations 7 | { 8 | public partial class InitialCreate : Migration 9 | { 10 | protected override void Up(MigrationBuilder migrationBuilder) 11 | { 12 | migrationBuilder.CreateTable( 13 | name: "Accounts", 14 | columns: table => new 15 | { 16 | Id = table.Column(type: "INTEGER", nullable: false) 17 | .Annotation("Sqlite:Autoincrement", true), 18 | Title = table.Column(type: "TEXT", nullable: true), 19 | FirstName = table.Column(type: "TEXT", nullable: true), 20 | LastName = table.Column(type: "TEXT", nullable: true), 21 | Email = table.Column(type: "TEXT", nullable: true), 22 | PasswordHash = table.Column(type: "TEXT", nullable: true), 23 | AcceptTerms = table.Column(type: "INTEGER", nullable: false), 24 | Role = table.Column(type: "INTEGER", nullable: false), 25 | VerificationToken = table.Column(type: "TEXT", nullable: true), 26 | Verified = table.Column(type: "TEXT", nullable: true), 27 | ResetToken = table.Column(type: "TEXT", nullable: true), 28 | ResetTokenExpires = table.Column(type: "TEXT", nullable: true), 29 | PasswordReset = table.Column(type: "TEXT", nullable: true), 30 | Created = table.Column(type: "TEXT", nullable: false), 31 | Updated = table.Column(type: "TEXT", nullable: true) 32 | }, 33 | constraints: table => 34 | { 35 | table.PrimaryKey("PK_Accounts", x => x.Id); 36 | }); 37 | 38 | migrationBuilder.CreateTable( 39 | name: "RefreshToken", 40 | columns: table => new 41 | { 42 | Id = table.Column(type: "INTEGER", nullable: false) 43 | .Annotation("Sqlite:Autoincrement", true), 44 | AccountId = table.Column(type: "INTEGER", nullable: false), 45 | Token = table.Column(type: "TEXT", nullable: true), 46 | Expires = table.Column(type: "TEXT", nullable: false), 47 | Created = table.Column(type: "TEXT", nullable: false), 48 | CreatedByIp = table.Column(type: "TEXT", nullable: true), 49 | Revoked = table.Column(type: "TEXT", nullable: true), 50 | RevokedByIp = table.Column(type: "TEXT", nullable: true), 51 | ReplacedByToken = table.Column(type: "TEXT", nullable: true), 52 | ReasonRevoked = table.Column(type: "TEXT", nullable: true) 53 | }, 54 | constraints: table => 55 | { 56 | table.PrimaryKey("PK_RefreshToken", x => x.Id); 57 | table.ForeignKey( 58 | name: "FK_RefreshToken_Accounts_AccountId", 59 | column: x => x.AccountId, 60 | principalTable: "Accounts", 61 | principalColumn: "Id", 62 | onDelete: ReferentialAction.Cascade); 63 | }); 64 | 65 | migrationBuilder.CreateIndex( 66 | name: "IX_RefreshToken_AccountId", 67 | table: "RefreshToken", 68 | column: "AccountId"); 69 | } 70 | 71 | protected override void Down(MigrationBuilder migrationBuilder) 72 | { 73 | migrationBuilder.DropTable( 74 | name: "RefreshToken"); 75 | 76 | migrationBuilder.DropTable( 77 | name: "Accounts"); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Migrations/DataContextModelSnapshot.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 6 | using WebApi.Helpers; 7 | 8 | #nullable disable 9 | 10 | namespace WebApi.Migrations 11 | { 12 | [DbContext(typeof(DataContext))] 13 | partial class DataContextModelSnapshot : ModelSnapshot 14 | { 15 | protected override void BuildModel(ModelBuilder modelBuilder) 16 | { 17 | #pragma warning disable 612, 618 18 | modelBuilder.HasAnnotation("ProductVersion", "6.0.2"); 19 | 20 | modelBuilder.Entity("WebApi.Entities.Account", b => 21 | { 22 | b.Property("Id") 23 | .ValueGeneratedOnAdd() 24 | .HasColumnType("INTEGER"); 25 | 26 | b.Property("AcceptTerms") 27 | .HasColumnType("INTEGER"); 28 | 29 | b.Property("Created") 30 | .HasColumnType("TEXT"); 31 | 32 | b.Property("Email") 33 | .HasColumnType("TEXT"); 34 | 35 | b.Property("FirstName") 36 | .HasColumnType("TEXT"); 37 | 38 | b.Property("LastName") 39 | .HasColumnType("TEXT"); 40 | 41 | b.Property("PasswordHash") 42 | .HasColumnType("TEXT"); 43 | 44 | b.Property("PasswordReset") 45 | .HasColumnType("TEXT"); 46 | 47 | b.Property("ResetToken") 48 | .HasColumnType("TEXT"); 49 | 50 | b.Property("ResetTokenExpires") 51 | .HasColumnType("TEXT"); 52 | 53 | b.Property("Role") 54 | .HasColumnType("INTEGER"); 55 | 56 | b.Property("Title") 57 | .HasColumnType("TEXT"); 58 | 59 | b.Property("Updated") 60 | .HasColumnType("TEXT"); 61 | 62 | b.Property("VerificationToken") 63 | .HasColumnType("TEXT"); 64 | 65 | b.Property("Verified") 66 | .HasColumnType("TEXT"); 67 | 68 | b.HasKey("Id"); 69 | 70 | b.ToTable("Accounts"); 71 | }); 72 | 73 | modelBuilder.Entity("WebApi.Entities.Account", b => 74 | { 75 | b.OwnsMany("WebApi.Entities.RefreshToken", "RefreshTokens", b1 => 76 | { 77 | b1.Property("Id") 78 | .ValueGeneratedOnAdd() 79 | .HasColumnType("INTEGER"); 80 | 81 | b1.Property("AccountId") 82 | .HasColumnType("INTEGER"); 83 | 84 | b1.Property("Created") 85 | .HasColumnType("TEXT"); 86 | 87 | b1.Property("CreatedByIp") 88 | .HasColumnType("TEXT"); 89 | 90 | b1.Property("Expires") 91 | .HasColumnType("TEXT"); 92 | 93 | b1.Property("ReasonRevoked") 94 | .HasColumnType("TEXT"); 95 | 96 | b1.Property("ReplacedByToken") 97 | .HasColumnType("TEXT"); 98 | 99 | b1.Property("Revoked") 100 | .HasColumnType("TEXT"); 101 | 102 | b1.Property("RevokedByIp") 103 | .HasColumnType("TEXT"); 104 | 105 | b1.Property("Token") 106 | .HasColumnType("TEXT"); 107 | 108 | b1.HasKey("Id"); 109 | 110 | b1.HasIndex("AccountId"); 111 | 112 | b1.ToTable("RefreshToken"); 113 | 114 | b1.WithOwner("Account") 115 | .HasForeignKey("AccountId"); 116 | 117 | b1.Navigation("Account"); 118 | }); 119 | 120 | b.Navigation("RefreshTokens"); 121 | }); 122 | #pragma warning restore 612, 618 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Models/Accounts/AccountResponse.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Models.Accounts; 2 | 3 | public class AccountResponse 4 | { 5 | public int Id { get; set; } 6 | public string Title { get; set; } 7 | public string FirstName { get; set; } 8 | public string LastName { get; set; } 9 | public string Email { get; set; } 10 | public string Role { get; set; } 11 | public DateTime Created { get; set; } 12 | public DateTime? Updated { get; set; } 13 | public bool IsVerified { get; set; } 14 | } -------------------------------------------------------------------------------- /Models/Accounts/AuthenticateRequest.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Models.Accounts; 2 | 3 | using System.ComponentModel.DataAnnotations; 4 | 5 | public class AuthenticateRequest 6 | { 7 | [Required] 8 | [EmailAddress] 9 | public string Email { get; set; } 10 | 11 | [Required] 12 | public string Password { get; set; } 13 | } -------------------------------------------------------------------------------- /Models/Accounts/AuthenticateResponse.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Models.Accounts; 2 | 3 | using System.Text.Json.Serialization; 4 | 5 | public class AuthenticateResponse 6 | { 7 | public int Id { get; set; } 8 | public string Title { get; set; } 9 | public string FirstName { get; set; } 10 | public string LastName { get; set; } 11 | public string Email { get; set; } 12 | public string Role { get; set; } 13 | public DateTime Created { get; set; } 14 | public DateTime? Updated { get; set; } 15 | public bool IsVerified { get; set; } 16 | public string JwtToken { get; set; } 17 | 18 | [JsonIgnore] // refresh token is returned in http only cookie 19 | public string RefreshToken { get; set; } 20 | } -------------------------------------------------------------------------------- /Models/Accounts/CreateRequest.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Models.Accounts; 2 | 3 | using System.ComponentModel.DataAnnotations; 4 | using WebApi.Entities; 5 | 6 | public class CreateRequest 7 | { 8 | [Required] 9 | public string Title { get; set; } 10 | 11 | [Required] 12 | public string FirstName { get; set; } 13 | 14 | [Required] 15 | public string LastName { get; set; } 16 | 17 | [Required] 18 | [EnumDataType(typeof(Role))] 19 | public string Role { get; set; } 20 | 21 | [Required] 22 | [EmailAddress] 23 | public string Email { get; set; } 24 | 25 | [Required] 26 | [MinLength(6)] 27 | public string Password { get; set; } 28 | 29 | [Required] 30 | [Compare("Password")] 31 | public string ConfirmPassword { get; set; } 32 | } -------------------------------------------------------------------------------- /Models/Accounts/ForgotPasswordRequest.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Models.Accounts; 2 | 3 | using System.ComponentModel.DataAnnotations; 4 | 5 | public class ForgotPasswordRequest 6 | { 7 | [Required] 8 | [EmailAddress] 9 | public string Email { get; set; } 10 | } -------------------------------------------------------------------------------- /Models/Accounts/RegisterRequest.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Models.Accounts; 2 | 3 | using System.ComponentModel.DataAnnotations; 4 | 5 | public class RegisterRequest 6 | { 7 | [Required] 8 | public string Title { get; set; } 9 | 10 | [Required] 11 | public string FirstName { get; set; } 12 | 13 | [Required] 14 | public string LastName { get; set; } 15 | 16 | [Required] 17 | [EmailAddress] 18 | public string Email { get; set; } 19 | 20 | [Required] 21 | [MinLength(6)] 22 | public string Password { get; set; } 23 | 24 | [Required] 25 | [Compare("Password")] 26 | public string ConfirmPassword { get; set; } 27 | 28 | [Range(typeof(bool), "true", "true")] 29 | public bool AcceptTerms { get; set; } 30 | } -------------------------------------------------------------------------------- /Models/Accounts/ResetPasswordRequest.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Models.Accounts; 2 | 3 | using System.ComponentModel.DataAnnotations; 4 | 5 | public class ResetPasswordRequest 6 | { 7 | [Required] 8 | public string Token { get; set; } 9 | 10 | [Required] 11 | [MinLength(6)] 12 | public string Password { get; set; } 13 | 14 | [Required] 15 | [Compare("Password")] 16 | public string ConfirmPassword { get; set; } 17 | } -------------------------------------------------------------------------------- /Models/Accounts/RevokeTokenRequest.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Models.Accounts; 2 | 3 | public class RevokeTokenRequest 4 | { 5 | public string Token { get; set; } 6 | } -------------------------------------------------------------------------------- /Models/Accounts/UpdateRequest.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Models.Accounts; 2 | 3 | using System.ComponentModel.DataAnnotations; 4 | using WebApi.Entities; 5 | 6 | public class UpdateRequest 7 | { 8 | private string _password; 9 | private string _confirmPassword; 10 | private string _role; 11 | private string _email; 12 | 13 | public string Title { get; set; } 14 | public string FirstName { get; set; } 15 | public string LastName { get; set; } 16 | 17 | [EnumDataType(typeof(Role))] 18 | public string Role 19 | { 20 | get => _role; 21 | set => _role = replaceEmptyWithNull(value); 22 | } 23 | 24 | [EmailAddress] 25 | public string Email 26 | { 27 | get => _email; 28 | set => _email = replaceEmptyWithNull(value); 29 | } 30 | 31 | [MinLength(6)] 32 | public string Password 33 | { 34 | get => _password; 35 | set => _password = replaceEmptyWithNull(value); 36 | } 37 | 38 | [Compare("Password")] 39 | public string ConfirmPassword 40 | { 41 | get => _confirmPassword; 42 | set => _confirmPassword = replaceEmptyWithNull(value); 43 | } 44 | 45 | // helpers 46 | 47 | private string replaceEmptyWithNull(string value) 48 | { 49 | // replace empty string with null to make field optional 50 | return string.IsNullOrEmpty(value) ? null : value; 51 | } 52 | } -------------------------------------------------------------------------------- /Models/Accounts/ValidateResetTokenRequest.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Models.Accounts; 2 | 3 | using System.ComponentModel.DataAnnotations; 4 | 5 | public class ValidateResetTokenRequest 6 | { 7 | [Required] 8 | public string Token { get; set; } 9 | } -------------------------------------------------------------------------------- /Models/Accounts/VerifyEmailRequest.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Models.Accounts; 2 | 3 | using System.ComponentModel.DataAnnotations; 4 | 5 | public class VerifyEmailRequest 6 | { 7 | [Required] 8 | public string Token { get; set; } 9 | } -------------------------------------------------------------------------------- /Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using System.Text.Json.Serialization; 3 | using WebApi.Authorization; 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().AddJsonOptions(x => 17 | { 18 | // serialize enums as strings in api responses (e.g. Role) 19 | x.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); 20 | }); 21 | services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies()); 22 | services.AddSwaggerGen(); 23 | 24 | // configure strongly typed settings object 25 | services.Configure(builder.Configuration.GetSection("AppSettings")); 26 | 27 | // configure DI for application services 28 | services.AddScoped(); 29 | services.AddScoped(); 30 | services.AddScoped(); 31 | } 32 | 33 | var app = builder.Build(); 34 | 35 | // migrate any database changes on startup (includes initial db creation) 36 | using (var scope = app.Services.CreateScope()) 37 | { 38 | var dataContext = scope.ServiceProvider.GetRequiredService(); 39 | dataContext.Database.Migrate(); 40 | } 41 | 42 | // configure HTTP request pipeline 43 | { 44 | // generated swagger json and swagger ui middleware 45 | app.UseSwagger(); 46 | app.UseSwaggerUI(x => x.SwaggerEndpoint("/swagger/v1/swagger.json", ".NET Sign-up and Verification API")); 47 | 48 | // global cors policy 49 | app.UseCors(x => x 50 | .SetIsOriginAllowed(origin => true) 51 | .AllowAnyMethod() 52 | .AllowAnyHeader() 53 | .AllowCredentials()); 54 | 55 | // global error handler 56 | app.UseMiddleware(); 57 | 58 | // custom jwt auth middleware 59 | app.UseMiddleware(); 60 | 61 | app.MapControllers(); 62 | } 63 | 64 | app.Run("http://localhost:4000"); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dotnet-6-signup-verification-api 2 | 3 | .NET 6.0 - Boilerplate API with Email Sign Up, Verification, Authentication & Forgot Password 4 | 5 | Documentation at https://jasonwatmore.com/post/2022/02/26/net-6-boilerplate-api-tutorial-with-email-sign-up-verification-authentication-forgot-password 6 | 7 | Documentación en español en https://jasonwatmore.es/post/2022/02/26/net-6-tutorial-de-api-estandar-con-registro-de-correo-electronico-verificacion-autenticacion-y-contrasena-olvidada -------------------------------------------------------------------------------- /Services/AccountService.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Services; 2 | 3 | using AutoMapper; 4 | using BCrypt.Net; 5 | using Microsoft.Extensions.Options; 6 | using Microsoft.IdentityModel.Tokens; 7 | using System.IdentityModel.Tokens.Jwt; 8 | using System.Security.Claims; 9 | using System.Security.Cryptography; 10 | using System.Text; 11 | using WebApi.Authorization; 12 | using WebApi.Entities; 13 | using WebApi.Helpers; 14 | using WebApi.Models.Accounts; 15 | 16 | public interface IAccountService 17 | { 18 | AuthenticateResponse Authenticate(AuthenticateRequest model, string ipAddress); 19 | AuthenticateResponse RefreshToken(string token, string ipAddress); 20 | void RevokeToken(string token, string ipAddress); 21 | void Register(RegisterRequest model, string origin); 22 | void VerifyEmail(string token); 23 | void ForgotPassword(ForgotPasswordRequest model, string origin); 24 | void ValidateResetToken(ValidateResetTokenRequest model); 25 | void ResetPassword(ResetPasswordRequest model); 26 | IEnumerable GetAll(); 27 | AccountResponse GetById(int id); 28 | AccountResponse Create(CreateRequest model); 29 | AccountResponse Update(int id, UpdateRequest model); 30 | void Delete(int id); 31 | } 32 | 33 | public class AccountService : IAccountService 34 | { 35 | private readonly DataContext _context; 36 | private readonly IJwtUtils _jwtUtils; 37 | private readonly IMapper _mapper; 38 | private readonly AppSettings _appSettings; 39 | private readonly IEmailService _emailService; 40 | 41 | public AccountService( 42 | DataContext context, 43 | IJwtUtils jwtUtils, 44 | IMapper mapper, 45 | IOptions appSettings, 46 | IEmailService emailService) 47 | { 48 | _context = context; 49 | _jwtUtils = jwtUtils; 50 | _mapper = mapper; 51 | _appSettings = appSettings.Value; 52 | _emailService = emailService; 53 | } 54 | 55 | public AuthenticateResponse Authenticate(AuthenticateRequest model, string ipAddress) 56 | { 57 | var account = _context.Accounts.SingleOrDefault(x => x.Email == model.Email); 58 | 59 | // validate 60 | if (account == null || !account.IsVerified || !BCrypt.Verify(model.Password, account.PasswordHash)) 61 | throw new AppException("Email or password is incorrect"); 62 | 63 | // authentication successful so generate jwt and refresh tokens 64 | var jwtToken = _jwtUtils.GenerateJwtToken(account); 65 | var refreshToken = _jwtUtils.GenerateRefreshToken(ipAddress); 66 | account.RefreshTokens.Add(refreshToken); 67 | 68 | // remove old refresh tokens from account 69 | removeOldRefreshTokens(account); 70 | 71 | // save changes to db 72 | _context.Update(account); 73 | _context.SaveChanges(); 74 | 75 | var response = _mapper.Map(account); 76 | response.JwtToken = jwtToken; 77 | response.RefreshToken = refreshToken.Token; 78 | return response; 79 | } 80 | 81 | public AuthenticateResponse RefreshToken(string token, string ipAddress) 82 | { 83 | var account = getAccountByRefreshToken(token); 84 | var refreshToken = account.RefreshTokens.Single(x => x.Token == token); 85 | 86 | if (refreshToken.IsRevoked) 87 | { 88 | // revoke all descendant tokens in case this token has been compromised 89 | revokeDescendantRefreshTokens(refreshToken, account, ipAddress, $"Attempted reuse of revoked ancestor token: {token}"); 90 | _context.Update(account); 91 | _context.SaveChanges(); 92 | } 93 | 94 | if (!refreshToken.IsActive) 95 | throw new AppException("Invalid token"); 96 | 97 | // replace old refresh token with a new one (rotate token) 98 | var newRefreshToken = rotateRefreshToken(refreshToken, ipAddress); 99 | account.RefreshTokens.Add(newRefreshToken); 100 | 101 | // remove old refresh tokens from account 102 | removeOldRefreshTokens(account); 103 | 104 | // save changes to db 105 | _context.Update(account); 106 | _context.SaveChanges(); 107 | 108 | // generate new jwt 109 | var jwtToken = _jwtUtils.GenerateJwtToken(account); 110 | 111 | // return data in authenticate response object 112 | var response = _mapper.Map(account); 113 | response.JwtToken = jwtToken; 114 | response.RefreshToken = newRefreshToken.Token; 115 | return response; 116 | } 117 | 118 | public void RevokeToken(string token, string ipAddress) 119 | { 120 | var account = getAccountByRefreshToken(token); 121 | var refreshToken = account.RefreshTokens.Single(x => x.Token == token); 122 | 123 | if (!refreshToken.IsActive) 124 | throw new AppException("Invalid token"); 125 | 126 | // revoke token and save 127 | revokeRefreshToken(refreshToken, ipAddress, "Revoked without replacement"); 128 | _context.Update(account); 129 | _context.SaveChanges(); 130 | } 131 | 132 | public void Register(RegisterRequest model, string origin) 133 | { 134 | // validate 135 | if (_context.Accounts.Any(x => x.Email == model.Email)) 136 | { 137 | // send already registered error in email to prevent account enumeration 138 | sendAlreadyRegisteredEmail(model.Email, origin); 139 | return; 140 | } 141 | 142 | // map model to new account object 143 | var account = _mapper.Map(model); 144 | 145 | // first registered account is an admin 146 | var isFirstAccount = _context.Accounts.Count() == 0; 147 | account.Role = isFirstAccount ? Role.Admin : Role.User; 148 | account.Created = DateTime.UtcNow; 149 | account.VerificationToken = generateVerificationToken(); 150 | 151 | // hash password 152 | account.PasswordHash = BCrypt.HashPassword(model.Password); 153 | 154 | // save account 155 | _context.Accounts.Add(account); 156 | _context.SaveChanges(); 157 | 158 | // send email 159 | sendVerificationEmail(account, origin); 160 | } 161 | 162 | public void VerifyEmail(string token) 163 | { 164 | var account = _context.Accounts.SingleOrDefault(x => x.VerificationToken == token); 165 | 166 | if (account == null) 167 | throw new AppException("Verification failed"); 168 | 169 | account.Verified = DateTime.UtcNow; 170 | account.VerificationToken = null; 171 | 172 | _context.Accounts.Update(account); 173 | _context.SaveChanges(); 174 | } 175 | 176 | public void ForgotPassword(ForgotPasswordRequest model, string origin) 177 | { 178 | var account = _context.Accounts.SingleOrDefault(x => x.Email == model.Email); 179 | 180 | // always return ok response to prevent email enumeration 181 | if (account == null) return; 182 | 183 | // create reset token that expires after 1 day 184 | account.ResetToken = generateResetToken(); 185 | account.ResetTokenExpires = DateTime.UtcNow.AddDays(1); 186 | 187 | _context.Accounts.Update(account); 188 | _context.SaveChanges(); 189 | 190 | // send email 191 | sendPasswordResetEmail(account, origin); 192 | } 193 | 194 | public void ValidateResetToken(ValidateResetTokenRequest model) 195 | { 196 | getAccountByResetToken(model.Token); 197 | } 198 | 199 | public void ResetPassword(ResetPasswordRequest model) 200 | { 201 | var account = getAccountByResetToken(model.Token); 202 | 203 | // update password and remove reset token 204 | account.PasswordHash = BCrypt.HashPassword(model.Password); 205 | account.PasswordReset = DateTime.UtcNow; 206 | account.ResetToken = null; 207 | account.ResetTokenExpires = null; 208 | 209 | _context.Accounts.Update(account); 210 | _context.SaveChanges(); 211 | } 212 | 213 | public IEnumerable GetAll() 214 | { 215 | var accounts = _context.Accounts; 216 | return _mapper.Map>(accounts); 217 | } 218 | 219 | public AccountResponse GetById(int id) 220 | { 221 | var account = getAccount(id); 222 | return _mapper.Map(account); 223 | } 224 | 225 | public AccountResponse Create(CreateRequest model) 226 | { 227 | // validate 228 | if (_context.Accounts.Any(x => x.Email == model.Email)) 229 | throw new AppException($"Email '{model.Email}' is already registered"); 230 | 231 | // map model to new account object 232 | var account = _mapper.Map(model); 233 | account.Created = DateTime.UtcNow; 234 | account.Verified = DateTime.UtcNow; 235 | 236 | // hash password 237 | account.PasswordHash = BCrypt.HashPassword(model.Password); 238 | 239 | // save account 240 | _context.Accounts.Add(account); 241 | _context.SaveChanges(); 242 | 243 | return _mapper.Map(account); 244 | } 245 | 246 | public AccountResponse Update(int id, UpdateRequest model) 247 | { 248 | var account = getAccount(id); 249 | 250 | // validate 251 | if (account.Email != model.Email && _context.Accounts.Any(x => x.Email == model.Email)) 252 | throw new AppException($"Email '{model.Email}' is already registered"); 253 | 254 | // hash password if it was entered 255 | if (!string.IsNullOrEmpty(model.Password)) 256 | account.PasswordHash = BCrypt.HashPassword(model.Password); 257 | 258 | // copy model to account and save 259 | _mapper.Map(model, account); 260 | account.Updated = DateTime.UtcNow; 261 | _context.Accounts.Update(account); 262 | _context.SaveChanges(); 263 | 264 | return _mapper.Map(account); 265 | } 266 | 267 | public void Delete(int id) 268 | { 269 | var account = getAccount(id); 270 | _context.Accounts.Remove(account); 271 | _context.SaveChanges(); 272 | } 273 | 274 | // helper methods 275 | 276 | private Account getAccount(int id) 277 | { 278 | var account = _context.Accounts.Find(id); 279 | if (account == null) throw new KeyNotFoundException("Account not found"); 280 | return account; 281 | } 282 | 283 | private Account getAccountByRefreshToken(string token) 284 | { 285 | var account = _context.Accounts.SingleOrDefault(u => u.RefreshTokens.Any(t => t.Token == token)); 286 | if (account == null) throw new AppException("Invalid token"); 287 | return account; 288 | } 289 | 290 | private Account getAccountByResetToken(string token) 291 | { 292 | var account = _context.Accounts.SingleOrDefault(x => 293 | x.ResetToken == token && x.ResetTokenExpires > DateTime.UtcNow); 294 | if (account == null) throw new AppException("Invalid token"); 295 | return account; 296 | } 297 | 298 | private string generateJwtToken(Account account) 299 | { 300 | var tokenHandler = new JwtSecurityTokenHandler(); 301 | var key = Encoding.ASCII.GetBytes(_appSettings.Secret); 302 | var tokenDescriptor = new SecurityTokenDescriptor 303 | { 304 | Subject = new ClaimsIdentity(new[] { new Claim("id", account.Id.ToString()) }), 305 | Expires = DateTime.UtcNow.AddMinutes(15), 306 | SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature) 307 | }; 308 | var token = tokenHandler.CreateToken(tokenDescriptor); 309 | return tokenHandler.WriteToken(token); 310 | } 311 | 312 | private string generateResetToken() 313 | { 314 | // token is a cryptographically strong random sequence of values 315 | var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(64)); 316 | 317 | // ensure token is unique by checking against db 318 | var tokenIsUnique = !_context.Accounts.Any(x => x.ResetToken == token); 319 | if (!tokenIsUnique) 320 | return generateResetToken(); 321 | 322 | return token; 323 | } 324 | 325 | private string generateVerificationToken() 326 | { 327 | // token is a cryptographically strong random sequence of values 328 | var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(64)); 329 | 330 | // ensure token is unique by checking against db 331 | var tokenIsUnique = !_context.Accounts.Any(x => x.VerificationToken == token); 332 | if (!tokenIsUnique) 333 | return generateVerificationToken(); 334 | 335 | return token; 336 | } 337 | 338 | private RefreshToken rotateRefreshToken(RefreshToken refreshToken, string ipAddress) 339 | { 340 | var newRefreshToken = _jwtUtils.GenerateRefreshToken(ipAddress); 341 | revokeRefreshToken(refreshToken, ipAddress, "Replaced by new token", newRefreshToken.Token); 342 | return newRefreshToken; 343 | } 344 | 345 | private void removeOldRefreshTokens(Account account) 346 | { 347 | account.RefreshTokens.RemoveAll(x => 348 | !x.IsActive && 349 | x.Created.AddDays(_appSettings.RefreshTokenTTL) <= DateTime.UtcNow); 350 | } 351 | 352 | private void revokeDescendantRefreshTokens(RefreshToken refreshToken, Account account, string ipAddress, string reason) 353 | { 354 | // recursively traverse the refresh token chain and ensure all descendants are revoked 355 | if (!string.IsNullOrEmpty(refreshToken.ReplacedByToken)) 356 | { 357 | var childToken = account.RefreshTokens.SingleOrDefault(x => x.Token == refreshToken.ReplacedByToken); 358 | if (childToken.IsActive) 359 | revokeRefreshToken(childToken, ipAddress, reason); 360 | else 361 | revokeDescendantRefreshTokens(childToken, account, ipAddress, reason); 362 | } 363 | } 364 | 365 | private void revokeRefreshToken(RefreshToken token, string ipAddress, string reason = null, string replacedByToken = null) 366 | { 367 | token.Revoked = DateTime.UtcNow; 368 | token.RevokedByIp = ipAddress; 369 | token.ReasonRevoked = reason; 370 | token.ReplacedByToken = replacedByToken; 371 | } 372 | 373 | private void sendVerificationEmail(Account account, string origin) 374 | { 375 | string message; 376 | if (!string.IsNullOrEmpty(origin)) 377 | { 378 | // origin exists if request sent from browser single page app (e.g. Angular or React) 379 | // so send link to verify via single page app 380 | var verifyUrl = $"{origin}/account/verify-email?token={account.VerificationToken}"; 381 | message = $@"

Please click the below link to verify your email address:

382 |

{verifyUrl}

"; 383 | } 384 | else 385 | { 386 | // origin missing if request sent directly to api (e.g. from Postman) 387 | // so send instructions to verify directly with api 388 | message = $@"

Please use the below token to verify your email address with the /accounts/verify-email api route:

389 |

{account.VerificationToken}

"; 390 | } 391 | 392 | _emailService.Send( 393 | to: account.Email, 394 | subject: "Sign-up Verification API - Verify Email", 395 | html: $@"

Verify Email

396 |

Thanks for registering!

397 | {message}" 398 | ); 399 | } 400 | 401 | private void sendAlreadyRegisteredEmail(string email, string origin) 402 | { 403 | string message; 404 | if (!string.IsNullOrEmpty(origin)) 405 | message = $@"

If you don't know your password please visit the forgot password page.

"; 406 | else 407 | message = "

If you don't know your password you can reset it via the /accounts/forgot-password api route.

"; 408 | 409 | _emailService.Send( 410 | to: email, 411 | subject: "Sign-up Verification API - Email Already Registered", 412 | html: $@"

Email Already Registered

413 |

Your email {email} is already registered.

414 | {message}" 415 | ); 416 | } 417 | 418 | private void sendPasswordResetEmail(Account account, string origin) 419 | { 420 | string message; 421 | if (!string.IsNullOrEmpty(origin)) 422 | { 423 | var resetUrl = $"{origin}/account/reset-password?token={account.ResetToken}"; 424 | message = $@"

Please click the below link to reset your password, the link will be valid for 1 day:

425 |

{resetUrl}

"; 426 | } 427 | else 428 | { 429 | message = $@"

Please use the below token to reset your password with the /accounts/reset-password api route:

430 |

{account.ResetToken}

"; 431 | } 432 | 433 | _emailService.Send( 434 | to: account.Email, 435 | subject: "Sign-up Verification API - Reset Password", 436 | html: $@"

Reset Password Email

437 | {message}" 438 | ); 439 | } 440 | } -------------------------------------------------------------------------------- /Services/EmailService.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Services; 2 | 3 | using MailKit.Net.Smtp; 4 | using MailKit.Security; 5 | using Microsoft.Extensions.Options; 6 | using MimeKit; 7 | using MimeKit.Text; 8 | using WebApi.Helpers; 9 | 10 | public interface IEmailService 11 | { 12 | void Send(string to, string subject, string html, string from = null); 13 | } 14 | 15 | public class EmailService : IEmailService 16 | { 17 | private readonly AppSettings _appSettings; 18 | 19 | public EmailService(IOptions appSettings) 20 | { 21 | _appSettings = appSettings.Value; 22 | } 23 | 24 | public void Send(string to, string subject, string html, string from = null) 25 | { 26 | // create message 27 | var email = new MimeMessage(); 28 | email.From.Add(MailboxAddress.Parse(from ?? _appSettings.EmailFrom)); 29 | email.To.Add(MailboxAddress.Parse(to)); 30 | email.Subject = subject; 31 | email.Body = new TextPart(TextFormat.Html) { Text = html }; 32 | 33 | // send email 34 | using var smtp = new SmtpClient(); 35 | smtp.Connect(_appSettings.SmtpHost, _appSettings.SmtpPort, SecureSocketOptions.StartTls); 36 | smtp.Authenticate(_appSettings.SmtpUser, _appSettings.SmtpPass); 37 | smtp.Send(email); 38 | smtp.Disconnect(true); 39 | } 40 | } -------------------------------------------------------------------------------- /WebApi.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | net6.0 4 | enable 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | runtime; build; native; contentfiles; analyzers; buildtransitive 14 | all 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /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 | "EmailFrom": "info@dotnet-signup-verification-api.com", 6 | "SmtpHost": "[ENTER YOUR OWN SMTP OPTIONS OR CREATE FREE TEST ACCOUNT IN ONE CLICK AT https://ethereal.email/]", 7 | "SmtpPort": 587, 8 | "SmtpUser": "", 9 | "SmtpPass": "" 10 | }, 11 | "ConnectionStrings": { 12 | "WebApiDatabase": "Data Source=WebApiDatabase.db" 13 | }, 14 | "Logging": { 15 | "LogLevel": { 16 | "Default": "Information", 17 | "Microsoft.AspNetCore": "Warning" 18 | } 19 | } 20 | } --------------------------------------------------------------------------------