├── .gitignore ├── App ├── EmailService.cs ├── Jwt.cs ├── SecureJwtMiddleware.cs ├── SecureJwtMiddlewareExtensions.cs └── Security.cs ├── Controllers ├── BaseApiController.cs ├── ClientLogController.cs ├── JwtCheckController.cs ├── LoginController.cs ├── PasswordResetController.cs ├── RegisterController.cs ├── SecureApiController.cs └── VueStoreDataController.cs ├── Data └── Database.cs ├── Interfaces ├── IDatabase.cs └── IEmailService.cs ├── Models ├── AppConfig.cs ├── AppUser.cs └── AuthResponse.cs ├── Program.cs ├── Properties └── launchSettings.json ├── README.md ├── VueApp ├── .browserslistrc ├── .eslintrc.js ├── .gitignore ├── babel.config.js ├── package-lock.json ├── package.json ├── public │ ├── images │ │ └── logo.png │ └── index.html ├── src │ ├── App.vue │ ├── api.js │ ├── common-mixin.js │ ├── components │ │ └── error-summary.vue │ ├── main.js │ ├── plugins │ │ └── vuetify.js │ ├── router │ │ ├── router.js │ │ └── routes.js │ ├── store-types.js │ ├── store.js │ ├── styles │ │ ├── variables.scss │ │ └── vueapp.scss │ ├── validation.js │ ├── views │ │ ├── account-confirm.vue │ │ ├── account-login.vue │ │ ├── account-pwreset-complete.vue │ │ ├── account-pwreset.vue │ │ ├── account-register-complete.vue │ │ ├── account-register.vue │ │ ├── admin.vue │ │ ├── denied.vue │ │ ├── home.vue │ │ └── restricted.vue │ └── vue-numeric-directive.js └── vue.config.js ├── VueCoreJwt.csproj ├── VueCoreJwt.sln ├── appsettings.Development.json ├── appsettings.json ├── web.Development.config └── web.config /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by http://www.gitignore.io 2 | 3 | ### VisualStudio ### 4 | ## Ignore Visual Studio temporary files, build results, and 5 | ## files generated by popular Visual Studio add-ons. 6 | 7 | # User-specific files 8 | *.suo 9 | *.user 10 | *.sln.docstates 11 | 12 | # Build results 13 | [Dd]ebug/ 14 | [Dd]ebugPublic/ 15 | [Rr]elease/ 16 | x64/ 17 | build/ 18 | bld/ 19 | [Bb]in/ 20 | [Oo]bj/ 21 | 22 | # Roslyn cache directories 23 | *.ide 24 | 25 | # MSTest test Results 26 | [Tt]est[Rr]esult*/ 27 | [Bb]uild[Ll]og.* 28 | 29 | #NUNIT 30 | *.VisualState.xml 31 | TestResult.xml 32 | 33 | # Build Results of an ATL Project 34 | [Dd]ebugPS/ 35 | [Rr]eleasePS/ 36 | dlldata.c 37 | 38 | *_i.c 39 | *_p.c 40 | *_i.h 41 | *.ilk 42 | *.meta 43 | *.obj 44 | *.pch 45 | *.pdb 46 | *.pgc 47 | *.pgd 48 | *.rsp 49 | *.sbr 50 | *.tlb 51 | *.tli 52 | *.tlh 53 | *.tmp 54 | *.tmp_proj 55 | *.log 56 | *.vspscc 57 | *.vssscc 58 | .builds 59 | *.pidb 60 | *.svclog 61 | *.scc 62 | 63 | # Chutzpah Test files 64 | _Chutzpah* 65 | 66 | # Visual C++ cache files 67 | ipch/ 68 | *.aps 69 | *.ncb 70 | *.opensdf 71 | *.sdf 72 | *.cachefile 73 | 74 | # Visual Studio profiler 75 | *.psess 76 | *.vsp 77 | *.vspx 78 | 79 | # TFS 2012 Local Workspace 80 | $tf/ 81 | 82 | # Guidance Automation Toolkit 83 | *.gpState 84 | 85 | # ReSharper is a .NET coding add-in 86 | _ReSharper*/ 87 | *.[Rr]e[Ss]harper 88 | *.DotSettings.user 89 | 90 | # JustCode is a .NET coding addin-in 91 | .JustCode 92 | 93 | # TeamCity is a build add-in 94 | _TeamCity* 95 | 96 | # DotCover is a Code Coverage Tool 97 | *.dotCover 98 | 99 | # NCrunch 100 | _NCrunch_* 101 | .*crunch*.local.xml 102 | 103 | # MightyMoose 104 | *.mm.* 105 | AutoTest.Net/ 106 | 107 | # Web workbench (sass) 108 | .sass-cache/ 109 | 110 | # Installshield output folder 111 | [Ee]xpress/ 112 | 113 | # DocProject is a documentation generator add-in 114 | DocProject/buildhelp/ 115 | DocProject/Help/*.HxT 116 | DocProject/Help/*.HxC 117 | DocProject/Help/*.hhc 118 | DocProject/Help/*.hhk 119 | DocProject/Help/*.hhp 120 | DocProject/Help/Html2 121 | DocProject/Help/html 122 | 123 | # Click-Once directory 124 | publish/ 125 | 126 | # Publish Web Output 127 | *.[Pp]ublish.xml 128 | *.azurePubxml 129 | ## TODO: Comment the next line if you want to checkin your web deploy settings but do note that will include unencrypted passwords 130 | #*.pubxml 131 | *.pubxml.user 132 | 133 | # NuGet Packages Directory 134 | packages/* 135 | ## TODO: If the tool you use requires repositories.config uncomment the next line 136 | #!packages/repositories.config 137 | 138 | # Enable "build/" folder in the NuGet Packages folder since NuGet packages use it for MSBuild targets 139 | # This line needs to be after the ignore of the build folder (and the packages folder if the line above has been uncommented) 140 | !packages/build/ 141 | 142 | # Windows Azure Build Output 143 | csx/ 144 | *.build.csdef 145 | 146 | # Windows Store app package directory 147 | AppPackages/ 148 | 149 | # Others 150 | sql/ 151 | *.Cache 152 | ClientBin/ 153 | [Ss]tyle[Cc]op.* 154 | ~$* 155 | *~ 156 | *.dbmdl 157 | *.dbproj.schemaview 158 | *.pfx 159 | *.publishsettings 160 | node_modules/ 161 | Thumbs.db 162 | 163 | # RIA/Silverlight projects 164 | Generated_Code/ 165 | 166 | # Backup & report files from converting an old project file to a newer 167 | # Visual Studio version. Backup files are not needed, because we have git ;-) 168 | _UpgradeReport_Files/ 169 | Backup*/ 170 | UpgradeLog*.XML 171 | UpgradeLog*.htm 172 | 173 | # SQL Server files 174 | *.mdf 175 | *.ldf 176 | 177 | # Business Intelligence projects 178 | *.rdl.data 179 | *.bim.layout 180 | *.bim_*.settings 181 | 182 | # Microsoft Fakes 183 | FakesAssemblies/ 184 | 185 | # VS 2015 186 | .vs/ 187 | Vendor/packages/ 188 | *.pubxml.user 189 | -------------------------------------------------------------------------------- /App/EmailService.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Serilog; 3 | using VueCoreJwt.Interfaces; 4 | using VueCoreJwt.Models; 5 | 6 | // TODO: customize for your needs and integrate with PostmarkApp, Sendgrid, etc 7 | namespace VueCoreJwt.App 8 | { 9 | public class EmailService : IEmailService 10 | { 11 | private readonly AppConfig config; 12 | 13 | public EmailService(AppConfig config) 14 | { 15 | this.config = config; 16 | } 17 | 18 | public Task Register(AppUser user) 19 | { 20 | var activationLink = $"{config.SiteUrl}/confirm/{user.EmailToken}"; 21 | 22 | if (config.IsDevelopment) 23 | { 24 | Log.Information($"Registration Confirmation Link: {activationLink}"); 25 | } 26 | // TODO: send an email to the user with the link 27 | // config.EmailServiceApiKey 28 | return Task.CompletedTask; 29 | } 30 | 31 | public Task Reset(AppUser user) 32 | { 33 | var resetLink = $"{config.SiteUrl}/reset-confirm/{user.EmailToken}"; 34 | 35 | if (config.IsDevelopment) 36 | { 37 | Log.Information($"Password Reset Link: {resetLink}"); 38 | } 39 | // TODO: send an email to the user with the link 40 | // config.EmailServiceApiKey 41 | 42 | return Task.CompletedTask; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /App/Jwt.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IdentityModel.Tokens.Jwt; 3 | using System.Linq; 4 | using System.Security.Claims; 5 | using System.Text; 6 | using Microsoft.IdentityModel.Tokens; 7 | using VueCoreJwt.Models; 8 | 9 | namespace VueCoreJwt.App 10 | { 11 | public static class Jwt 12 | { 13 | public static string GenerateTokenForUser(AppConfig configuration, AppUser user) 14 | { 15 | var key = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(configuration.JwtKey)); 16 | 17 | var tokenHandler = new JwtSecurityTokenHandler(); 18 | var tokenDescriptor = new SecurityTokenDescriptor 19 | { 20 | Subject = new ClaimsIdentity(new Claim[] 21 | { 22 | // TODO: customize for your needs 23 | // these should match AppUser.cs 24 | // common types are list at https://docs.microsoft.com/en-us/dotnet/api/System.Security.Claims.ClaimTypes?view=netcore-3.0 25 | new Claim(ClaimTypes.Name, user.Name), 26 | new Claim(ClaimTypes.Email, user.Email), 27 | new Claim(ClaimTypes.Sid, user.Id.ToString()), 28 | new Claim(ClaimTypes.Role, user.Role), 29 | // example of using a custom claim type 30 | new Claim("CustomInfo", user.CustomInfo ?? ""), 31 | 32 | }), 33 | Expires = DateTime.UtcNow.AddDays(configuration.JwtDays), 34 | SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256Signature), 35 | Issuer = configuration.SiteUrl, 36 | Audience = configuration.SiteUrl, 37 | }; 38 | 39 | var token = tokenHandler.CreateToken(tokenDescriptor); 40 | 41 | return tokenHandler.WriteToken(token); 42 | 43 | } 44 | 45 | public static int UserIdFromJwt(string tokenString) 46 | { 47 | var jwtEncodedString = tokenString.StartsWith("Bearer") ? tokenString.Substring(7) : tokenString; 48 | var token = new JwtSecurityToken(jwtEncodedString: jwtEncodedString); 49 | 50 | return int.Parse(token.Claims.Single(c => c.Type == ClaimTypes.Sid).Value); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /App/SecureJwtMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Microsoft.AspNetCore.Http; 3 | using VueCoreJwt.Models; 4 | 5 | namespace VueCoreJwt.App 6 | { 7 | // this takes the JWT from the cookie and passes it along the pipeline in the Authorization header where you'd 8 | // normally find a JWT 9 | public class SecureJwtMiddleware 10 | { 11 | private readonly RequestDelegate _next; 12 | 13 | public SecureJwtMiddleware(RequestDelegate next) => _next = next; 14 | 15 | public async Task InvokeAsync(HttpContext context, AppConfig config) 16 | { 17 | var token = context.Request.Cookies[config.CookieName]; 18 | 19 | if (!string.IsNullOrEmpty(token)) 20 | context.Request.Headers.Add("Authorization", "Bearer " + token); 21 | 22 | context.Response.Headers.Add("X-Content-Type-Options", "nosniff"); 23 | context.Response.Headers.Add("X-Xss-Protection", "1"); 24 | context.Response.Headers.Add("X-Frame-Options", "DENY"); 25 | 26 | await _next(context); 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /App/SecureJwtMiddlewareExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | 3 | namespace VueCoreJwt.App 4 | { 5 | // this is used to help register the JWT middleware 6 | public static class SecureJwtMiddlewareExtensions 7 | { 8 | public static IApplicationBuilder UseSecureJwt(this IApplicationBuilder builder) => builder.UseMiddleware(); 9 | } 10 | } -------------------------------------------------------------------------------- /App/Security.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Security.Cryptography; 3 | using System.Text; 4 | using Microsoft.AspNetCore.Cryptography.KeyDerivation; 5 | 6 | namespace VueCoreJwt.App 7 | { 8 | public static class Security 9 | { 10 | public static string HashPassword(string requestPassword, byte[] salt) 11 | { 12 | var hash = Convert.ToBase64String(KeyDerivation.Pbkdf2( 13 | requestPassword, 14 | salt, 15 | prf: KeyDerivationPrf.HMACSHA1, 16 | iterationCount: 10000, 17 | numBytesRequested: 256 / 8)); 18 | return hash; 19 | } 20 | 21 | public static byte[] GenerateSalt() 22 | { 23 | var salt = new byte[128 / 8]; 24 | using (var rng = RandomNumberGenerator.Create()) 25 | { 26 | rng.GetBytes(salt); 27 | } 28 | 29 | return salt; 30 | } 31 | 32 | // generates a GUID for password resetting, email confirmation, etc 33 | public static string GeneratePasswordResetIdentifier() 34 | { 35 | return Guid.NewGuid().ToString("N"); 36 | } 37 | 38 | // the following is not used in this application but maybe useful for other pw reset flows 39 | // can be replaced with https://www.nuget.org/packages/Bogus/ 40 | 41 | // public static string GenerateRandomPassword() 42 | // { 43 | // return GetRandomString(8); 44 | // } 45 | // 46 | // private static string GetRandomString(int length) 47 | // { 48 | // var chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890".ToCharArray(); 49 | // var data = new byte[1]; 50 | // using (var crypto = new RNGCryptoServiceProvider()) 51 | // { 52 | // crypto.GetNonZeroBytes(data); 53 | // data = new byte[length]; 54 | // crypto.GetNonZeroBytes(data); 55 | // } 56 | // 57 | // var result = new StringBuilder(length); 58 | // foreach (var b in data) 59 | // { 60 | // result.Append(chars[b % (chars.Length)]); 61 | // } 62 | // 63 | // return result.ToString(); 64 | // } 65 | 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Controllers/BaseApiController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace VueCoreJwt.Controllers 4 | { 5 | // TODO: customize for your needs 6 | [ApiController] 7 | public class BaseApiController : ControllerBase 8 | { 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Controllers/ClientLogController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Serilog; 3 | 4 | namespace VueCoreJwt.Controllers 5 | { 6 | // stores errors sent from client-side in your Serilog sink of choice 7 | [Route("api/[controller]")] 8 | [ApiController] 9 | public class ClientLogController : BaseApiController 10 | { 11 | // log data from clientside 12 | [HttpPost] 13 | public ActionResult Post(ClientLogRequest request) 14 | { 15 | Log.Error("[JS] {Error}", request.Error); 16 | 17 | return Ok(); 18 | } 19 | } 20 | 21 | public class ClientLogRequest 22 | { 23 | public string Error { get; set; } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Controllers/JwtCheckController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.AspNetCore.Http; 3 | using Microsoft.AspNetCore.Mvc; 4 | using VueCoreJwt.Data; 5 | using VueCoreJwt.Models; 6 | 7 | namespace VueCoreJwt.Controllers 8 | { 9 | // checks to see if the JWT token passed with the request is good and still valid 10 | // sets a fresh cookie and returns the latest user info 11 | [Route("api/[controller]")] 12 | [ApiController] 13 | public class JwtCheckController : SecureApiController 14 | { 15 | private readonly IDatabase db; 16 | private readonly AppConfig config; 17 | 18 | public JwtCheckController(IDatabase db, AppConfig config) 19 | { 20 | this.db = db; 21 | this.config = config; 22 | } 23 | 24 | [HttpGet] 25 | public ActionResult Get() 26 | { 27 | var user = db.GetUserById(CurrentUser.Id); 28 | 29 | // TODO: customize for your needs 30 | // if you wanted to check a token invalidation list then you would do it here 31 | 32 | if (user == null) 33 | { 34 | // if the user isn't found then clear the cookie 35 | HttpContext.Response.Cookies.Delete(config.CookieName); 36 | return new UnauthorizedResult(); 37 | } 38 | 39 | if (config.ValidateEmail && !user.Active) 40 | { 41 | // if using the email validated registration flow then reject users that haven't confirmed 42 | HttpContext.Response.Cookies.Delete(config.CookieName); 43 | return new BadRequestObjectResult("Email has not been confirmed"); 44 | } 45 | 46 | user.LastLogin = DateTime.UtcNow; 47 | db.UpdateUser(user); 48 | 49 | var response = new AuthResponse(config, user, false); 50 | 51 | // set a fresh cookie 52 | HttpContext.Response.Cookies.Append(config.CookieName, response.Token, new CookieOptions { MaxAge = TimeSpan.FromMinutes(60) }); 53 | 54 | // return the latest user info 55 | return response; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Controllers/LoginController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.AspNetCore.Http; 3 | using Microsoft.AspNetCore.Mvc; 4 | using VueCoreJwt.App; 5 | using VueCoreJwt.Data; 6 | using VueCoreJwt.Models; 7 | 8 | namespace VueCoreJwt.Controllers 9 | { 10 | [Route("api/[controller]")] 11 | [ApiController] 12 | public class LoginController : BaseApiController 13 | { 14 | private readonly AppConfig config; 15 | private readonly IDatabase db; 16 | 17 | public LoginController(AppConfig config, IDatabase db) 18 | { 19 | this.config = config; 20 | this.db = db; 21 | } 22 | 23 | [HttpPost] 24 | public ActionResult Post(LoginRequest request) 25 | { 26 | 27 | var user = db.GetUserByEmail(request.Email); 28 | if (user == null) 29 | { 30 | return new NotFoundObjectResult("Account not found"); 31 | } 32 | 33 | // check the login against stored values 34 | var auth = user.PasswordHash == Security.HashPassword(request.Password, user.Salt); 35 | 36 | if (!auth) 37 | { 38 | return new BadRequestObjectResult("Incorrect password"); 39 | } 40 | 41 | if (config.ValidateEmail && !user.Active) 42 | { 43 | // if using the email validated registration flow then reject users that haven't confirmed 44 | return new BadRequestObjectResult("Email has not been confirmed"); 45 | } 46 | 47 | var firstLogin = !user.LastLogin.HasValue; 48 | 49 | user.LastLogin = DateTime.UtcNow; 50 | db.UpdateUser(user); 51 | 52 | var response = new AuthResponse (config, user, firstLogin); 53 | 54 | // set the auth cookie and send back the user info 55 | HttpContext.Response.Cookies.Append(config.CookieName, response.Token, new CookieOptions { MaxAge = TimeSpan.FromMinutes(60) }); 56 | 57 | return response; 58 | } 59 | 60 | [HttpDelete] 61 | public ActionResult Logout() 62 | { 63 | HttpContext.Response.Cookies.Delete(config.CookieName); 64 | 65 | return Ok(); 66 | } 67 | } 68 | 69 | public class LoginRequest 70 | { 71 | public string Email { get; set; } 72 | public string Password { get; set; } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Controllers/PasswordResetController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.AspNetCore.Http; 4 | using Microsoft.AspNetCore.Mvc; 5 | using VueCoreJwt.App; 6 | using VueCoreJwt.Data; 7 | using VueCoreJwt.Interfaces; 8 | using VueCoreJwt.Models; 9 | 10 | namespace VueCoreJwt.Controllers 11 | { 12 | [Route("api/[controller]")] 13 | [ApiController] 14 | public class PasswordResetController : BaseApiController 15 | { 16 | private readonly AppConfig config; 17 | private readonly IDatabase db; 18 | private readonly IEmailService emailService; 19 | 20 | public PasswordResetController(AppConfig config, IDatabase db, IEmailService emailService) 21 | { 22 | this.config = config; 23 | this.db = db; 24 | this.emailService = emailService; 25 | } 26 | 27 | [HttpPost] 28 | public async Task ResetLink(PasswordResetLinkRequest request) 29 | { 30 | var user = db.GetUserByEmail(request.Email); 31 | if (user == null) 32 | { 33 | return new NotFoundObjectResult("Account not found"); 34 | } 35 | 36 | // send an email with a link back to the app containing a verification token 37 | // TODO: if it matters you could store and check a time for the reset request to restrict the reset to a time window 38 | user.EmailToken = Security.GeneratePasswordResetIdentifier(); 39 | db.UpdateUser(user); 40 | 41 | await emailService.Reset(user); 42 | 43 | return Ok(); 44 | } 45 | 46 | [HttpPost("complete")] 47 | public ActionResult ResetComplete(PasswordResetRequest request) 48 | { 49 | var user = db.GetUserByToken(request.EmailToken); 50 | if (user == null) 51 | { 52 | return new NotFoundObjectResult("Invalid Reset Link"); 53 | } 54 | if (!user.Active) 55 | { 56 | return new UnauthorizedObjectResult("Account disabled"); 57 | } 58 | 59 | var firstLogin = !user.LastLogin.HasValue; 60 | 61 | var salt = Security.GenerateSalt(); 62 | var hash = Security.HashPassword(request.NewPassword.Trim(), salt); 63 | 64 | user.EmailToken = null; 65 | user.Salt = salt; 66 | user.PasswordHash = hash; 67 | user.LastLogin = DateTime.UtcNow; 68 | db.UpdateUser(user); 69 | 70 | var response = new AuthResponse(config, user, firstLogin); 71 | 72 | HttpContext.Response.Cookies.Append(config.CookieName, response.Token, new CookieOptions { MaxAge = TimeSpan.FromMinutes(60) }); 73 | // set the new cookie and return the user info 74 | return response; 75 | } 76 | } 77 | 78 | public class PasswordResetLinkRequest 79 | { 80 | public string Email { get; set; } 81 | } 82 | 83 | public class PasswordResetRequest 84 | { 85 | public string NewPassword { get; set; } 86 | public string EmailToken { get; set; } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Controllers/RegisterController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.AspNetCore.Http; 4 | using Microsoft.AspNetCore.Mvc; 5 | using VueCoreJwt.App; 6 | using VueCoreJwt.Data; 7 | using VueCoreJwt.Interfaces; 8 | using VueCoreJwt.Models; 9 | 10 | namespace VueCoreJwt.Controllers 11 | { 12 | [Route("api/[controller]")] 13 | [ApiController] 14 | public class RegisterController : ControllerBase 15 | { 16 | private readonly AppConfig config; 17 | private readonly IDatabase db; 18 | private readonly IEmailService emailService; 19 | 20 | public RegisterController(AppConfig config, IDatabase db, IEmailService emailService) 21 | { 22 | this.config = config; 23 | this.db = db; 24 | this.emailService = emailService; 25 | } 26 | 27 | [HttpPost] 28 | public async Task> Register(RegisterRequest request) 29 | { 30 | var existingUser = db.GetUserByEmail(request.Email); 31 | 32 | if (existingUser != null) 33 | { 34 | return BadRequest("A User with this address already exists"); 35 | } 36 | 37 | var salt = Security.GenerateSalt(); 38 | var hash = Security.HashPassword(request.Password, salt); 39 | var token = Security.GeneratePasswordResetIdentifier(); 40 | 41 | // TODO: customize for your needs 42 | var user = new AppUser 43 | { 44 | Email = request.Email.Trim(), 45 | Name = request.Name.Trim(), 46 | LastLogin = null, 47 | Role = "Normal", 48 | Salt = salt, 49 | PasswordHash = hash, 50 | EmailToken = token, 51 | Active = !config.ValidateEmail 52 | }; 53 | 54 | user.Id = db.AddUser(user); 55 | 56 | // if not using the email validated registration flow then set the auth cookie and return the user info 57 | if (!config.ValidateEmail) 58 | { 59 | var response = new AuthResponse(config, user, true); 60 | 61 | HttpContext.Response.Cookies.Append(config.CookieName, response.Token, new CookieOptions { MaxAge = TimeSpan.FromMinutes(60) }); 62 | 63 | return response; 64 | } 65 | 66 | // else send a new user confirmation link 67 | await emailService.Register(user); 68 | 69 | return Ok(); 70 | } 71 | 72 | // this is only used if validating email 73 | [HttpPost("confirm")] 74 | public ActionResult Confirm(ConfirmRequest request) 75 | { 76 | var user = db.GetUserByToken(request.EmailToken); 77 | 78 | if (user == null) 79 | { 80 | return new NotFoundObjectResult("Invalid confirmation link"); 81 | } 82 | 83 | if (user.Active) 84 | { 85 | return new BadRequestObjectResult("Account already confirmed"); 86 | } 87 | 88 | user.Active = true; 89 | user.LastLogin = DateTime.UtcNow; 90 | user.EmailToken = null; 91 | 92 | db.UpdateUser(user); 93 | 94 | var response = new AuthResponse(config, user, true); 95 | 96 | HttpContext.Response.Cookies.Append(config.CookieName, response.Token, new CookieOptions { MaxAge = TimeSpan.FromMinutes(60) }); 97 | 98 | return response; 99 | } 100 | } 101 | 102 | public class RegisterRequest 103 | { 104 | public string Name { get; set; } 105 | public string Email { get; set; } 106 | public string Password { get; set; } 107 | } 108 | 109 | public class ConfirmRequest 110 | { 111 | public string EmailToken { get; set; } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Controllers/SecureApiController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authorization; 2 | using VueCoreJwt.Models; 3 | 4 | namespace VueCoreJwt.Controllers 5 | { 6 | [Authorize] 7 | public class SecureApiController : BaseApiController 8 | { 9 | private AppUser _currentUser; 10 | 11 | // CurrentUser is hydrated from the information in the JWT token 12 | public AppUser CurrentUser 13 | { 14 | get 15 | { 16 | if (_currentUser == null) 17 | { 18 | _currentUser = AppUser.FromIdentity(User); 19 | } 20 | return _currentUser; 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Controllers/VueStoreDataController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Serilog; 3 | using VueCoreJwt.Models; 4 | 5 | namespace VueCoreJwt.Controllers 6 | { 7 | // first endpoint called by Vue to get any startup data 8 | [Route("api/[controller]")] 9 | [ApiController] 10 | public class VueStoreDataController : BaseApiController 11 | { 12 | private readonly AppConfig config; 13 | 14 | public VueStoreDataController(AppConfig config) 15 | { 16 | this.config = config; 17 | } 18 | 19 | [HttpGet] 20 | public ActionResult Get() 21 | { 22 | // TODO: customize for your needs 23 | return new VueStoreData 24 | { 25 | SomeServiceApiKey = config.SomeServiceApiKey, 26 | ValidateEmail = config.ValidateEmail 27 | }; 28 | } 29 | } 30 | 31 | // TODO: customize for your needs 32 | // anything public you want passed from the server to the Vue app when it starts 33 | // lists, clientside keys, urls, config info, etc 34 | public class VueStoreData 35 | { 36 | public string SomeServiceApiKey { get; set; } // Just a demo value passed to Vue 37 | public bool ValidateEmail { get; set; } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Data/Database.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using VueCoreJwt.App; 5 | using VueCoreJwt.Models; 6 | 7 | // this is a fake database used for demo purposes 8 | // TODO: delete or replace based on your DB choice 9 | 10 | namespace VueCoreJwt.Data 11 | { 12 | public class Database : IDatabase 13 | { 14 | private List Users { get; set; } 15 | 16 | public Database() 17 | { 18 | var salt = Security.GenerateSalt(); 19 | var password = Security.HashPassword("password", salt); 20 | 21 | Users = new List 22 | { 23 | new AppUser 24 | { 25 | Id = 1, 26 | Name = "Idris Elba", 27 | Email = "admin@test.com", 28 | LastLogin = DateTime.Now.AddDays(-10), 29 | CustomInfo = "52 cats", 30 | Role = "Admin", 31 | Salt = salt, 32 | PasswordHash = password, 33 | EmailToken = null, 34 | Active = true, 35 | }, 36 | new AppUser 37 | { 38 | Id = 2, 39 | Name = "Halle Berry", 40 | Email = "normal@test.com", 41 | LastLogin = DateTime.Now.AddDays(-1), 42 | CustomInfo = "2 dogs", 43 | Role = "Normal", 44 | Salt = salt, 45 | PasswordHash = password, 46 | EmailToken = null, 47 | Active = true, 48 | }, 49 | new AppUser 50 | { 51 | Id = 3, 52 | Name = "Gene Kelly", 53 | Email = "unconfirmed@test.com", 54 | LastLogin = null, 55 | CustomInfo = "likes rain", 56 | Role = "Normal", 57 | Salt = salt, 58 | PasswordHash = password, 59 | EmailToken = "db06011dca3a4276aaba2fab9547286b", 60 | Active = false, 61 | }, 62 | new AppUser 63 | { 64 | Id = 4, 65 | Name = "Audry Hepburn", 66 | Email = "pwreset@test.com", 67 | LastLogin = DateTime.Now.AddDays(-5), 68 | CustomInfo = "", 69 | Role = "Normal", 70 | Salt = salt, 71 | PasswordHash = password, 72 | EmailToken = "272065a0222948c2ad5b6cdefb11066e", 73 | Active = true, 74 | } 75 | }; 76 | } 77 | 78 | public int AddUser(AppUser user) 79 | { 80 | user.Id = Users.Count + 1; 81 | Users.Add(user); 82 | return user.Id; 83 | } 84 | 85 | public AppUser GetUserById(int id) 86 | { 87 | return Users.SingleOrDefault(u => u.Id == id); 88 | } 89 | 90 | public AppUser GetUserByEmail(string email) 91 | { 92 | return Users.SingleOrDefault(u => u.Email == email); 93 | } 94 | 95 | public AppUser GetUserByToken(string token) 96 | { 97 | return Users.SingleOrDefault(u => u.EmailToken == token); 98 | } 99 | 100 | public void UpdateUser(AppUser user) 101 | { 102 | Users[Users.FindIndex(u => u.Id == user.Id)] = user; 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Interfaces/IDatabase.cs: -------------------------------------------------------------------------------- 1 | using VueCoreJwt.Models; 2 | 3 | // TODO: replace with your DB 4 | namespace VueCoreJwt.Data 5 | { 6 | public interface IDatabase 7 | { 8 | int AddUser(AppUser user); 9 | AppUser GetUserById(int id); 10 | AppUser GetUserByEmail(string email); 11 | AppUser GetUserByToken(string token); 12 | void UpdateUser(AppUser user); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Interfaces/IEmailService.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using VueCoreJwt.Models; 3 | 4 | // TODO: customize for your needs 5 | namespace VueCoreJwt.Interfaces 6 | { 7 | public interface IEmailService 8 | { 9 | Task Register(AppUser user); 10 | Task Reset(AppUser user); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Models/AppConfig.cs: -------------------------------------------------------------------------------- 1 | namespace VueCoreJwt.Models 2 | { 3 | // this is hydrated from appsettings.json 4 | // TODO: customize for your needs 5 | // TODO: set ValidateEmail in appsettings to control the registration flow 6 | // TODO: update the dev and production settings in appsettings.json 7 | public class AppConfig 8 | { 9 | public string SiteUrl { get; set; } 10 | public string JwtKey { get; set; } 11 | public int JwtDays { get; set; } 12 | public string SomeServiceApiKey { get; set; } 13 | public string EmailServiceApiKey { get; set; } 14 | public bool ValidateEmail { get; set; } // if true then new users must confirm their email before gaining access 15 | public bool IsDevelopment { get; set; } 16 | public string CookieName { get; set; } // this can be any value 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Models/AppUser.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Security.Claims; 4 | 5 | namespace VueCoreJwt.Models 6 | { 7 | // TODO: customize for your needs 8 | public class AppUser 9 | { 10 | public int Id { get; set; } 11 | 12 | public string Email { get; set; } 13 | public string Role { get; set; } 14 | public string Name { get; set; } 15 | public string CustomInfo { get; set; } 16 | 17 | public bool Active { get; set; } 18 | 19 | public DateTime? LastLogin { get; set; } 20 | public byte[] Salt { get; set; } 21 | public string PasswordHash { get; set; } 22 | public string EmailToken { get; set; } // sent in email for pw reset, email confirm, etc 23 | 24 | // get a user from the info stored in the JWT 25 | public static AppUser FromIdentity(ClaimsPrincipal p) 26 | { 27 | return new AppUser 28 | { 29 | // see Jwt.cs for more info 30 | // these should match with Jwt.cs 31 | Name = p.Claims.Single(c => c.Type == ClaimTypes.Name).Value, 32 | Email = p.Claims.Single(c => c.Type == ClaimTypes.Email).Value, 33 | Id = int.Parse(p.Claims.Single(c => c.Type == ClaimTypes.Sid).Value), 34 | Role = p.Claims.Single(c => c.Type == ClaimTypes.Role).Value, 35 | // example of a custom claim 36 | CustomInfo = p.Claims.SingleOrDefault(c => c.Type == "CustomInfo")?.Value, 37 | }; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Models/AuthResponse.cs: -------------------------------------------------------------------------------- 1 | using VueCoreJwt.App; 2 | 3 | namespace VueCoreJwt.Models 4 | { 5 | // Items from AppUser that aren't secret and useful client-side 6 | // This data is sent to Vue upon login or refresh 7 | // TODO: customize for your needs 8 | public class AuthResponse 9 | { 10 | public AuthResponse(AppConfig configuration, AppUser user, bool firstLogin) 11 | { 12 | Token = Jwt.GenerateTokenForUser(configuration, user); 13 | Id = user.Id; 14 | Name = user.Name; 15 | Email = user.Email; 16 | Role = user.Role; 17 | CustomInfo = user.CustomInfo; 18 | FirstLogin = firstLogin; 19 | } 20 | 21 | public int Id { get; set; } 22 | public string Token { get; set; } 23 | public string Role { get; set; } 24 | public string Name { get; set; } 25 | public string Email { get; set; } 26 | public string CustomInfo { get; set; } 27 | public bool FirstLogin { get; set; } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | using Microsoft.AspNetCore.Authentication.JwtBearer; 4 | using Microsoft.AspNetCore.Builder; 5 | using Microsoft.AspNetCore.CookiePolicy; 6 | using Microsoft.AspNetCore.Http; 7 | using Microsoft.Extensions.Configuration; 8 | using Microsoft.Extensions.DependencyInjection; 9 | using Microsoft.Extensions.Hosting; 10 | using Microsoft.IdentityModel.Tokens; 11 | using Serilog; 12 | using Serilog.Events; 13 | using VueCoreJwt.App; 14 | using VueCoreJwt.Data; 15 | using VueCoreJwt.Interfaces; 16 | using VueCoreJwt.Models; 17 | 18 | namespace VueCoreJwt 19 | { 20 | public class Program 21 | { 22 | public static void Main(string[] args) 23 | { 24 | var builder = WebApplication.CreateBuilder(args); 25 | 26 | var env = builder.Environment; 27 | 28 | if (env.IsDevelopment()) 29 | { 30 | // allow the CORS access in development for use by the vue dev server 31 | builder.Services.AddCors(options => 32 | { 33 | options.AddPolicy("_vueDev", 34 | builder => 35 | { 36 | builder.WithOrigins("http://localhost:8080") 37 | .AllowCredentials() 38 | .AllowAnyHeader() 39 | .AllowAnyMethod(); 40 | }); 41 | }); 42 | } 43 | 44 | // register the AppConfig class for DI 45 | var config = new AppConfig(); 46 | builder.Configuration.Bind("App", config); 47 | config.IsDevelopment = env.IsDevelopment(); 48 | builder.Services.AddSingleton(config); 49 | 50 | // use JWT authentication 51 | builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) 52 | .AddJwtBearer(options => 53 | { 54 | options.RequireHttpsMetadata = true; 55 | options.SaveToken = true; 56 | options.TokenValidationParameters = new TokenValidationParameters 57 | { 58 | ValidateAudience = true, 59 | ValidateIssuer = true, 60 | ValidateIssuerSigningKey = true, 61 | IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(config.JwtKey)), 62 | ValidIssuer = config.SiteUrl, 63 | ValidAudience = config.SiteUrl 64 | }; 65 | }); 66 | 67 | builder.Services.AddAuthorization(); 68 | 69 | builder.Services.AddControllers(); 70 | 71 | // TODO: replace with your database and email service 72 | builder.Services.AddScoped(ctx => new Database()); 73 | builder.Services.AddTransient(); 74 | 75 | // configure Serilog 76 | // TODO: adjust the logging levels and desired (also in appsettings.config) 77 | // TODO: change the sink to use a database or other option 78 | builder.Host.UseSerilog((hostingContext, config) => 79 | { 80 | if (env.IsDevelopment()) 81 | { 82 | config 83 | .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Debug) 84 | .Enrich.FromLogContext() 85 | .WriteTo.Console() 86 | ; 87 | } 88 | else 89 | { 90 | config 91 | .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Error) 92 | .Enrich.FromLogContext() 93 | .ReadFrom.Configuration(builder.Configuration) 94 | ; 95 | } 96 | }); 97 | 98 | var app = builder.Build(); 99 | 100 | if (env.IsDevelopment()) 101 | { 102 | app.UseDeveloperExceptionPage(); 103 | }else 104 | { 105 | app.UseHsts(); 106 | } 107 | 108 | app.UseHttpsRedirection(); 109 | 110 | // this serves the production files from /dist 111 | // TODO: see web.config for information or to change redirects 112 | app.UseStaticFiles(); 113 | 114 | if (env.IsDevelopment()) 115 | { 116 | app.UseCors("_vueDev"); 117 | } 118 | 119 | // security restrictions on the cookie 120 | app.UseCookiePolicy(new CookiePolicyOptions 121 | { 122 | MinimumSameSitePolicy = env.IsDevelopment() ? SameSiteMode.Lax : SameSiteMode.Strict, 123 | HttpOnly = HttpOnlyPolicy.Always, 124 | Secure = CookieSecurePolicy.Always 125 | }); 126 | 127 | // middleware to insert the JWT from the cookie into the request header 128 | app.UseSecureJwt(); 129 | 130 | app.UseAuthentication(); 131 | 132 | app.UseAuthorization(); 133 | 134 | app.MapControllers(); 135 | 136 | // log API requests 137 | app.UseSerilogRequestLogging(); 138 | 139 | try 140 | { 141 | app.Run(); 142 | } 143 | catch (Exception ex) 144 | { 145 | Log.Fatal(ex, "Application start-up failed"); 146 | } 147 | finally 148 | { 149 | Log.CloseAndFlush(); 150 | } 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:55195", 8 | "sslPort": 44302 9 | } 10 | }, 11 | "profiles": { 12 | "IIS Express": { 13 | "commandName": "IISExpress", 14 | "launchBrowser": true, 15 | "environmentVariables": { 16 | "ASPNETCORE_ENVIRONMENT": "Development" 17 | } 18 | }, 19 | "VueCoreJwt": { 20 | "commandName": "Project", 21 | "launchBrowser": true, 22 | "launchUrl": "https://localhost:5000", 23 | "applicationUrl": "https://localhost:5000", 24 | "environmentVariables": { 25 | "ASPNETCORE_ENVIRONMENT": "Development" 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-core-jwt 2 | Sample SPA application using Vue CLI, Asp.Net Core 3 API, with JWT for authentication. Also uses Vuetify, 3 | Vee Validate, and includes a few other useful directives and utilities. This project does not use the Vue CLI Middleware package. 4 | 5 | Features include: 6 | * JWT tokens stored in a cookie with httponly 7 | * In production client-side errors are sent to the backend for logging with Serilog 8 | * 2 registration flows, with or without email confirmation 9 | * password reset using email 10 | * FontAwesome Free 11 | * Vuetify (easy to swap for something else) 12 | * Vee Validate 13 | * Vuex store with user information 14 | * Vue mixin passes common features through app 15 | * Navigation guards for 3 levels of users: public, normal, admin (change to anything you like) 16 | * Core API endpoint base classes for public or secure controllers 17 | * ```CurrentUser``` available in secure controllers 18 | 19 | **Both the API and Vue application are marked with ```// TODO:``` comments in places where you should customize the code for your own needs.** 20 | 21 | Uses a fake database and email service. There are a few demo values passed around as boilerplate for passing your own, like 22 | an api key sent to the client for use with 3rd party services. 23 | 24 | In production the /dist folder is served using the ```web.config``` settings. If you are trying to serving files 25 | that end up redirecting to index.html by accident then edit the ```web.config``` file to exclude 26 | those extensions from redirection. 27 | 28 | The ESLint settings are a relaxed version of AirBnb + Vue rules, edit the ```.eslintrc.js``` file with your own preferences. 29 | 30 | Change the ```ValidateEmail``` setting in appsettings.json to toggle the app between: 31 | 1) making new users confirm their email address before having access to the app 32 | 2) instantly approving the account and logging the new user in 33 | 34 | The login page includes buttons to test the features using data from the fake database. 35 | 36 | 37 | ## Computer setup 38 | 39 | Change the JwtKey and other values in ```appsettings.json``` and ```appsettings.Development.json``` 40 | 41 | Install Vue CLI: 42 | 43 | https://cli.vuejs.org/guide/installation.html 44 | 45 | Assumed you have the .Net framework already installed. 46 | 47 | Install the dotnet-watch utility. In a console run: 48 | 49 | ```dotnet tool install --global dotnet-watch --version 2.2.0``` 50 | 51 | If your linter or IDE complains about the ```@/foo``` paths in the JS files then you can 52 | point the IDE to the webpack config file located in ```\VueApp\node_modules\@vue\cli-service\webpack.config.js``` 53 | 54 | ## NPM Commands 55 | 56 | **IMPORTANT**: Run all NPM commands from the /VueApp sub-folder. Skip the ```cd``` if you're already in there. 57 | 58 | ## Project setup 59 | ``` 60 | cd VueApp 61 | npm install 62 | ``` 63 | 64 | I open two console windows, one for Vue and one for the API. 65 | 66 | ***Console 1: Start the backend API for development and watches for changes*** 67 | ``` 68 | cd VueApp 69 | npm run api 70 | ``` 71 | 72 | ***Console 2: Compiles and hot-reloads for development*** 73 | ``` 74 | cd VueApp 75 | npm run serve 76 | ``` 77 | 78 | ### Compiles and minifies for production 79 | ``` 80 | cd VueApp 81 | npm run build 82 | ``` 83 | 84 | ### Lints and fixes files 85 | ``` 86 | cd VueApp 87 | npm run lint 88 | ``` 89 | ## Vue Application 90 | 91 | #### Mixin 92 | ```common-mixin.js``` includes some useful properties from the store and Vuetify. You can register this globally instead of 93 | importing as-needed but third-party components like Vuetify will also get these properties and you might end up with naming 94 | conflicts. 95 | 96 | - ```currentUser``` = the object representing the current user or null if not signed-in 97 | - ```signedIn``` = boolean of the user's sign-in state 98 | - ```mobile``` = boolean, true if the page is in a [mobile layout size](https://vuetifyjs.com/en/customization/breakpoints/#breakpoint-service-object) 99 | 100 | #### Numeric Directives 101 | 102 | - ```v-decimal``` 103 | - ```v-integer``` 104 | 105 | #### Icon Font 106 | 107 | [Font Awesome](https://fontawesome.com/) 108 | 109 | #### Validation 110 | 111 | [Vee Validate](https://github.com/logaretm/vee-validate) 112 | 113 | #### UI Framework 114 | 115 | [Vuetify](https://vuetifyjs.com) 116 | 117 | #### Toast Notifications 118 | 119 | [Vue Toastification](https://github.com/Maronato/vue-toastification) 120 | 121 | ## References 122 | 123 | 1) https://vuetifyjs.com 124 | 1) https://github.com/logaretm/vee-validate 125 | 1) https://github.com/Maronato/vue-toastification 126 | 1) https://fontawesome.com/ 127 | 1) https://cli.vuejs.org/ 128 | 1) https://weblog.west-wind.com/posts/2017/Apr/27/IIS-and-ASPNET-Core-Rewrite-Rules-for-Static-Files-and-Html-5-Routing 129 | 1) https://github.com/neonbones/Boilerplate.AuthDemo 130 | 1) https://gist.github.com/jonasraoni/9dea65e270495158393f54e36ee6b78d 131 | 132 | ## Change Log 133 | 134 | ### 2020-07-21 135 | 136 | - Edited the project file to hide the dist folder but still publish it 137 | 138 | ### 2020-07-18 139 | 140 | - Replaced the global mixin with a separate class for as-needed use 141 | - Use a default User object in the store for easier resetting 142 | - Added a lot of documentation 143 | - Added ```TODO:``` comments everywhere you might want to customize code for your needs 144 | - Send a user to their original route after login if they were redirected to the login page 145 | - Moved the cookie name into appsetting.json 146 | -------------------------------------------------------------------------------- /VueApp/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | ie 11 -------------------------------------------------------------------------------- /VueApp/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | 4 | env: { 5 | node: true, 6 | browser: true, 7 | }, 8 | 9 | extends: [ 10 | "plugin:vue/essential", 11 | "@vue/airbnb", 12 | ], 13 | 14 | rules: { 15 | indent: [ 16 | 2, 17 | 'tab' 18 | ], 19 | quotes: [ 20 | 2, 21 | 'double' 22 | ], 23 | 'linebreak-style': [ 24 | 0 25 | ], 26 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 27 | 'spaced-comment': [ 28 | 0 29 | ], 30 | 'max-len': [ 31 | 0 32 | ], 33 | 'no-new': [ 34 | 0 35 | ], 36 | 'no-tabs': [ 37 | 0 38 | ], 39 | 'object-shorthand': [ 40 | 0 41 | ], 42 | 'func-names': [ 43 | 0 44 | ], 45 | 'no-param-reassign': [ 46 | 0 47 | ], 48 | 'import/no-extraneous-dependencies': [ 49 | 0 50 | ], 51 | 'function-paren-newline': [ 52 | 0 53 | ], 54 | 'no-underscore-dangle': [ 55 | 0 56 | ], 57 | 'object-curly-newline': [ 58 | 0 59 | ], 60 | 'prefer-destructuring': [ 61 | 0 62 | ], 63 | 'operator-linebreak': [ 64 | 0 65 | ], 66 | 'implicit-arrow-linebreak': [ 67 | 0 68 | ], 69 | 'no-bitwise': [ 70 | 0 71 | ], 72 | 'no-mixed-operators': [ 73 | 0 74 | ], 75 | 'no-loop-func': [ 76 | 0 77 | ], 78 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off' 79 | }, 80 | 81 | parserOptions: { 82 | parser: 'babel-eslint', 83 | }, 84 | 85 | globals: { 86 | }, 87 | 88 | }; 89 | -------------------------------------------------------------------------------- /VueApp/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | -------------------------------------------------------------------------------- /VueApp/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | "@vue/cli-plugin-babel/preset", 4 | ], 5 | }; 6 | -------------------------------------------------------------------------------- /VueApp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-core-jwt", 3 | "version": "0.2.0", 4 | "private": false, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint", 9 | "api": "dotnet watch --project ../VueCoreJwt.csproj run -v m" 10 | }, 11 | "dependencies": { 12 | "@fortawesome/fontawesome-free": "^6.0.0", 13 | "axios": "^0.26.0", 14 | "lodash": "^4.17.21", 15 | "vee-validate": "^3.4.14", 16 | "vue": "^2.6.14", 17 | "vue-router": "^3.5.3", 18 | "vue-toastification": "^1.7.14", 19 | "vuetify": "^2.6.3", 20 | "vuex": "^3.6.2" 21 | }, 22 | "devDependencies": { 23 | "@vue/cli-plugin-babel": "^4.5.15", 24 | "@vue/cli-plugin-eslint": "~4.5.15", 25 | "@vue/cli-plugin-router": "^4.5.15", 26 | "@vue/cli-plugin-vuex": "^4.5.15", 27 | "@vue/cli-service": "^4.5.15", 28 | "@vue/eslint-config-airbnb": "^5.3.0", 29 | "babel-eslint": "^10.1.0", 30 | "eslint": "^6.8.0", 31 | "eslint-plugin-import": "^2.25.4", 32 | "eslint-plugin-vue": "^6.2.2", 33 | "sass": "1.32.13", 34 | "sass-loader": "10.2.1", 35 | "vue-cli-plugin-vuetify": "~2.4.5", 36 | "vue-template-compiler": "^2.6.14", 37 | "vuetify-loader": "^1.7.3" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /VueApp/public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsodeman/vue-core-jwt/f1b012343c6827aa85d656132cf32ae214ff7b79/VueApp/public/images/logo.png -------------------------------------------------------------------------------- /VueApp/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Vue Core Jwt 11 | 12 | 13 | 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /VueApp/src/App.vue: -------------------------------------------------------------------------------- 1 | 71 | 103 | 137 | -------------------------------------------------------------------------------- /VueApp/src/api.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | // in dev the api runs on a different URL 4 | const basePath = process.env.NODE_ENV === "development" ? "https://localhost:5001/api/" : "/api/"; 5 | axios.defaults.baseURL = basePath; 6 | 7 | // pass the auth cookies 8 | const requestInterceptor = (request) => { 9 | request.withCredentials = true; 10 | return request; 11 | }; 12 | 13 | axios.interceptors.request.use((request) => requestInterceptor(request)); 14 | 15 | const jwtCheck = () => axios.get("jwtcheck"); 16 | const appSettings = () => axios.get("vuestoredata"); 17 | const login = (r) => axios.post("login", r); 18 | const logout = () => axios.delete("login"); 19 | const log = (error) => axios.post("clientlog", { error: JSON.stringify(error) }); 20 | const passwordReset = (r) => axios.post("passwordreset", r); 21 | const passwordResetComplete = (r) => axios.post("passwordreset/complete", r); 22 | const register = (r) => axios.post("register", r); 23 | const confirm = (r) => axios.post("register/confirm", r); 24 | const user = (id) => axios.get(`users/${id}`); 25 | 26 | export default { 27 | appSettings, 28 | basePath, 29 | confirm, 30 | jwtCheck, 31 | log, 32 | login, 33 | logout, 34 | passwordReset, 35 | passwordResetComplete, 36 | register, 37 | user, 38 | }; 39 | -------------------------------------------------------------------------------- /VueApp/src/common-mixin.js: -------------------------------------------------------------------------------- 1 | // this mixin provides an easy way for components to access the most used 2 | // data from the store and Vuetify state 3 | // you could apply this globally but 3rd party components will also get these 4 | // values and you may end up with naming conflicts 5 | export default { 6 | computed: { 7 | currentUser() { 8 | return this.$store.state.user; 9 | }, 10 | signedIn() { 11 | return this.$store.state.signedIn; 12 | }, 13 | mobile() { 14 | return this.$vuetify.breakpoint.mobile; 15 | }, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /VueApp/src/components/error-summary.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 40 | -------------------------------------------------------------------------------- /VueApp/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import VueRouter from "vue-router"; 3 | import NumericDirective from "@/vue-numeric-directive"; 4 | import Toast from "vue-toastification"; 5 | import "@/validation"; 6 | import { ValidationProvider, ValidationObserver } from "vee-validate"; 7 | import vuetify from "@/plugins/vuetify"; 8 | import api from "@/api"; 9 | import router from "@/router/router"; 10 | import store from "@/store"; 11 | import { actionTypes, commitTypes } from "@/store-types"; 12 | import App from "@/App.vue"; 13 | import "@/styles/vueapp.scss"; 14 | 15 | Vue.config.productionTip = false; 16 | 17 | // ******** router ******** 18 | Vue.use(VueRouter); 19 | 20 | // ******** validation ******** 21 | // vee-validate common components 22 | Vue.component("ValidationProvider", ValidationProvider); 23 | Vue.component("ValidationObserver", ValidationObserver); 24 | 25 | // ******** directives ******** 26 | // v-integer and v-decimal 27 | Vue.use(NumericDirective); 28 | 29 | // ******** other components ******** 30 | Vue.use(Toast, { 31 | timeout: 2000, 32 | }); 33 | 34 | // load any starting data the app needs from the server like paths, keys, lists 35 | store.dispatch(actionTypes.APP_LOAD) 36 | // checks to see if a JWT cookie exists and validates it 37 | .then(() => store.dispatch(actionTypes.CHECK_TOKEN)) 38 | .then(() => { 39 | // configure the router navigation guards to check auth before following routes on each navigation event 40 | // roles are assigned to routes in routes.js 41 | router.beforeEach((to, from, next) => { 42 | // public routes, no access value set or it's set to * 43 | if (!Object.prototype.hasOwnProperty.call(to.meta, "access") || to.meta.access.includes("*")) { 44 | next(); 45 | return; 46 | } 47 | 48 | // private route, users that aren't signed in go to login 49 | if (!store.state.signedIn) { 50 | // store the path they were trying to go so we can send them there after login 51 | store.commit(commitTypes.SET_REDIRECT, { name: to.name, params: to.params }); 52 | next({ name: "account-login" }); 53 | return; 54 | } 55 | 56 | // user doesn't have the correct role 57 | if (!to.meta.access.includes(store.state.user.role)) { 58 | next({ name: "denied" }); 59 | return; 60 | } 61 | 62 | // correct roles, continue 63 | next(); 64 | }); 65 | 66 | // create the main Vue instance 67 | new Vue({ 68 | router, 69 | store, 70 | vuetify, 71 | render: (h) => h(App), 72 | }).$mount("#app"); 73 | 74 | // GLOBAL ERROR LOGGING AND HANDLING 75 | // this sends client-side errors to the server for logging in the database 76 | 77 | // don't send errors to the API in development 78 | if (process.env.NODE_ENV === "development") { 79 | console.log("Error recording disabled for development"); 80 | return; 81 | } 82 | 83 | // Vue errors 84 | Vue.config.errorHandler = (err, vm, info) => { 85 | const e = { 86 | error: JSON.parse(JSON.stringify(err, Object.getOwnPropertyNames(err))), 87 | vm: vm ? { 88 | name: vm.name, 89 | route: vm.$router.currentRoute.fullPath, 90 | tag: vm.$vnode.tag, 91 | } : {}, 92 | info: info, 93 | }; 94 | 95 | const logInfo = { source: "Vue", user: store.state.user, data: e }; 96 | 97 | try { 98 | api.log(logInfo); 99 | } catch (x) { 100 | console.log(logInfo); 101 | } 102 | 103 | return false; 104 | }; 105 | 106 | // global JS error logging 107 | window.addEventListener("error", (err) => { 108 | const e = { 109 | error: JSON.parse(JSON.stringify(err, Object.getOwnPropertyNames(err))), 110 | }; 111 | 112 | const logInfo = { source: "Global Errors", user: store.state.user, data: e }; 113 | 114 | try { 115 | api.log(logInfo); 116 | } catch (x) { 117 | console.log(logInfo); 118 | } 119 | }); 120 | 121 | // unhandled Promise rejection error logging 122 | window.addEventListener("unhandledrejection", (err) => { 123 | const e = { 124 | error: JSON.parse(JSON.stringify(err, Object.getOwnPropertyNames(err))), 125 | }; 126 | 127 | const logInfo = { source: "Unhandled Rejections", user: store.state.user, data: e }; 128 | 129 | try { 130 | api.log(logInfo); 131 | } catch (x) { 132 | console.log(logInfo); 133 | } 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /VueApp/src/plugins/vuetify.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import Vuetify from "vuetify/lib"; 3 | 4 | Vue.use(Vuetify); 5 | 6 | export default new Vuetify({ 7 | theme: { 8 | options: { 9 | // customProperties: true, 10 | }, 11 | themes: { 12 | light: { 13 | primary: "#00A7CB", 14 | secondary: "#162850", 15 | accent: "#f8d277", 16 | error: "#FF5252", 17 | info: "#2196F3", 18 | success: "#4CAF50", 19 | warning: "#FFC107", 20 | owlGrey: "#a5a5a5", 21 | lightGrey: "#f2f2f2", 22 | darkGrey: "#4f4f4f", 23 | }, 24 | }, 25 | }, 26 | icons: { 27 | iconfont: "fa", 28 | values: { 29 | // sort: "far fa-sort-up", 30 | }, 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /VueApp/src/router/router.js: -------------------------------------------------------------------------------- 1 | import VueRouter from "vue-router"; 2 | import routes from "./routes"; 3 | 4 | const router = new VueRouter({ 5 | linkActiveClass: "active", 6 | mode: "history", 7 | routes, 8 | base: process.env.BASE_URL, 9 | }); 10 | 11 | export default router; 12 | -------------------------------------------------------------------------------- /VueApp/src/router/routes.js: -------------------------------------------------------------------------------- 1 | // general 2 | import Denied from "@/views/denied.vue"; 3 | 4 | // account 5 | import Register from "@/views/account-register.vue"; 6 | import RegisterConfirm from "@/views/account-confirm.vue"; 7 | import RegisterComplete from "@/views/account-register-complete.vue"; 8 | import Login from "@/views/account-login.vue"; 9 | import PasswordReset from "@/views/account-pwreset.vue"; 10 | import PasswordResetComplete from "@/views/account-pwreset-complete.vue"; 11 | 12 | // sample pages 13 | import Home from "@/views/home.vue"; 14 | import Restricted from "@/views/restricted.vue"; 15 | import Admin from "@/views/admin.vue"; 16 | 17 | // the access property defined which roles are allowed to access a route 18 | // * = anyone/public, or the access property can just be left off 19 | // multiple allowed roles per route can be provided in the array 20 | export default [ 21 | // root 22 | { 23 | path: "/", 24 | redirect: { name: "home" }, 25 | }, 26 | { 27 | path: "/home", 28 | component: Home, 29 | meta: { title: "Home - Public", access: ["*"] }, 30 | name: "home", 31 | }, 32 | { 33 | path: "/denied", 34 | component: Denied, 35 | meta: { title: "Access Denied", access: ["*"] }, 36 | name: "denied", 37 | }, 38 | { 39 | path: "/register", 40 | component: Register, 41 | meta: { title: "Register", access: ["*"] }, 42 | name: "account-register", 43 | }, 44 | { 45 | path: "/confirm/:id", 46 | component: RegisterConfirm, 47 | meta: { title: "Account Confirmation", access: ["*"] }, 48 | name: "account-confirm", 49 | }, 50 | { 51 | path: "/register-complete", 52 | component: RegisterComplete, 53 | meta: { title: "Registration Complete", access: ["*"] }, 54 | name: "account-register-complete", 55 | }, 56 | { 57 | path: "/login", 58 | component: Login, 59 | meta: { title: "Login", access: ["*"] }, 60 | name: "account-login", 61 | }, 62 | { 63 | path: "/reset", 64 | component: PasswordReset, 65 | meta: { title: "Password Reset", access: ["*"] }, 66 | name: "pw-reset", 67 | }, 68 | { 69 | path: "/reset-complete/:id", 70 | component: PasswordResetComplete, 71 | meta: { title: "Password Reset", access: ["*"] }, 72 | name: "pw-reset-complete", 73 | }, 74 | { 75 | path: "/admin", 76 | component: Admin, 77 | meta: { title: "Admin - Admin only", access: ["Admin"] }, 78 | name: "admin", 79 | }, 80 | { 81 | path: "/restricted", 82 | component: Restricted, 83 | meta: { title: "Restricted - Logged In users only", access: ["Admin", "Normal"] }, 84 | name: "restricted", 85 | }, 86 | ]; 87 | -------------------------------------------------------------------------------- /VueApp/src/store-types.js: -------------------------------------------------------------------------------- 1 | export const actionTypes = { 2 | APP_LOAD: "appLoad", 3 | SAVE_LOGIN: "saveLogin", 4 | CLEAR_LOGIN: "clearLogin", 5 | CHECK_TOKEN: "checkToken", 6 | }; 7 | 8 | export const commitTypes = { 9 | SET_USER: "setUser", 10 | SET_SETTINGS: "setSettings", 11 | SET_REDIRECT: "setRedirect", 12 | }; 13 | 14 | export const getterTypes = { 15 | }; 16 | -------------------------------------------------------------------------------- /VueApp/src/store.js: -------------------------------------------------------------------------------- 1 | import Vuex from "vuex"; 2 | import Vue from "vue"; 3 | import { actionTypes, commitTypes } from "./store-types"; 4 | import api from "./api"; 5 | 6 | Vue.use(Vuex); 7 | 8 | const defaultUser = () => ({ 9 | id: null, 10 | email: null, 11 | role: null, 12 | name: null, 13 | customInfo: null, 14 | token: null, 15 | firstLogin: false, 16 | }); 17 | 18 | export default new Vuex.Store({ 19 | state: { 20 | user: defaultUser(), 21 | signedIn: false, 22 | config: { 23 | someServiceApiKey: null, 24 | validateEmail: false, 25 | }, 26 | loginRedirect: null, 27 | }, 28 | mutations: { 29 | [commitTypes.SET_USER](state, user) { 30 | Object.assign(state.user, user); 31 | state.signedIn = user.token !== null; 32 | }, 33 | [commitTypes.SET_SETTINGS](state, config) { 34 | state.config = config; 35 | }, 36 | [commitTypes.SET_REDIRECT](state, val) { 37 | state.loginRedirect = val; 38 | }, 39 | }, 40 | actions: { 41 | // initial data load from the server: paths, config info, lists, keys, etc 42 | [actionTypes.APP_LOAD]({ commit }) { 43 | return new Promise((resolve, reject) => { 44 | api.appSettings() 45 | .then(({ data }) => { 46 | commit(commitTypes.SET_SETTINGS, data); 47 | resolve(); 48 | }).catch((e) => { 49 | reject(e); 50 | }); 51 | }); 52 | }, 53 | [actionTypes.SAVE_LOGIN]({ commit, state }, authInfo) { 54 | return new Promise((resolve) => { 55 | // if the user was trying to get to a path send them along 56 | // otherwise set to whatever landing page you like based on their role 57 | // optional redirect for first time users 58 | // TODO: customize for your needs 59 | 60 | let redirect = state.loginRedirect ? state.loginRedirect : { name: "home", params: {} }; 61 | commit(commitTypes.SET_REDIRECT, null); 62 | 63 | if (authInfo.firstLogin) { 64 | redirect = { name: "account-register-complete", params: {} }; 65 | } 66 | 67 | commit(commitTypes.SET_USER, authInfo); 68 | 69 | resolve(redirect); 70 | }); 71 | }, 72 | [actionTypes.CLEAR_LOGIN]({ commit }) { 73 | commit(commitTypes.SET_USER, defaultUser()); 74 | 75 | return api.logout(); 76 | }, 77 | // call the API to see if the JWT cookie exists and is good 78 | // gets a fresh cookie back and the latest user info 79 | [actionTypes.CHECK_TOKEN]({ dispatch }) { 80 | return api.jwtCheck().then((r) => { 81 | if (r.status === 200) { 82 | // store the fresh user info 83 | return dispatch(actionTypes.SAVE_LOGIN, r.data); 84 | } 85 | 86 | // check failed, clear the user, the nav guard will send them to login 87 | return dispatch(actionTypes.CLEAR_LOGIN); 88 | }).catch(() => dispatch(actionTypes.CLEAR_LOGIN)); 89 | }, 90 | }, 91 | }); 92 | -------------------------------------------------------------------------------- /VueApp/src/styles/variables.scss: -------------------------------------------------------------------------------- 1 | // Vuetify theme customizations, also defined in vuetify.js 2 | $primary: #00A7CB; 3 | $secondary: #162850; 4 | $secondary-light: lighten(#162850, 20%); 5 | $accent: #f8d277; 6 | $error: #FF5252; 7 | $info: #2196F3; 8 | $success: #4CAF50; 9 | $warning: #FFC107; 10 | $link: #00A7CB; 11 | //$navbar-height: 128px; 12 | $lightGrey: #f2f2f2; 13 | $darkGrey: #4f4f4f; 14 | 15 | // Fonts 16 | 17 | $body-font-family: Roboto, sans-serif !default; 18 | $font-size-root: 14px; 19 | $icon-size: 18px !default; 20 | $icon-size-dense: 16px !default; 21 | 22 | // Grid 23 | 24 | $grid-breakpoints: ( 25 | 'xs': 0, 26 | 'sm': 600px, 27 | 'md': 960px, 28 | 'lg': 1280px, 29 | 'xl': 1920px 30 | ); 31 | 32 | $container-max-widths: ( 33 | 'md': 960px, 34 | 'lg': 1280px, 35 | 'xl': 1920px 36 | ); 37 | 38 | // Type 39 | 40 | //$headings: ( 41 | // 'h1': ( 42 | // 'size': 36px, 43 | // 'line-height':2em, 44 | // 'weight': 700, 45 | // ), 46 | // 'h2': ( 47 | // 'size': 24px, 48 | // 'line-height': 2em, 49 | // 'weight': 700, 50 | // ), 51 | // 'h3': ( 52 | // 'size': 20px, 53 | // 'line-height': 2em, 54 | // 'weight': 700, 55 | // ), 56 | // 'h4': ( 57 | // 'size': 16px, 58 | // 'line-height': 2em, 59 | // 'weight': 700, 60 | // ), 61 | // 'h5': ( 62 | // 'size': 16px, 63 | // 'line-height': 2em, 64 | // 'weight': 700, 65 | // ), 66 | // 'h6': ( 67 | // 'size': 16px, 68 | // 'line-height': 2em, 69 | // 'weight': 400, 70 | // ) 71 | //); 72 | 73 | // https://github.com/vuetifyjs/vuetify/blob/master/packages/vuetify/src/styles/settings/_light.scss 74 | 75 | $material-light: ( 76 | 'text': ( 77 | 'primary': $darkGrey 78 | ), 79 | 'text-color': $darkGrey, 80 | 'primary-text-percent': 1.0 81 | ); 82 | 83 | // Tables 84 | //$data-table-regular-header-font-size: $font-size-root; 85 | //$data-table-regular-row-font-size: $font-size-root; 86 | -------------------------------------------------------------------------------- /VueApp/src/styles/vueapp.scss: -------------------------------------------------------------------------------- 1 | @import "~@fortawesome/fontawesome-free/css/all.css"; 2 | // ======== Custom styles go in here 3 | 4 | .clickable { 5 | cursor: pointer; 6 | } 7 | 8 | // Helpers for Vuetify 9 | 10 | .v-btn.all-case { 11 | text-transform: none; 12 | } 13 | 14 | h1 { 15 | font-size: map-deep-get($headings, 'h1', 'size'); 16 | line-height: map-deep-get($headings, 'h1', 'line-height'); 17 | font-weight: map-deep-get($headings, 'h1', 'weight'); 18 | letter-spacing: map-deep-get($headings, 'h1', 'letter-spacing'); 19 | } 20 | h2 { 21 | font-size: map-deep-get($headings, 'h2', 'size'); 22 | line-height: map-deep-get($headings, 'h2', 'line-height'); 23 | font-weight: map-deep-get($headings, 'h2', 'weight'); 24 | letter-spacing: map-deep-get($headings, 'h2', 'letter-spacing'); 25 | } 26 | h3 { 27 | font-size: map-deep-get($headings, 'h3', 'size'); 28 | line-height: map-deep-get($headings, 'h3', 'line-height'); 29 | font-weight: map-deep-get($headings, 'h3', 'weight'); 30 | letter-spacing: map-deep-get($headings, 'h3', 'letter-spacing'); 31 | } 32 | h4 { 33 | font-size: map-deep-get($headings, 'h4', 'size'); 34 | line-height: map-deep-get($headings, 'h4', 'line-height'); 35 | font-weight: map-deep-get($headings, 'h4', 'weight'); 36 | letter-spacing: map-deep-get($headings, 'h4', 'letter-spacing'); 37 | } 38 | h5 { 39 | font-size: map-deep-get($headings, 'h5', 'size'); 40 | line-height: map-deep-get($headings, 'h5', 'line-height'); 41 | font-weight: map-deep-get($headings, 'h5', 'weight'); 42 | letter-spacing: map-deep-get($headings, 'h5', 'letter-spacing'); 43 | } 44 | h6 { 45 | font-size: map-deep-get($headings, 'h6', 'size'); 46 | line-height: map-deep-get($headings, 'h6', 'line-height'); 47 | font-weight: map-deep-get($headings, 'h6', 'weight'); 48 | letter-spacing: map-deep-get($headings, 'h6', 'letter-spacing'); 49 | } 50 | 51 | .link--text { 52 | color: $link; 53 | } 54 | 55 | // FORMS - required field indicators for labels 56 | 57 | .req:not(.v-input--radio-group) { 58 | label::after { 59 | content: "*"; 60 | color: $error !important; 61 | } 62 | } 63 | 64 | .req.v-input--radio-group { 65 | .v-input__prepend-outer::after { 66 | content: "*"; 67 | color: $error !important; 68 | } 69 | } 70 | 71 | .req-label::after { 72 | content: "*"; 73 | color: $error !important; 74 | } 75 | 76 | // optional, moves the radio group labels up 77 | .v-input--radio-group--row { 78 | flex-direction: column; 79 | margin-top: 0; 80 | 81 | .v-input__prepend-outer { 82 | font-size: 12px; 83 | margin-bottom: 8px; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /VueApp/src/validation.js: -------------------------------------------------------------------------------- 1 | import { extend } from "vee-validate"; 2 | // eslint-disable-next-line camelcase 3 | import { required, email, min_value, min } from "vee-validate/dist/rules"; 4 | 5 | // See https://github.com/logaretm/vee-validate 6 | 7 | extend("required", { 8 | ...required, 9 | message: "Required", 10 | }); 11 | 12 | extend("min_value", { 13 | // eslint-disable-next-line camelcase 14 | ...min_value, 15 | message: "At least one", 16 | }); 17 | 18 | extend("min", { 19 | ...min, 20 | message: "Too short", 21 | }); 22 | 23 | extend("url", { 24 | validate: (str) => { 25 | const pattern = new RegExp("^(https?:\\/\\/)?" + // protocol 26 | "((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // domain name 27 | "((\\d{1,3}\\.){3}\\d{1,3}))" + // OR ip (v4) address 28 | "(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" + // port and path 29 | "(\\?[;&a-z\\d%_.~+=-]*)?" + // query string 30 | "(\\#[-a-z\\d_]*)?$", "i"); // fragment locator 31 | return !!pattern.test(str); 32 | }, 33 | message: "This is not a valid URL", 34 | }); 35 | 36 | extend("email", { 37 | ...email, 38 | message: "Invalid email address", 39 | }); 40 | -------------------------------------------------------------------------------- /VueApp/src/views/account-confirm.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 52 | 53 | 57 | -------------------------------------------------------------------------------- /VueApp/src/views/account-login.vue: -------------------------------------------------------------------------------- 1 | 93 | 145 | 147 | -------------------------------------------------------------------------------- /VueApp/src/views/account-pwreset-complete.vue: -------------------------------------------------------------------------------- 1 | 46 | 91 | -------------------------------------------------------------------------------- /VueApp/src/views/account-pwreset.vue: -------------------------------------------------------------------------------- 1 | 45 | 79 | -------------------------------------------------------------------------------- /VueApp/src/views/account-register-complete.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | 19 | -------------------------------------------------------------------------------- /VueApp/src/views/account-register.vue: -------------------------------------------------------------------------------- 1 | 63 | 117 | 119 | -------------------------------------------------------------------------------- /VueApp/src/views/admin.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 27 | 28 | 31 | -------------------------------------------------------------------------------- /VueApp/src/views/denied.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | -------------------------------------------------------------------------------- /VueApp/src/views/home.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 37 | 38 | 41 | -------------------------------------------------------------------------------- /VueApp/src/views/restricted.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 27 | 28 | 31 | -------------------------------------------------------------------------------- /VueApp/src/vue-numeric-directive.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // https://gist.github.com/jonasraoni/9dea65e270495158393f54e36ee6b78d 3 | //+ Jonas Raoni Soares Silva 4 | //@ http://raoni.org 5 | 6 | export default class NumericDirective { 7 | constructor(input, binding) { 8 | Object.assign(this, { input, binding }); 9 | input.addEventListener("keydown", this); 10 | input.addEventListener("change", this); 11 | } 12 | 13 | static install(Vue) { 14 | Vue.directive("decimal", this.directive); 15 | Vue.directive("integer", this.directive); 16 | } 17 | 18 | static directive = { 19 | bind(el, binding) { 20 | el = el.querySelector("input"); 21 | if (el) { 22 | return new NumericDirective(el, binding); 23 | } 24 | }, 25 | } 26 | 27 | handleEvent(event) { 28 | this[event.type](event); 29 | } 30 | 31 | keydown(event) { 32 | const { target, key, keyCode, ctrlKey } = event; 33 | if (!( 34 | (key >= "0" && key <= "9") || 35 | ( 36 | ((key === "." && this.binding.name === "decimal") || (key === "-" && !this.binding.modifiers.unsigned)) && 37 | !~target.value.indexOf(key) 38 | ) || 39 | [ 40 | "Delete", "Backspace", "Tab", "Esc", "Escape", "Enter", 41 | "Home", "End", "PageUp", "PageDown", "Del", "Delete", 42 | "Left", "ArrowLeft", "Right", "ArrowRight", "Insert", 43 | "Up", "ArrowUp", "Down", "ArrowDown", 44 | ].includes(key) || 45 | // ctrl+a, c, x, v 46 | (ctrlKey && [65, 67, 86, 88].includes(keyCode)) 47 | )) { 48 | event.preventDefault(); 49 | } 50 | } 51 | 52 | change({ target }) { 53 | const isDecimal = this.binding.name === "decimal"; 54 | let value = target.value; 55 | if (!value) { 56 | return; 57 | } 58 | const isNegative = /^\s*-/.test(value) && !this.binding.modifiers.unsigned; 59 | value = value.replace(isDecimal ? /[^\d,.]/g : /\D/g, ""); 60 | if (isDecimal) { 61 | const pieces = value.split(/[,.]/); 62 | const decimal = pieces.pop().replace(/0+$/, ""); 63 | if (pieces.length) { 64 | value = `${pieces.join("") || (decimal ? "0" : "")}${decimal ? `.${decimal}` : ""}`; 65 | } 66 | } 67 | value = value.replace(/^(?:0(?!\b))+/, ""); 68 | if (value && isNegative) { 69 | value = `-${value}`; 70 | } 71 | if (target.value !== value) { 72 | target.value = value; 73 | const event = document.createEvent("UIEvent"); 74 | event.initEvent("input", true, false, window, 0); 75 | target.dispatchEvent(event); 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /VueApp/vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | devServer: { 3 | progress: false, 4 | }, 5 | css: { 6 | sourceMap: true, 7 | loaderOptions: { 8 | sass: { 9 | implementation: require("sass"), 10 | }, 11 | }, 12 | }, 13 | configureWebpack: { 14 | devtool: "eval-source-map", 15 | }, 16 | transpileDependencies: [ 17 | "vuetify", 18 | ], 19 | }; 20 | -------------------------------------------------------------------------------- /VueCoreJwt.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | VueApp\ 6 | $(DefaultItemExcludes);$(SpaRoot)node_modules\** 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | web.config 26 | true 27 | 28 | 29 | false 30 | 31 | 32 | 33 | 34 | 35 | appsettings.json 36 | true 37 | 38 | 39 | false 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | %(DistFiles.Identity) 48 | PreserveNewest 49 | true 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /VueCoreJwt.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.30204.135 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VueCoreJwt", "VueCoreJwt.csproj", "{634893F5-E251-42A5-AA87-AC7FD738DF9C}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {634893F5-E251-42A5-AA87-AC7FD738DF9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {634893F5-E251-42A5-AA87-AC7FD738DF9C}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {634893F5-E251-42A5-AA87-AC7FD738DF9C}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {634893F5-E251-42A5-AA87-AC7FD738DF9C}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {790BA534-3389-43B7-8642-C6E6B20C7BCF} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "App": { 3 | "SiteUrl": "http://localhost:8080" 4 | }, 5 | "Serilog": { 6 | "MinimumLevel": "Debug" 7 | } 8 | } -------------------------------------------------------------------------------- /appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "AllowedHosts": "*", 3 | "Kestrel": { 4 | "Endpoints": { 5 | "Http": { 6 | "Url": "http://localhost:5000" 7 | }, 8 | "Https": { 9 | "Url": "https://localhost:5001" 10 | } 11 | } 12 | }, 13 | "App": { 14 | "JwtKey": "NpNy69QmbkG1TW41JMqEw5cR135W7sRkESVx1n4x", 15 | "JwtDays": 7, 16 | "SiteUrl": "https://test.com", 17 | "SomeServiceApiKey": "foofoofoo", 18 | "EmailServiceApiKey": "barbarbar", 19 | "ValidateEmail": true, 20 | "CookieName": ".AspNetCore.Application.Id" 21 | }, 22 | "Serilog": { 23 | "Using": [ "Serilog.Sinks.Console" ], 24 | "MinimumLevel": "Error", 25 | "WriteTo": [ 26 | { 27 | "Name": "Console", 28 | "Args": {} 29 | } 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /web.Development.config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /web.config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | --------------------------------------------------------------------------------