├── .gitignore ├── .vscode ├── launch.json └── tasks.json ├── Controllers ├── AccountsController.cs └── BaseController.cs ├── Entities ├── Account.cs ├── RefreshToken.cs └── Role.cs ├── Helpers ├── AppException.cs ├── AppSettings.cs ├── AuthorizeAttribute.cs ├── AutoMapperProfile.cs └── DataContext.cs ├── LICENSE ├── Middleware ├── ErrorHandlerMiddleware.cs └── JwtMiddleware.cs ├── Migrations ├── 20200715105414_InitialCreate.Designer.cs ├── 20200715105414_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 ├── Startup.cs ├── WebApi.csproj ├── appsettings.Development.json └── 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 -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 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/netcoreapp3.1/WebApi.dll", 14 | "args": [], 15 | "cwd": "${workspaceFolder}", 16 | "stopAtEntry": false, 17 | "internalConsoleOptions": "openOnSessionStart", 18 | "env": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | }, 21 | "sourceFileMap": { 22 | "/Views": "${workspaceFolder}/Views" 23 | } 24 | }, 25 | { 26 | "name": ".NET Core Attach", 27 | "type": "coreclr", 28 | "request": "attach", 29 | "processId": "${command:pickProcess}" 30 | } 31 | ] 32 | } -------------------------------------------------------------------------------- /.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}/WebApi.csproj", 11 | "/property:GenerateFullPaths=true", 12 | "/consoleloggerparameters:NoSummary" 13 | ], 14 | "problemMatcher": "$msCompile" 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /Controllers/AccountsController.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using Microsoft.AspNetCore.Http; 3 | using Microsoft.AspNetCore.Mvc; 4 | using System; 5 | using System.Collections.Generic; 6 | using WebApi.Entities; 7 | using WebApi.Models.Accounts; 8 | using WebApi.Services; 9 | 10 | namespace WebApi.Controllers 11 | { 12 | [ApiController] 13 | [Route("[controller]")] 14 | public class AccountsController : BaseController 15 | { 16 | private readonly IAccountService _accountService; 17 | private readonly IMapper _mapper; 18 | 19 | public AccountsController( 20 | IAccountService accountService, 21 | IMapper mapper) 22 | { 23 | _accountService = accountService; 24 | _mapper = mapper; 25 | } 26 | 27 | [HttpPost("authenticate")] 28 | public ActionResult Authenticate(AuthenticateRequest model) 29 | { 30 | var response = _accountService.Authenticate(model, ipAddress()); 31 | setTokenCookie(response.RefreshToken); 32 | return Ok(response); 33 | } 34 | 35 | [HttpPost("refresh-token")] 36 | public ActionResult RefreshToken() 37 | { 38 | var refreshToken = Request.Cookies["refreshToken"]; 39 | var response = _accountService.RefreshToken(refreshToken, ipAddress()); 40 | setTokenCookie(response.RefreshToken); 41 | return Ok(response); 42 | } 43 | 44 | [Authorize] 45 | [HttpPost("revoke-token")] 46 | public IActionResult RevokeToken(RevokeTokenRequest model) 47 | { 48 | // accept token from request body or cookie 49 | var token = model.Token ?? Request.Cookies["refreshToken"]; 50 | 51 | if (string.IsNullOrEmpty(token)) 52 | return BadRequest(new { message = "Token is required" }); 53 | 54 | // users can revoke their own tokens and admins can revoke any tokens 55 | if (!Account.OwnsToken(token) && Account.Role != Role.Admin) 56 | return Unauthorized(new { message = "Unauthorized" }); 57 | 58 | _accountService.RevokeToken(token, ipAddress()); 59 | return Ok(new { message = "Token revoked" }); 60 | } 61 | 62 | [HttpPost("register")] 63 | public IActionResult Register(RegisterRequest model) 64 | { 65 | _accountService.Register(model, Request.Headers["origin"]); 66 | return Ok(new { message = "Registration successful, please check your email for verification instructions" }); 67 | } 68 | 69 | [HttpPost("verify-email")] 70 | public IActionResult VerifyEmail(VerifyEmailRequest model) 71 | { 72 | _accountService.VerifyEmail(model.Token); 73 | return Ok(new { message = "Verification successful, you can now login" }); 74 | } 75 | 76 | [HttpPost("forgot-password")] 77 | public IActionResult ForgotPassword(ForgotPasswordRequest model) 78 | { 79 | _accountService.ForgotPassword(model, Request.Headers["origin"]); 80 | return Ok(new { message = "Please check your email for password reset instructions" }); 81 | } 82 | 83 | [HttpPost("validate-reset-token")] 84 | public IActionResult ValidateResetToken(ValidateResetTokenRequest model) 85 | { 86 | _accountService.ValidateResetToken(model); 87 | return Ok(new { message = "Token is valid" }); 88 | } 89 | 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 | [Authorize] 106 | [HttpGet("{id:int}")] 107 | public ActionResult GetById(int id) 108 | { 109 | // users can get their own account and admins can get any account 110 | if (id != Account.Id && Account.Role != Role.Admin) 111 | return Unauthorized(new { message = "Unauthorized" }); 112 | 113 | var account = _accountService.GetById(id); 114 | return Ok(account); 115 | } 116 | 117 | [Authorize(Role.Admin)] 118 | [HttpPost] 119 | public ActionResult Create(CreateRequest model) 120 | { 121 | var account = _accountService.Create(model); 122 | return Ok(account); 123 | } 124 | 125 | [Authorize] 126 | [HttpPut("{id:int}")] 127 | public ActionResult Update(int id, UpdateRequest model) 128 | { 129 | // users can update their own account and admins can update any account 130 | if (id != Account.Id && Account.Role != Role.Admin) 131 | return Unauthorized(new { message = "Unauthorized" }); 132 | 133 | // only admins can update role 134 | if (Account.Role != Role.Admin) 135 | model.Role = null; 136 | 137 | var account = _accountService.Update(id, model); 138 | return Ok(account); 139 | } 140 | 141 | [Authorize] 142 | [HttpDelete("{id:int}")] 143 | public IActionResult Delete(int id) 144 | { 145 | // users can delete their own account and admins can delete any account 146 | if (id != Account.Id && Account.Role != Role.Admin) 147 | return Unauthorized(new { message = "Unauthorized" }); 148 | 149 | _accountService.Delete(id); 150 | return Ok(new { message = "Account deleted successfully" }); 151 | } 152 | 153 | // helper methods 154 | 155 | private void setTokenCookie(string token) 156 | { 157 | var cookieOptions = new CookieOptions 158 | { 159 | HttpOnly = true, 160 | Expires = DateTime.UtcNow.AddDays(7) 161 | }; 162 | Response.Cookies.Append("refreshToken", token, cookieOptions); 163 | } 164 | 165 | private string ipAddress() 166 | { 167 | if (Request.Headers.ContainsKey("X-Forwarded-For")) 168 | return Request.Headers["X-Forwarded-For"]; 169 | else 170 | return HttpContext.Connection.RemoteIpAddress.MapToIPv4().ToString(); 171 | } 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /Controllers/BaseController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Microsoft.AspNetCore.Mvc; 3 | using WebApi.Entities; 4 | 5 | namespace WebApi.Controllers 6 | { 7 | [Controller] 8 | public abstract class BaseController : ControllerBase 9 | { 10 | // returns the current authenticated account (null if not logged in) 11 | public Account Account => (Account)HttpContext.Items["Account"]; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Entities/Account.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace WebApi.Entities 5 | { 6 | public class Account 7 | { 8 | public int Id { get; set; } 9 | public string Title { get; set; } 10 | public string FirstName { get; set; } 11 | public string LastName { get; set; } 12 | public string Email { get; set; } 13 | public string PasswordHash { get; set; } 14 | public bool AcceptTerms { get; set; } 15 | public Role Role { get; set; } 16 | public string VerificationToken { get; set; } 17 | public DateTime? Verified { get; set; } 18 | public bool IsVerified => Verified.HasValue || PasswordReset.HasValue; 19 | public string ResetToken { get; set; } 20 | public DateTime? ResetTokenExpires { get; set; } 21 | public DateTime? PasswordReset { get; set; } 22 | public DateTime Created { get; set; } 23 | public DateTime? Updated { get; set; } 24 | public List RefreshTokens { get; set; } 25 | 26 | public bool OwnsToken(string token) 27 | { 28 | return this.RefreshTokens?.Find(x => x.Token == token) != null; 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /Entities/RefreshToken.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using System; 3 | using System.ComponentModel.DataAnnotations; 4 | 5 | namespace WebApi.Entities 6 | { 7 | [Owned] 8 | public class RefreshToken 9 | { 10 | [Key] 11 | public int Id { get; set; } 12 | public Account Account { get; set; } 13 | public string Token { get; set; } 14 | public DateTime Expires { get; set; } 15 | public bool IsExpired => DateTime.UtcNow >= Expires; 16 | public DateTime Created { get; set; } 17 | public string CreatedByIp { get; set; } 18 | public DateTime? Revoked { get; set; } 19 | public string RevokedByIp { get; set; } 20 | public string ReplacedByToken { get; set; } 21 | public bool IsActive => Revoked == null && !IsExpired; 22 | } 23 | } -------------------------------------------------------------------------------- /Entities/Role.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Entities 2 | { 3 | public enum Role 4 | { 5 | Admin, 6 | User 7 | } 8 | } -------------------------------------------------------------------------------- /Helpers/AppException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | 4 | namespace WebApi.Helpers 5 | { 6 | // custom exception class for throwing application specific exceptions 7 | // that can be caught and handled within the application 8 | public class AppException : Exception 9 | { 10 | public AppException() : base() {} 11 | 12 | public AppException(string message) : base(message) { } 13 | 14 | public AppException(string message, params object[] args) 15 | : base(String.Format(CultureInfo.CurrentCulture, message, args)) 16 | { 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /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 | } 17 | } -------------------------------------------------------------------------------- /Helpers/AuthorizeAttribute.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Microsoft.AspNetCore.Mvc.Filters; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using WebApi.Entities; 8 | 9 | [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] 10 | public class AuthorizeAttribute : Attribute, IAuthorizationFilter 11 | { 12 | private readonly IList _roles; 13 | 14 | public AuthorizeAttribute(params Role[] roles) 15 | { 16 | _roles = roles ?? new Role[] { }; 17 | } 18 | 19 | public void OnAuthorization(AuthorizationFilterContext context) 20 | { 21 | var account = (Account)context.HttpContext.Items["Account"]; 22 | if (account == null || (_roles.Any() && !_roles.Contains(account.Role))) 23 | { 24 | // not logged in or role not authorized 25 | context.Result = new JsonResult(new { message = "Unauthorized" }) { StatusCode = StatusCodes.Status401Unauthorized }; 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /Helpers/AutoMapperProfile.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using WebApi.Entities; 3 | using WebApi.Models.Accounts; 4 | 5 | namespace WebApi.Helpers 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 | } 36 | } -------------------------------------------------------------------------------- /Helpers/DataContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.Extensions.Configuration; 3 | using WebApi.Entities; 4 | 5 | namespace WebApi.Helpers 6 | { 7 | public class DataContext : DbContext 8 | { 9 | public DbSet Accounts { get; set; } 10 | 11 | private readonly IConfiguration Configuration; 12 | 13 | public DataContext(IConfiguration configuration) 14 | { 15 | Configuration = configuration; 16 | } 17 | 18 | protected override void OnConfiguring(DbContextOptionsBuilder options) 19 | { 20 | // connect to sqlite database 21 | options.UseSqlite(Configuration.GetConnectionString("WebApiDatabase")); 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 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 | -------------------------------------------------------------------------------- /Middleware/ErrorHandlerMiddleware.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Net; 5 | using System.Text.Json; 6 | using System.Threading.Tasks; 7 | using WebApi.Helpers; 8 | 9 | namespace WebApi.Middleware 10 | { 11 | public class ErrorHandlerMiddleware 12 | { 13 | private readonly RequestDelegate _next; 14 | 15 | public ErrorHandlerMiddleware(RequestDelegate next) 16 | { 17 | _next = next; 18 | } 19 | 20 | public async Task Invoke(HttpContext context) 21 | { 22 | try 23 | { 24 | await _next(context); 25 | } 26 | catch (Exception error) 27 | { 28 | var response = context.Response; 29 | response.ContentType = "application/json"; 30 | 31 | switch(error) 32 | { 33 | case AppException e: 34 | // custom application error 35 | response.StatusCode = (int)HttpStatusCode.BadRequest; 36 | break; 37 | case KeyNotFoundException e: 38 | // not found error 39 | response.StatusCode = (int)HttpStatusCode.NotFound; 40 | break; 41 | default: 42 | // unhandled error 43 | response.StatusCode = (int)HttpStatusCode.InternalServerError; 44 | break; 45 | } 46 | 47 | var result = JsonSerializer.Serialize(new { message = error?.Message }); 48 | await response.WriteAsync(result); 49 | } 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /Middleware/JwtMiddleware.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Microsoft.Extensions.Options; 3 | using Microsoft.IdentityModel.Tokens; 4 | using System; 5 | using System.IdentityModel.Tokens.Jwt; 6 | using System.Linq; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | using WebApi.Helpers; 10 | 11 | namespace WebApi.Middleware 12 | { 13 | public class JwtMiddleware 14 | { 15 | private readonly RequestDelegate _next; 16 | private readonly AppSettings _appSettings; 17 | 18 | public JwtMiddleware(RequestDelegate next, IOptions appSettings) 19 | { 20 | _next = next; 21 | _appSettings = appSettings.Value; 22 | } 23 | 24 | public async Task Invoke(HttpContext context, DataContext dataContext) 25 | { 26 | var token = context.Request.Headers["Authorization"].FirstOrDefault()?.Split(" ").Last(); 27 | 28 | if (token != null) 29 | await attachAccountToContext(context, dataContext, token); 30 | 31 | await _next(context); 32 | } 33 | 34 | private async Task attachAccountToContext(HttpContext context, DataContext dataContext, string token) 35 | { 36 | try 37 | { 38 | var tokenHandler = new JwtSecurityTokenHandler(); 39 | var key = Encoding.ASCII.GetBytes(_appSettings.Secret); 40 | tokenHandler.ValidateToken(token, new TokenValidationParameters 41 | { 42 | ValidateIssuerSigningKey = true, 43 | IssuerSigningKey = new SymmetricSecurityKey(key), 44 | ValidateIssuer = false, 45 | ValidateAudience = false, 46 | // set clockskew to zero so tokens expire exactly at token expiration time (instead of 5 minutes later) 47 | ClockSkew = TimeSpan.Zero 48 | }, out SecurityToken validatedToken); 49 | 50 | var jwtToken = (JwtSecurityToken)validatedToken; 51 | var accountId = int.Parse(jwtToken.Claims.First(x => x.Type == "id").Value); 52 | 53 | // attach account to context on successful jwt validation 54 | context.Items["Account"] = await dataContext.Accounts.FindAsync(accountId); 55 | } 56 | catch 57 | { 58 | // do nothing if jwt validation fails 59 | // account is not attached to context so request won't have access to secure routes 60 | } 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /Migrations/20200715105414_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 | namespace WebApi.Migrations 10 | { 11 | [DbContext(typeof(DataContext))] 12 | [Migration("20200715105414_InitialCreate")] 13 | partial class InitialCreate 14 | { 15 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 16 | { 17 | #pragma warning disable 612, 618 18 | modelBuilder 19 | .HasAnnotation("ProductVersion", "3.1.5"); 20 | 21 | modelBuilder.Entity("WebApi.Entities.Account", b => 22 | { 23 | b.Property("Id") 24 | .ValueGeneratedOnAdd() 25 | .HasColumnType("INTEGER"); 26 | 27 | b.Property("AcceptTerms") 28 | .HasColumnType("INTEGER"); 29 | 30 | b.Property("Created") 31 | .HasColumnType("TEXT"); 32 | 33 | b.Property("Email") 34 | .HasColumnType("TEXT"); 35 | 36 | b.Property("FirstName") 37 | .HasColumnType("TEXT"); 38 | 39 | b.Property("LastName") 40 | .HasColumnType("TEXT"); 41 | 42 | b.Property("PasswordHash") 43 | .HasColumnType("TEXT"); 44 | 45 | b.Property("PasswordReset") 46 | .HasColumnType("TEXT"); 47 | 48 | b.Property("ResetToken") 49 | .HasColumnType("TEXT"); 50 | 51 | b.Property("ResetTokenExpires") 52 | .HasColumnType("TEXT"); 53 | 54 | b.Property("Role") 55 | .HasColumnType("INTEGER"); 56 | 57 | b.Property("Title") 58 | .HasColumnType("TEXT"); 59 | 60 | b.Property("Updated") 61 | .HasColumnType("TEXT"); 62 | 63 | b.Property("VerificationToken") 64 | .HasColumnType("TEXT"); 65 | 66 | b.Property("Verified") 67 | .HasColumnType("TEXT"); 68 | 69 | b.HasKey("Id"); 70 | 71 | b.ToTable("Accounts"); 72 | }); 73 | 74 | modelBuilder.Entity("WebApi.Entities.Account", b => 75 | { 76 | b.OwnsMany("WebApi.Entities.RefreshToken", "RefreshTokens", b1 => 77 | { 78 | b1.Property("Id") 79 | .ValueGeneratedOnAdd() 80 | .HasColumnType("INTEGER"); 81 | 82 | b1.Property("AccountId") 83 | .HasColumnType("INTEGER"); 84 | 85 | b1.Property("Created") 86 | .HasColumnType("TEXT"); 87 | 88 | b1.Property("CreatedByIp") 89 | .HasColumnType("TEXT"); 90 | 91 | b1.Property("Expires") 92 | .HasColumnType("TEXT"); 93 | 94 | b1.Property("ReplacedByToken") 95 | .HasColumnType("TEXT"); 96 | 97 | b1.Property("Revoked") 98 | .HasColumnType("TEXT"); 99 | 100 | b1.Property("RevokedByIp") 101 | .HasColumnType("TEXT"); 102 | 103 | b1.Property("Token") 104 | .HasColumnType("TEXT"); 105 | 106 | b1.HasKey("Id"); 107 | 108 | b1.HasIndex("AccountId"); 109 | 110 | b1.ToTable("RefreshToken"); 111 | 112 | b1.WithOwner("Account") 113 | .HasForeignKey("AccountId"); 114 | }); 115 | }); 116 | #pragma warning restore 612, 618 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /Migrations/20200715105414_InitialCreate.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | namespace WebApi.Migrations 5 | { 6 | public partial class InitialCreate : Migration 7 | { 8 | protected override void Up(MigrationBuilder migrationBuilder) 9 | { 10 | migrationBuilder.CreateTable( 11 | name: "Accounts", 12 | columns: table => new 13 | { 14 | Id = table.Column(nullable: false) 15 | .Annotation("Sqlite:Autoincrement", true), 16 | Title = table.Column(nullable: true), 17 | FirstName = table.Column(nullable: true), 18 | LastName = table.Column(nullable: true), 19 | Email = table.Column(nullable: true), 20 | PasswordHash = table.Column(nullable: true), 21 | AcceptTerms = table.Column(nullable: false), 22 | Role = table.Column(nullable: false), 23 | VerificationToken = table.Column(nullable: true), 24 | Verified = table.Column(nullable: true), 25 | ResetToken = table.Column(nullable: true), 26 | ResetTokenExpires = table.Column(nullable: true), 27 | PasswordReset = table.Column(nullable: true), 28 | Created = table.Column(nullable: false), 29 | Updated = table.Column(nullable: true) 30 | }, 31 | constraints: table => 32 | { 33 | table.PrimaryKey("PK_Accounts", x => x.Id); 34 | }); 35 | 36 | migrationBuilder.CreateTable( 37 | name: "RefreshToken", 38 | columns: table => new 39 | { 40 | Id = table.Column(nullable: false) 41 | .Annotation("Sqlite:Autoincrement", true), 42 | AccountId = table.Column(nullable: false), 43 | Token = table.Column(nullable: true), 44 | Expires = table.Column(nullable: false), 45 | Created = table.Column(nullable: false), 46 | CreatedByIp = table.Column(nullable: true), 47 | Revoked = table.Column(nullable: true), 48 | RevokedByIp = table.Column(nullable: true), 49 | ReplacedByToken = table.Column(nullable: true) 50 | }, 51 | constraints: table => 52 | { 53 | table.PrimaryKey("PK_RefreshToken", x => x.Id); 54 | table.ForeignKey( 55 | name: "FK_RefreshToken_Accounts_AccountId", 56 | column: x => x.AccountId, 57 | principalTable: "Accounts", 58 | principalColumn: "Id", 59 | onDelete: ReferentialAction.Cascade); 60 | }); 61 | 62 | migrationBuilder.CreateIndex( 63 | name: "IX_RefreshToken_AccountId", 64 | table: "RefreshToken", 65 | column: "AccountId"); 66 | } 67 | 68 | protected override void Down(MigrationBuilder migrationBuilder) 69 | { 70 | migrationBuilder.DropTable( 71 | name: "RefreshToken"); 72 | 73 | migrationBuilder.DropTable( 74 | name: "Accounts"); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /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 | namespace WebApi.Migrations 9 | { 10 | [DbContext(typeof(DataContext))] 11 | partial class DataContextModelSnapshot : ModelSnapshot 12 | { 13 | protected override void BuildModel(ModelBuilder modelBuilder) 14 | { 15 | #pragma warning disable 612, 618 16 | modelBuilder 17 | .HasAnnotation("ProductVersion", "3.1.5"); 18 | 19 | modelBuilder.Entity("WebApi.Entities.Account", b => 20 | { 21 | b.Property("Id") 22 | .ValueGeneratedOnAdd() 23 | .HasColumnType("INTEGER"); 24 | 25 | b.Property("AcceptTerms") 26 | .HasColumnType("INTEGER"); 27 | 28 | b.Property("Created") 29 | .HasColumnType("TEXT"); 30 | 31 | b.Property("Email") 32 | .HasColumnType("TEXT"); 33 | 34 | b.Property("FirstName") 35 | .HasColumnType("TEXT"); 36 | 37 | b.Property("LastName") 38 | .HasColumnType("TEXT"); 39 | 40 | b.Property("PasswordHash") 41 | .HasColumnType("TEXT"); 42 | 43 | b.Property("PasswordReset") 44 | .HasColumnType("TEXT"); 45 | 46 | b.Property("ResetToken") 47 | .HasColumnType("TEXT"); 48 | 49 | b.Property("ResetTokenExpires") 50 | .HasColumnType("TEXT"); 51 | 52 | b.Property("Role") 53 | .HasColumnType("INTEGER"); 54 | 55 | b.Property("Title") 56 | .HasColumnType("TEXT"); 57 | 58 | b.Property("Updated") 59 | .HasColumnType("TEXT"); 60 | 61 | b.Property("VerificationToken") 62 | .HasColumnType("TEXT"); 63 | 64 | b.Property("Verified") 65 | .HasColumnType("TEXT"); 66 | 67 | b.HasKey("Id"); 68 | 69 | b.ToTable("Accounts"); 70 | }); 71 | 72 | modelBuilder.Entity("WebApi.Entities.Account", b => 73 | { 74 | b.OwnsMany("WebApi.Entities.RefreshToken", "RefreshTokens", b1 => 75 | { 76 | b1.Property("Id") 77 | .ValueGeneratedOnAdd() 78 | .HasColumnType("INTEGER"); 79 | 80 | b1.Property("AccountId") 81 | .HasColumnType("INTEGER"); 82 | 83 | b1.Property("Created") 84 | .HasColumnType("TEXT"); 85 | 86 | b1.Property("CreatedByIp") 87 | .HasColumnType("TEXT"); 88 | 89 | b1.Property("Expires") 90 | .HasColumnType("TEXT"); 91 | 92 | b1.Property("ReplacedByToken") 93 | .HasColumnType("TEXT"); 94 | 95 | b1.Property("Revoked") 96 | .HasColumnType("TEXT"); 97 | 98 | b1.Property("RevokedByIp") 99 | .HasColumnType("TEXT"); 100 | 101 | b1.Property("Token") 102 | .HasColumnType("TEXT"); 103 | 104 | b1.HasKey("Id"); 105 | 106 | b1.HasIndex("AccountId"); 107 | 108 | b1.ToTable("RefreshToken"); 109 | 110 | b1.WithOwner("Account") 111 | .HasForeignKey("AccountId"); 112 | }); 113 | }); 114 | #pragma warning restore 612, 618 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Models/Accounts/AccountResponse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace WebApi.Models.Accounts 4 | { 5 | public class AccountResponse 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 | } 17 | } -------------------------------------------------------------------------------- /Models/Accounts/AuthenticateRequest.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace WebApi.Models.Accounts 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 | } 14 | } -------------------------------------------------------------------------------- /Models/Accounts/AuthenticateResponse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace WebApi.Models.Accounts 5 | { 6 | public class AuthenticateResponse 7 | { 8 | public int Id { get; set; } 9 | public string Title { get; set; } 10 | public string FirstName { get; set; } 11 | public string LastName { get; set; } 12 | public string Email { get; set; } 13 | public string Role { get; set; } 14 | public DateTime Created { get; set; } 15 | public DateTime? Updated { get; set; } 16 | public bool IsVerified { get; set; } 17 | public string JwtToken { get; set; } 18 | 19 | [JsonIgnore] // refresh token is returned in http only cookie 20 | public string RefreshToken { get; set; } 21 | } 22 | } -------------------------------------------------------------------------------- /Models/Accounts/CreateRequest.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using WebApi.Entities; 3 | 4 | namespace WebApi.Models.Accounts 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 | } 33 | } -------------------------------------------------------------------------------- /Models/Accounts/ForgotPasswordRequest.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace WebApi.Models.Accounts 4 | { 5 | public class ForgotPasswordRequest 6 | { 7 | [Required] 8 | [EmailAddress] 9 | public string Email { get; set; } 10 | } 11 | } -------------------------------------------------------------------------------- /Models/Accounts/RegisterRequest.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace WebApi.Models.Accounts 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 | } 31 | } -------------------------------------------------------------------------------- /Models/Accounts/ResetPasswordRequest.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace WebApi.Models.Accounts 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 | } 18 | } -------------------------------------------------------------------------------- /Models/Accounts/RevokeTokenRequest.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Models.Accounts 2 | { 3 | public class RevokeTokenRequest 4 | { 5 | public string Token { get; set; } 6 | } 7 | } -------------------------------------------------------------------------------- /Models/Accounts/UpdateRequest.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using WebApi.Entities; 3 | 4 | namespace WebApi.Models.Accounts 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 | } 53 | } -------------------------------------------------------------------------------- /Models/Accounts/ValidateResetTokenRequest.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace WebApi.Models.Accounts 4 | { 5 | public class ValidateResetTokenRequest 6 | { 7 | [Required] 8 | public string Token { get; set; } 9 | } 10 | } -------------------------------------------------------------------------------- /Models/Accounts/VerifyEmailRequest.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace WebApi.Models.Accounts 4 | { 5 | public class VerifyEmailRequest 6 | { 7 | [Required] 8 | public string Token { get; set; } 9 | } 10 | } -------------------------------------------------------------------------------- /Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Hosting; 2 | using Microsoft.Extensions.Hosting; 3 | 4 | namespace WebApi 5 | { 6 | public class Program 7 | { 8 | public static void Main(string[] args) 9 | { 10 | CreateHostBuilder(args).Build().Run(); 11 | } 12 | 13 | public static IHostBuilder CreateHostBuilder(string[] args) => 14 | Host.CreateDefaultBuilder(args) 15 | .ConfigureWebHostDefaults(webBuilder => 16 | { 17 | webBuilder.UseStartup() 18 | .UseUrls("http://localhost:4000"); 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aspnet-core-3-signup-verification-api 2 | 3 | ASP.NET Core 3.1 - Boilerplate API with Email Sign Up, Verification, Authentication & Forgot Password 4 | 5 | For Documentation and instructions see https://jasonwatmore.com/post/2020/07/06/aspnet-core-3-boilerplate-api-with-email-sign-up-verification-authentication-forgot-password 6 | -------------------------------------------------------------------------------- /Services/AccountService.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using BC = BCrypt.Net.BCrypt; 3 | using Microsoft.Extensions.Options; 4 | using Microsoft.IdentityModel.Tokens; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.IdentityModel.Tokens.Jwt; 8 | using System.Linq; 9 | using System.Security.Claims; 10 | using System.Security.Cryptography; 11 | using System.Text; 12 | using WebApi.Entities; 13 | using WebApi.Helpers; 14 | using WebApi.Models.Accounts; 15 | 16 | namespace WebApi.Services 17 | { 18 | public interface IAccountService 19 | { 20 | AuthenticateResponse Authenticate(AuthenticateRequest model, string ipAddress); 21 | AuthenticateResponse RefreshToken(string token, string ipAddress); 22 | void RevokeToken(string token, string ipAddress); 23 | void Register(RegisterRequest model, string origin); 24 | void VerifyEmail(string token); 25 | void ForgotPassword(ForgotPasswordRequest model, string origin); 26 | void ValidateResetToken(ValidateResetTokenRequest model); 27 | void ResetPassword(ResetPasswordRequest model); 28 | IEnumerable GetAll(); 29 | AccountResponse GetById(int id); 30 | AccountResponse Create(CreateRequest model); 31 | AccountResponse Update(int id, UpdateRequest model); 32 | void Delete(int id); 33 | } 34 | 35 | public class AccountService : IAccountService 36 | { 37 | private readonly DataContext _context; 38 | private readonly IMapper _mapper; 39 | private readonly AppSettings _appSettings; 40 | private readonly IEmailService _emailService; 41 | 42 | public AccountService( 43 | DataContext context, 44 | IMapper mapper, 45 | IOptions appSettings, 46 | IEmailService emailService) 47 | { 48 | _context = context; 49 | _mapper = mapper; 50 | _appSettings = appSettings.Value; 51 | _emailService = emailService; 52 | } 53 | 54 | public AuthenticateResponse Authenticate(AuthenticateRequest model, string ipAddress) 55 | { 56 | var account = _context.Accounts.SingleOrDefault(x => x.Email == model.Email); 57 | 58 | if (account == null || !account.IsVerified || !BC.Verify(model.Password, account.PasswordHash)) 59 | throw new AppException("Email or password is incorrect"); 60 | 61 | // authentication successful so generate jwt and refresh tokens 62 | var jwtToken = generateJwtToken(account); 63 | var refreshToken = generateRefreshToken(ipAddress); 64 | account.RefreshTokens.Add(refreshToken); 65 | 66 | // remove old refresh tokens from account 67 | removeOldRefreshTokens(account); 68 | 69 | // save changes to db 70 | _context.Update(account); 71 | _context.SaveChanges(); 72 | 73 | var response = _mapper.Map(account); 74 | response.JwtToken = jwtToken; 75 | response.RefreshToken = refreshToken.Token; 76 | return response; 77 | } 78 | 79 | public AuthenticateResponse RefreshToken(string token, string ipAddress) 80 | { 81 | var (refreshToken, account) = getRefreshToken(token); 82 | 83 | // replace old refresh token with a new one and save 84 | var newRefreshToken = generateRefreshToken(ipAddress); 85 | refreshToken.Revoked = DateTime.UtcNow; 86 | refreshToken.RevokedByIp = ipAddress; 87 | refreshToken.ReplacedByToken = newRefreshToken.Token; 88 | account.RefreshTokens.Add(newRefreshToken); 89 | 90 | removeOldRefreshTokens(account); 91 | 92 | _context.Update(account); 93 | _context.SaveChanges(); 94 | 95 | // generate new jwt 96 | var jwtToken = generateJwtToken(account); 97 | 98 | var response = _mapper.Map(account); 99 | response.JwtToken = jwtToken; 100 | response.RefreshToken = newRefreshToken.Token; 101 | return response; 102 | } 103 | 104 | public void RevokeToken(string token, string ipAddress) 105 | { 106 | var (refreshToken, account) = getRefreshToken(token); 107 | 108 | // revoke token and save 109 | refreshToken.Revoked = DateTime.UtcNow; 110 | refreshToken.RevokedByIp = ipAddress; 111 | _context.Update(account); 112 | _context.SaveChanges(); 113 | } 114 | 115 | public void Register(RegisterRequest model, string origin) 116 | { 117 | // validate 118 | if (_context.Accounts.Any(x => x.Email == model.Email)) 119 | { 120 | // send already registered error in email to prevent account enumeration 121 | sendAlreadyRegisteredEmail(model.Email, origin); 122 | return; 123 | } 124 | 125 | // map model to new account object 126 | var account = _mapper.Map(model); 127 | 128 | // first registered account is an admin 129 | var isFirstAccount = _context.Accounts.Count() == 0; 130 | account.Role = isFirstAccount ? Role.Admin : Role.User; 131 | account.Created = DateTime.UtcNow; 132 | account.VerificationToken = randomTokenString(); 133 | 134 | // hash password 135 | account.PasswordHash = BC.HashPassword(model.Password); 136 | 137 | // save account 138 | _context.Accounts.Add(account); 139 | _context.SaveChanges(); 140 | 141 | // send email 142 | sendVerificationEmail(account, origin); 143 | } 144 | 145 | public void VerifyEmail(string token) 146 | { 147 | var account = _context.Accounts.SingleOrDefault(x => x.VerificationToken == token); 148 | 149 | if (account == null) throw new AppException("Verification failed"); 150 | 151 | account.Verified = DateTime.UtcNow; 152 | account.VerificationToken = null; 153 | 154 | _context.Accounts.Update(account); 155 | _context.SaveChanges(); 156 | } 157 | 158 | public void ForgotPassword(ForgotPasswordRequest model, string origin) 159 | { 160 | var account = _context.Accounts.SingleOrDefault(x => x.Email == model.Email); 161 | 162 | // always return ok response to prevent email enumeration 163 | if (account == null) return; 164 | 165 | // create reset token that expires after 1 day 166 | account.ResetToken = randomTokenString(); 167 | account.ResetTokenExpires = DateTime.UtcNow.AddDays(1); 168 | 169 | _context.Accounts.Update(account); 170 | _context.SaveChanges(); 171 | 172 | // send email 173 | sendPasswordResetEmail(account, origin); 174 | } 175 | 176 | public void ValidateResetToken(ValidateResetTokenRequest model) 177 | { 178 | var account = _context.Accounts.SingleOrDefault(x => 179 | x.ResetToken == model.Token && 180 | x.ResetTokenExpires > DateTime.UtcNow); 181 | 182 | if (account == null) 183 | throw new AppException("Invalid token"); 184 | } 185 | 186 | public void ResetPassword(ResetPasswordRequest model) 187 | { 188 | var account = _context.Accounts.SingleOrDefault(x => 189 | x.ResetToken == model.Token && 190 | x.ResetTokenExpires > DateTime.UtcNow); 191 | 192 | if (account == null) 193 | throw new AppException("Invalid token"); 194 | 195 | // update password and remove reset token 196 | account.PasswordHash = BC.HashPassword(model.Password); 197 | account.PasswordReset = DateTime.UtcNow; 198 | account.ResetToken = null; 199 | account.ResetTokenExpires = null; 200 | 201 | _context.Accounts.Update(account); 202 | _context.SaveChanges(); 203 | } 204 | 205 | public IEnumerable GetAll() 206 | { 207 | var accounts = _context.Accounts; 208 | return _mapper.Map>(accounts); 209 | } 210 | 211 | public AccountResponse GetById(int id) 212 | { 213 | var account = getAccount(id); 214 | return _mapper.Map(account); 215 | } 216 | 217 | public AccountResponse Create(CreateRequest model) 218 | { 219 | // validate 220 | if (_context.Accounts.Any(x => x.Email == model.Email)) 221 | throw new AppException($"Email '{model.Email}' is already registered"); 222 | 223 | // map model to new account object 224 | var account = _mapper.Map(model); 225 | account.Created = DateTime.UtcNow; 226 | account.Verified = DateTime.UtcNow; 227 | 228 | // hash password 229 | account.PasswordHash = BC.HashPassword(model.Password); 230 | 231 | // save account 232 | _context.Accounts.Add(account); 233 | _context.SaveChanges(); 234 | 235 | return _mapper.Map(account); 236 | } 237 | 238 | public AccountResponse Update(int id, UpdateRequest model) 239 | { 240 | var account = getAccount(id); 241 | 242 | // validate 243 | if (account.Email != model.Email && _context.Accounts.Any(x => x.Email == model.Email)) 244 | throw new AppException($"Email '{model.Email}' is already taken"); 245 | 246 | // hash password if it was entered 247 | if (!string.IsNullOrEmpty(model.Password)) 248 | account.PasswordHash = BC.HashPassword(model.Password); 249 | 250 | // copy model to account and save 251 | _mapper.Map(model, account); 252 | account.Updated = DateTime.UtcNow; 253 | _context.Accounts.Update(account); 254 | _context.SaveChanges(); 255 | 256 | return _mapper.Map(account); 257 | } 258 | 259 | public void Delete(int id) 260 | { 261 | var account = getAccount(id); 262 | _context.Accounts.Remove(account); 263 | _context.SaveChanges(); 264 | } 265 | 266 | // helper methods 267 | 268 | private Account getAccount(int id) 269 | { 270 | var account = _context.Accounts.Find(id); 271 | if (account == null) throw new KeyNotFoundException("Account not found"); 272 | return account; 273 | } 274 | 275 | private (RefreshToken, Account) getRefreshToken(string token) 276 | { 277 | var account = _context.Accounts.SingleOrDefault(u => u.RefreshTokens.Any(t => t.Token == token)); 278 | if (account == null) throw new AppException("Invalid token"); 279 | var refreshToken = account.RefreshTokens.Single(x => x.Token == token); 280 | if (!refreshToken.IsActive) throw new AppException("Invalid token"); 281 | return (refreshToken, account); 282 | } 283 | 284 | private string generateJwtToken(Account account) 285 | { 286 | var tokenHandler = new JwtSecurityTokenHandler(); 287 | var key = Encoding.ASCII.GetBytes(_appSettings.Secret); 288 | var tokenDescriptor = new SecurityTokenDescriptor 289 | { 290 | Subject = new ClaimsIdentity(new[] { new Claim("id", account.Id.ToString()) }), 291 | Expires = DateTime.UtcNow.AddMinutes(15), 292 | SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature) 293 | }; 294 | var token = tokenHandler.CreateToken(tokenDescriptor); 295 | return tokenHandler.WriteToken(token); 296 | } 297 | 298 | private RefreshToken generateRefreshToken(string ipAddress) 299 | { 300 | return new RefreshToken 301 | { 302 | Token = randomTokenString(), 303 | Expires = DateTime.UtcNow.AddDays(7), 304 | Created = DateTime.UtcNow, 305 | CreatedByIp = ipAddress 306 | }; 307 | } 308 | 309 | private void removeOldRefreshTokens(Account account) 310 | { 311 | account.RefreshTokens.RemoveAll(x => 312 | !x.IsActive && 313 | x.Created.AddDays(_appSettings.RefreshTokenTTL) <= DateTime.UtcNow); 314 | } 315 | 316 | private string randomTokenString() 317 | { 318 | using var rngCryptoServiceProvider = new RNGCryptoServiceProvider(); 319 | var randomBytes = new byte[40]; 320 | rngCryptoServiceProvider.GetBytes(randomBytes); 321 | // convert random bytes to hex string 322 | return BitConverter.ToString(randomBytes).Replace("-", ""); 323 | } 324 | 325 | private void sendVerificationEmail(Account account, string origin) 326 | { 327 | string message; 328 | if (!string.IsNullOrEmpty(origin)) 329 | { 330 | var verifyUrl = $"{origin}/account/verify-email?token={account.VerificationToken}"; 331 | message = $@"

Please click the below link to verify your email address:

332 |

{verifyUrl}

"; 333 | } 334 | else 335 | { 336 | message = $@"

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

337 |

{account.VerificationToken}

"; 338 | } 339 | 340 | _emailService.Send( 341 | to: account.Email, 342 | subject: "Sign-up Verification API - Verify Email", 343 | html: $@"

Verify Email

344 |

Thanks for registering!

345 | {message}" 346 | ); 347 | } 348 | 349 | private void sendAlreadyRegisteredEmail(string email, string origin) 350 | { 351 | string message; 352 | if (!string.IsNullOrEmpty(origin)) 353 | message = $@"

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

"; 354 | else 355 | message = "

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

"; 356 | 357 | _emailService.Send( 358 | to: email, 359 | subject: "Sign-up Verification API - Email Already Registered", 360 | html: $@"

Email Already Registered

361 |

Your email {email} is already registered.

362 | {message}" 363 | ); 364 | } 365 | 366 | private void sendPasswordResetEmail(Account account, string origin) 367 | { 368 | string message; 369 | if (!string.IsNullOrEmpty(origin)) 370 | { 371 | var resetUrl = $"{origin}/account/reset-password?token={account.ResetToken}"; 372 | message = $@"

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

373 |

{resetUrl}

"; 374 | } 375 | else 376 | { 377 | message = $@"

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

378 |

{account.ResetToken}

"; 379 | } 380 | 381 | _emailService.Send( 382 | to: account.Email, 383 | subject: "Sign-up Verification API - Reset Password", 384 | html: $@"

Reset Password Email

385 | {message}" 386 | ); 387 | } 388 | } 389 | } 390 | -------------------------------------------------------------------------------- /Services/EmailService.cs: -------------------------------------------------------------------------------- 1 | using MailKit.Net.Smtp; 2 | using MailKit.Security; 3 | using Microsoft.Extensions.Options; 4 | using MimeKit; 5 | using MimeKit.Text; 6 | using WebApi.Helpers; 7 | 8 | namespace WebApi.Services 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 | } 41 | } -------------------------------------------------------------------------------- /Startup.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using Microsoft.AspNetCore.Builder; 3 | using Microsoft.AspNetCore.Hosting; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.Extensions.Configuration; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using System; 8 | using WebApi.Helpers; 9 | using WebApi.Middleware; 10 | using WebApi.Services; 11 | 12 | namespace WebApi 13 | { 14 | public class Startup 15 | { 16 | public IConfiguration Configuration { get; } 17 | 18 | public Startup(IConfiguration configuration) 19 | { 20 | Configuration = configuration; 21 | } 22 | 23 | // add services to the DI container 24 | public void ConfigureServices(IServiceCollection services) 25 | { 26 | services.AddDbContext(); 27 | services.AddCors(); 28 | services.AddControllers().AddJsonOptions(x => x.JsonSerializerOptions.IgnoreNullValues = true); 29 | services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies()); 30 | services.AddSwaggerGen(); 31 | 32 | // configure strongly typed settings object 33 | services.Configure(Configuration.GetSection("AppSettings")); 34 | 35 | // configure DI for application services 36 | services.AddScoped(); 37 | services.AddScoped(); 38 | } 39 | 40 | // configure the HTTP request pipeline 41 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env, DataContext context) 42 | { 43 | // migrate database changes on startup (includes initial db creation) 44 | context.Database.Migrate(); 45 | 46 | // generated swagger json and swagger ui middleware 47 | app.UseSwagger(); 48 | app.UseSwaggerUI(x => x.SwaggerEndpoint("/swagger/v1/swagger.json", "ASP.NET Core Sign-up and Verification API")); 49 | 50 | app.UseRouting(); 51 | 52 | // global cors policy 53 | app.UseCors(x => x 54 | .SetIsOriginAllowed(origin => true) 55 | .AllowAnyMethod() 56 | .AllowAnyHeader() 57 | .AllowCredentials()); 58 | 59 | // global error handler 60 | app.UseMiddleware(); 61 | 62 | // custom jwt auth middleware 63 | app.UseMiddleware(); 64 | 65 | app.UseEndpoints(x => x.MapControllers()); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /WebApi.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | netcoreapp3.1 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | runtime; build; native; contentfiles; analyzers; buildtransitive 13 | all 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "System": "Information", 6 | "Microsoft": "Information" 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /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@aspnet-core-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": "Warning", 18 | "Microsoft.Hosting.Lifetime": "Information" 19 | } 20 | }, 21 | "AllowedHosts": "*" 22 | } --------------------------------------------------------------------------------