diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs new file mode 100644 index 000000000..19903e2f0 --- /dev/null +++ b/API/Controllers/AccountController.cs @@ -0,0 +1,82 @@ +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using API.Data; +using API.DTOs; +using API.Entities; +using API.Interfaces; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Controllers +{ + public class AccountController : BaseApiController + { + private readonly DataContext _context; + private readonly ITokenService _tokenService; + private readonly ILogger _logger; + + public AccountController(DataContext context, ITokenService tokenService, ILogger logger) + { + _context = context; + _tokenService = tokenService; + _logger = logger; + } + + [HttpPost("register")] + public async Task> Register(RegisterDto registerDto) + { + _logger.LogInformation("Username: " + registerDto.Password); + if (await UserExists(registerDto.Username)) + { + return BadRequest("Username is taken."); + } + + using var hmac = new HMACSHA512(); + var user = new AppUser + { + UserName = registerDto.Username.ToLower(), + PasswordHash = hmac.ComputeHash(Encoding.UTF8.GetBytes(registerDto.Password)), + PasswordSalt = hmac.Key + }; + + _context.Users.Add(user); + await _context.SaveChangesAsync(); + + return new UserDto() + { + Username = user.UserName, + Token = _tokenService.CreateToken(user) + }; + } + + [HttpPost("login")] + public async Task> Login(LoginDto loginDto) + { + var user = await _context.Users.SingleOrDefaultAsync(x => x.UserName == loginDto.Username.ToLower()); + + if (user == null) return Unauthorized("Invalid username"); + + using var hmac = new HMACSHA512(user.PasswordSalt); + + var computedHash = hmac.ComputeHash(Encoding.UTF8.GetBytes(loginDto.Password)); + + for (int i = 0; i < computedHash.Length; i++) + { + if (computedHash[i] != user.PasswordHash[i]) return Unauthorized("Invalid password"); + } + + return new UserDto() + { + Username = user.UserName, + Token = _tokenService.CreateToken(user) + }; + } + + private async Task UserExists(string username) + { + return await _context.Users.AnyAsync(user => user.UserName == username.ToLower()); + } + } +} \ No newline at end of file diff --git a/API/Controllers/BaseApiController.cs b/API/Controllers/BaseApiController.cs new file mode 100644 index 000000000..b08265db8 --- /dev/null +++ b/API/Controllers/BaseApiController.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class BaseApiController : ControllerBase + { + public BaseApiController() + { + } + } +} \ No newline at end of file diff --git a/API/DTOs/LoginDto.cs b/API/DTOs/LoginDto.cs new file mode 100644 index 000000000..9983415e0 --- /dev/null +++ b/API/DTOs/LoginDto.cs @@ -0,0 +1,8 @@ +namespace API.DTOs +{ + public class LoginDto + { + public string Username { get; set; } + public string Password { get; set; } + } +} \ No newline at end of file diff --git a/API/DTOs/RegisterDto.cs b/API/DTOs/RegisterDto.cs new file mode 100644 index 000000000..9110f298d --- /dev/null +++ b/API/DTOs/RegisterDto.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs +{ + public class RegisterDto + { + [Required] + public string Username { get; set; } + [Required] + [StringLength(8, MinimumLength = 4)] + public string Password { get; set; } + } +} \ No newline at end of file diff --git a/API/DTOs/UserDto.cs b/API/DTOs/UserDto.cs new file mode 100644 index 000000000..15de1b8d9 --- /dev/null +++ b/API/DTOs/UserDto.cs @@ -0,0 +1,8 @@ +namespace API.DTOs +{ + public class UserDto + { + public string Username { get; set; } + public string Token { get; set; } + } +} \ No newline at end of file diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs new file mode 100644 index 000000000..bca4e24be --- /dev/null +++ b/API/Data/DataContext.cs @@ -0,0 +1,15 @@ +using API.Entities; +using Microsoft.EntityFrameworkCore; + +namespace API.Data +{ + public class DataContext : DbContext + { + public DataContext(DbContextOptions options) : base(options) + { + + } + + public DbSet Users { get; set; } + } +} \ No newline at end of file diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs new file mode 100644 index 000000000..3478b74f0 --- /dev/null +++ b/API/Entities/AppUser.cs @@ -0,0 +1,28 @@ +using System; +using System.ComponentModel.DataAnnotations; +using API.Entities.Interfaces; + + +namespace API.Entities +{ + public class AppUser : IHasConcurrencyToken + { + public int Id { get; set; } + public string UserName { get; set; } + public byte[] PasswordHash { get; set; } + public byte[] PasswordSalt { get; set; } + public DateTime Created { get; set; } = DateTime.Now; + public DateTime LastActive { get; set; } + + [ConcurrencyCheck] + public uint RowVersion { get; set; } + + public void OnSavingChanges() + { + RowVersion++; + } + + + + } +} \ No newline at end of file diff --git a/API/Entities/Interfaces/IHasConcurrencyToken.cs b/API/Entities/Interfaces/IHasConcurrencyToken.cs new file mode 100644 index 000000000..9372f1eb7 --- /dev/null +++ b/API/Entities/Interfaces/IHasConcurrencyToken.cs @@ -0,0 +1,19 @@ +namespace API.Entities.Interfaces +{ + /// + /// An interface abstracting an entity that has a concurrency token. + /// + public interface IHasConcurrencyToken + { + /// + /// Gets the version of this row. Acts as a concurrency token. + /// + uint RowVersion { get; } + + /// + /// Called when saving changes to this entity. + /// + void OnSavingChanges(); + + } +} \ No newline at end of file diff --git a/API/Errors/ApiException.cs b/API/Errors/ApiException.cs new file mode 100644 index 000000000..3f026bb64 --- /dev/null +++ b/API/Errors/ApiException.cs @@ -0,0 +1,16 @@ +namespace API.Errors +{ + public class ApiException + { + public int Status { get; set; } + public string Message { get; set; } + public string Details { get; set; } + + public ApiException(int status, string message = null, string details = null) + { + Status = status; + Message = message; + Details = details; + } + } +} \ No newline at end of file diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs new file mode 100644 index 000000000..778eb8c6e --- /dev/null +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -0,0 +1,23 @@ +using API.Data; +using API.Interfaces; +using API.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace API.Extensions +{ + public static class ApplicationServiceExtensions + { + public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration config) + { + services.AddScoped(); + services.AddDbContext(options => + { + options.UseSqlite(config.GetConnectionString("DefaultConnection")); + }); + + return services; + } + } +} \ No newline at end of file diff --git a/API/Extensions/IdentityServiceExtensions.cs b/API/Extensions/IdentityServiceExtensions.cs new file mode 100644 index 000000000..386efc0f2 --- /dev/null +++ b/API/Extensions/IdentityServiceExtensions.cs @@ -0,0 +1,28 @@ +using System.Collections; +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; + +namespace API.Extensions +{ + public static class IdentityServiceExtensions + { + public static IServiceCollection AddIdentityServices(this IServiceCollection services, IConfiguration config) + { + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters() + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"])), + ValidateIssuer = false, + ValidateAudience = false + }; + }); + return services; + } + } +} \ No newline at end of file diff --git a/API/Interfaces/ITokenService.cs b/API/Interfaces/ITokenService.cs new file mode 100644 index 000000000..e721d9ade --- /dev/null +++ b/API/Interfaces/ITokenService.cs @@ -0,0 +1,9 @@ +using API.Entities; + +namespace API.Interfaces +{ + public interface ITokenService + { + string CreateToken(AppUser user); + } +} \ No newline at end of file diff --git a/API/Middleware/ExceptionMiddleware.cs b/API/Middleware/ExceptionMiddleware.cs new file mode 100644 index 000000000..a946baff9 --- /dev/null +++ b/API/Middleware/ExceptionMiddleware.cs @@ -0,0 +1,55 @@ +using System; +using System.Net; +using System.Text.Json; +using System.Threading.Tasks; +using API.Errors; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace API.Middleware +{ + public class ExceptionMiddleware + { + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly IHostEnvironment _env; + + public ExceptionMiddleware(RequestDelegate next, ILogger logger, IHostEnvironment env) + { + _next = next; + _logger = logger; + _env = env; + } + + public async Task InvokeAsync(HttpContext context) + { + _logger.LogError("The middleware called"); + try + { + await _next(context); // downstream middlewares or http call + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + context.Response.ContentType = "application/json"; + context.Response.StatusCode = (int) HttpStatusCode.InternalServerError; + + var response = _env.IsDevelopment() + ? new ApiException(context.Response.StatusCode, ex.Message, ex.StackTrace?.ToString()) + : new ApiException(context.Response.StatusCode, "Internal Server Error"); + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = + JsonNamingPolicy.CamelCase + }; + + var json = JsonSerializer.Serialize(response, options); + + await context.Response.WriteAsync(json); + + } + } + } +} \ No newline at end of file diff --git a/API/Services/TokenService.cs b/API/Services/TokenService.cs new file mode 100644 index 000000000..195af02d2 --- /dev/null +++ b/API/Services/TokenService.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using API.Entities; +using API.Interfaces; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; +using JwtRegisteredClaimNames = Microsoft.IdentityModel.JsonWebTokens.JwtRegisteredClaimNames; + + +namespace API.Services +{ + public class TokenService : ITokenService + { + private readonly SymmetricSecurityKey _key; + + public TokenService(IConfiguration config) + { + _key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"])); + } + + public string CreateToken(AppUser user) + { + var claims = new List + { + new Claim(JwtRegisteredClaimNames.NameId, user.UserName) + }; + + var creds = new SigningCredentials(_key, SecurityAlgorithms.HmacSha512Signature); + + var tokenDescriptor = new SecurityTokenDescriptor() + { + Subject = new ClaimsIdentity(claims), + Expires = DateTime.Now.AddDays(7), + SigningCredentials = creds + }; + + var tokenHandler = new JwtSecurityTokenHandler(); + var token = tokenHandler.CreateToken(tokenDescriptor); + + return tokenHandler.WriteToken(token); + } + } +} \ No newline at end of file diff --git a/API/Startup.cs b/API/Startup.cs index 622d31cb2..f05530565 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -2,6 +2,8 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Extensions; +using API.Middleware; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.HttpsPolicy; @@ -16,18 +18,21 @@ namespace API { public class Startup { - public Startup(IConfiguration configuration) - { - Configuration = configuration; - } + private readonly IConfiguration _config; - public IConfiguration Configuration { get; } + public Startup(IConfiguration config) + { + _config = config; + } // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { + services.AddApplicationServices(_config); services.AddControllers(); + services.AddCors(); + services.AddIdentityServices(_config); services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo { Title = "API", Version = "v1" }); @@ -37,9 +42,11 @@ namespace API // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { + app.UseMiddleware(); + if (env.IsDevelopment()) { - app.UseDeveloperExceptionPage(); + //app.UseDeveloperExceptionPage(); app.UseSwagger(); app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "API v1")); } @@ -47,6 +54,11 @@ namespace API app.UseHttpsRedirection(); app.UseRouting(); + + // Ordering is important. Cors, authentication, authorization + app.UseCors(policy => policy.AllowAnyHeader().AllowAnyMethod().WithOrigins("https://localhost:4200")); + + app.UseAuthentication(); app.UseAuthorization();