diff --git a/Birdmap/Birdmap.csproj b/Birdmap/Birdmap.csproj index 17e9dee..5496272 100644 --- a/Birdmap/Birdmap.csproj +++ b/Birdmap/Birdmap.csproj @@ -10,6 +10,7 @@ + diff --git a/Birdmap/Controllers/AuthController.cs b/Birdmap/Controllers/AuthController.cs new file mode 100644 index 0000000..fa70bc6 --- /dev/null +++ b/Birdmap/Controllers/AuthController.cs @@ -0,0 +1,60 @@ +using Birdmap.Models; +using Birdmap.Services.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; + +namespace Birdmap.Controllers +{ + [Authorize] + [ApiController] + [Route("api/[controller]")] + public class AuthController : ControllerBase + { + private readonly IAuthService _service; + private readonly IConfiguration _configuration; + + public AuthController(IAuthService service, IConfiguration configuration) + { + _service = service; + _configuration = configuration; + } + + [AllowAnonymous] + [HttpPost("authenticate")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + public async Task AuthenticateAsync([FromBody] AuthenticateRequest model) + { + var user = await _service.AuthenticateUserAsync(model.Username, model.Password); + var expires = DateTime.UtcNow.AddHours(2); + var tokenHandler = new JwtSecurityTokenHandler(); + var key = Encoding.ASCII.GetBytes(_configuration["BasicAuth:Secret"]); + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(new Claim[] + { + new Claim(ClaimTypes.Name, user.Name) + }), + Expires = expires, + SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature) + }; + var token = tokenHandler.CreateToken(tokenDescriptor); + var tokenString = tokenHandler.WriteToken(token); + + return Ok( + new + { + Name = user.Name, + Token = tokenString, + Expires = expires, + }); + } + } +} diff --git a/Birdmap/Controllers/WeatherForecastController.cs b/Birdmap/Controllers/WeatherForecastController.cs index cc49ec1..0572181 100644 --- a/Birdmap/Controllers/WeatherForecastController.cs +++ b/Birdmap/Controllers/WeatherForecastController.cs @@ -2,11 +2,13 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace Birdmap.Controllers { + [Authorize] [ApiController] [Route("[controller]")] public class WeatherForecastController : ControllerBase diff --git a/Birdmap/Exceptions/AuthenticationException.cs b/Birdmap/Exceptions/AuthenticationException.cs new file mode 100644 index 0000000..a1a1a23 --- /dev/null +++ b/Birdmap/Exceptions/AuthenticationException.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Birdmap.Exceptions +{ + public class AuthenticationException : Exception + { + public AuthenticationException() + : base("Username or password is incorrect.") + { + } + } +} diff --git a/Birdmap/Models/AuthenticateRequest.cs b/Birdmap/Models/AuthenticateRequest.cs new file mode 100644 index 0000000..515c0df --- /dev/null +++ b/Birdmap/Models/AuthenticateRequest.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Birdmap.Models +{ + public class AuthenticateRequest + { + [Required(AllowEmptyStrings = false, ErrorMessage = "Username is required.")] + public string Username { get; set; } + + [Required(AllowEmptyStrings = false, ErrorMessage = "Password is required.")] + public string Password { get; set; } + } +} diff --git a/Birdmap/Models/User.cs b/Birdmap/Models/User.cs new file mode 100644 index 0000000..3ac4951 --- /dev/null +++ b/Birdmap/Models/User.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Birdmap.Models +{ + public class User + { + public string Name { get; set; } + public byte[] PasswordHash { get; set; } + public byte[] PasswordSalt { get; set; } + } +} diff --git a/Birdmap/Services/AuthService.cs b/Birdmap/Services/AuthService.cs new file mode 100644 index 0000000..82b77e5 --- /dev/null +++ b/Birdmap/Services/AuthService.cs @@ -0,0 +1,78 @@ +using Birdmap.Models; +using Birdmap.Services.Interfaces; +using Microsoft.Extensions.Configuration; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Authentication; +using System.Text; +using System.Threading.Tasks; + +namespace Birdmap.Services +{ + public class AuthService : IAuthService + { + private readonly IConfiguration _configuration; + + public AuthService(IConfiguration configuration) + { + _configuration = configuration; + } + + public async Task AuthenticateUserAsync(string username, string password) + { + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password)) + throw new ArgumentException("Username or password cannot be null or empty."); + + //var user = await _context.Users.SingleOrDefaultAsync(u => u.Name == username) + var user = await Temp_GetUserAsync(_configuration) + ?? throw new AuthenticationException(); + + if (!VerifyPasswordHash(password, user.PasswordHash, user.PasswordSalt)) + throw new AuthenticationException(); + + return user; + } + + private Task Temp_GetUserAsync(IConfiguration configuration) + { + var name = configuration["BasicAuth:Username"]; + var pass = configuration["BasicAuth:Password"]; + + CreatePasswordHash(pass, out var hash, out var salt); + return Task.FromResult(new User + { + Name = name, + PasswordHash = hash, + PasswordSalt = salt, + }); + } + + private static void CreatePasswordHash(string password, out byte[] passwordHash, out byte[] passwordSalt) + { + if (string.IsNullOrWhiteSpace(password)) throw new ArgumentException("Value cannot be null or empty.", "password"); + + using var hmac = new System.Security.Cryptography.HMACSHA512(); + + passwordSalt = hmac.Key; + passwordHash = hmac.ComputeHash(Encoding.UTF8.GetBytes(password)); + } + + private static bool VerifyPasswordHash(string password, byte[] storedHash, byte[] storedSalt) + { + if (string.IsNullOrWhiteSpace(password)) throw new ArgumentException("Value cannot be null or empty.", "password"); + if (storedHash.Length != 64) throw new ArgumentException("Invalid length of password hash (64 bytes expected).", "passwordHash"); + if (storedSalt.Length != 128) throw new ArgumentException("Invalid length of password salt (128 bytes expected).", "passwordHash"); + + using var hmac = new System.Security.Cryptography.HMACSHA512(storedSalt); + + var computedHash = hmac.ComputeHash(Encoding.UTF8.GetBytes(password)); + for (int i = 0; i < computedHash.Length; i++) + { + if (computedHash[i] != storedHash[i]) return false; + } + + return true; + } + } +} diff --git a/Birdmap/Services/Interfaces/IAuthService.cs b/Birdmap/Services/Interfaces/IAuthService.cs new file mode 100644 index 0000000..11d4b6f --- /dev/null +++ b/Birdmap/Services/Interfaces/IAuthService.cs @@ -0,0 +1,10 @@ +using Birdmap.Models; +using System.Threading.Tasks; + +namespace Birdmap.Services.Interfaces +{ + public interface IAuthService + { + Task AuthenticateUserAsync(string username, string password); + } +} diff --git a/Birdmap/Startup.cs b/Birdmap/Startup.cs index d6e1c5a..e95c385 100644 --- a/Birdmap/Startup.cs +++ b/Birdmap/Startup.cs @@ -1,3 +1,6 @@ +using Birdmap.Services; +using Birdmap.Services.Interfaces; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.HttpsPolicy; @@ -6,6 +9,10 @@ using Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.IdentityModel.Tokens; +using System; +using System.Text; +using System.Text.Json; namespace Birdmap { @@ -21,7 +28,33 @@ namespace Birdmap // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { - services.AddControllersWithViews(); + services.AddControllersWithViews() + .AddJsonOptions(opt => + { + //opt.JsonSerializerOptions.PropertyNamingPolicy = new JsonNamingPolicy() + }); + + services.AddTransient(); + + var key = Encoding.ASCII.GetBytes(Configuration["BasicAuth:Secret"]); + services.AddAuthentication(opt => + { + opt.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + opt.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(opt => + { + // opt.RequireHttpsMetadata = false; + opt.SaveToken = true; + opt.IncludeErrorDetails = true; + opt.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(key), + ValidateIssuer = false, + ValidateAudience = false + }; + }); // In production, the React files will be served from this directory services.AddSpaStaticFiles(configuration => @@ -50,11 +83,12 @@ namespace Birdmap app.UseRouting(); + app.UseAuthorization(); + app.UseAuthentication(); + app.UseEndpoints(endpoints => { - endpoints.MapControllerRoute( - name: "default", - pattern: "{controller}/{action=Index}/{id?}"); + endpoints.MapControllers(); }); app.UseSpa(spa => diff --git a/Birdmap/appsettings.json b/Birdmap/appsettings.json index d9d9a9b..c60ae0a 100644 --- a/Birdmap/appsettings.json +++ b/Birdmap/appsettings.json @@ -6,5 +6,10 @@ "Microsoft.Hosting.Lifetime": "Information" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "BasicAuth": { + "Username": "user", + "Password": "pass", + "Secret": "7vj.3KW.hYE!}4u6" + } }