Build auth API

This commit is contained in:
Robert Marshall 2020-04-12 13:50:39 +01:00
commit dafe603a06
43 changed files with 1153 additions and 0 deletions

View file

@ -0,0 +1,67 @@
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Robware.Api.Auth.Controllers;
using Robware.Api.Auth.Models;
using Robware.Auth;
using Xunit;
namespace Robware.Api.Auth.Tests {
public class AuthControllerTests {
private class TestUser : User {
public TestUser(string username, string password) {
Username = username;
Password = password;
}
}
[Fact]
public async Task Authenticate_WithSuccessfulLoginRequest_ReturnsUser() {
var logger = Substitute.For<ILogger<AuthController>>();
var authenticator = Substitute.For<IAuthenticator>();
authenticator.Authenticate("username", "password").Returns((AuthenticationResult.Success, new TestUser("username", "password")));
var request = new LoginRequest {
Username = "username",
Password = "password"
};
var expectation = new TestUser("username", "password");
var controller = new AuthController(logger, authenticator);
(await controller.Authenticate(request)).Value.Should().BeEquivalentTo(expectation);
}
[Fact]
public async Task Authenticate_WithIncorrectPassword_Returns401() {
var logger = Substitute.For<ILogger<AuthController>>();
var authenticator = Substitute.For<IAuthenticator>();
authenticator.Authenticate("username", "password").Returns((AuthenticationResult.IncorrectPassword, null));
var request = new LoginRequest {
Username = "username",
Password = "password"
};
var controller = new AuthController(logger, authenticator);
(await controller.Authenticate(request)).Result.Should().BeOfType<UnauthorizedResult>();
}
[Fact]
public async Task Authenticate_WithIncorrectPassword_Returns404() {
var logger = Substitute.For<ILogger<AuthController>>();
var authenticator = Substitute.For<IAuthenticator>();
authenticator.Authenticate("username", "password").Returns((AuthenticationResult.NotFound, null));
var request = new LoginRequest {
Username = "username",
Password = "password"
};
var controller = new AuthController(logger, authenticator);
(await controller.Authenticate(request)).Result.Should().BeOfType<NotFoundResult>();
}
}
}

View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="5.10.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.2.0" />
<PackageReference Include="NSubstitute" Version="4.2.1" />
<PackageReference Include="xunit" Version="2.4.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.0" />
<PackageReference Include="coverlet.collector" Version="1.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Robware.Api.Auth\Robware.Api.Auth.csproj" />
</ItemGroup>
</Project>

53
src/Robware.Api.Auth.sln Normal file
View file

@ -0,0 +1,53 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.29613.14
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Robware.Api.Auth", "Robware.Api.Auth\Robware.Api.Auth.csproj", "{D21F1402-6526-4BD7-8ADA-F8DC626D6D5A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Robware.Api.Auth.Tests", "Robware.Api.Auth.Tests\Robware.Api.Auth.Tests.csproj", "{88716F73-0264-44D9-970D-4134C644C7EF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Robware.Auth", "Robware.Auth\Robware.Auth.csproj", "{8740FE72-12D7-4039-9EB3-0417E529A10E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Robware.Auth.Tests", "Robware.Auth.Tests\Robware.Auth.Tests.csproj", "{E229DE31-8DBB-4AED-9461-A04C8DE0F074}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Robware.Data", "Robware.Data\Robware.Data.csproj", "{69989FA2-BEE8-491D-97B9-856D4916D154}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "_build", "..\build\_build.csproj", "{19A36DA9-BFBF-4988-B7C7-4808D6B57246}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{19A36DA9-BFBF-4988-B7C7-4808D6B57246}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{19A36DA9-BFBF-4988-B7C7-4808D6B57246}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D21F1402-6526-4BD7-8ADA-F8DC626D6D5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D21F1402-6526-4BD7-8ADA-F8DC626D6D5A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D21F1402-6526-4BD7-8ADA-F8DC626D6D5A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D21F1402-6526-4BD7-8ADA-F8DC626D6D5A}.Release|Any CPU.Build.0 = Release|Any CPU
{88716F73-0264-44D9-970D-4134C644C7EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{88716F73-0264-44D9-970D-4134C644C7EF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{88716F73-0264-44D9-970D-4134C644C7EF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{88716F73-0264-44D9-970D-4134C644C7EF}.Release|Any CPU.Build.0 = Release|Any CPU
{8740FE72-12D7-4039-9EB3-0417E529A10E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8740FE72-12D7-4039-9EB3-0417E529A10E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8740FE72-12D7-4039-9EB3-0417E529A10E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8740FE72-12D7-4039-9EB3-0417E529A10E}.Release|Any CPU.Build.0 = Release|Any CPU
{E229DE31-8DBB-4AED-9461-A04C8DE0F074}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E229DE31-8DBB-4AED-9461-A04C8DE0F074}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E229DE31-8DBB-4AED-9461-A04C8DE0F074}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E229DE31-8DBB-4AED-9461-A04C8DE0F074}.Release|Any CPU.Build.0 = Release|Any CPU
{69989FA2-BEE8-491D-97B9-856D4916D154}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{69989FA2-BEE8-491D-97B9-856D4916D154}.Debug|Any CPU.Build.0 = Debug|Any CPU
{69989FA2-BEE8-491D-97B9-856D4916D154}.Release|Any CPU.ActiveCfg = Release|Any CPU
{69989FA2-BEE8-491D-97B9-856D4916D154}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {75A89B46-CAE8-45F8-9BEF-3B7A6FD0BC72}
EndGlobalSection
EndGlobal

View file

@ -0,0 +1,35 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Robware.Api.Auth.Models;
using Robware.Auth;
namespace Robware.Api.Auth.Controllers {
[ApiController]
[Route("[controller]")]
public class AuthController : ControllerBase {
private readonly ILogger<AuthController> _logger;
private readonly IAuthenticator _authenticator;
public AuthController(ILogger<AuthController> logger, IAuthenticator authenticator) {
_logger = logger;
_authenticator = authenticator;
}
[HttpPost(nameof(Authenticate))]
public async Task<ActionResult<User>> Authenticate(LoginRequest request) {
var (result, user) = await _authenticator.Authenticate(request.Username, request.Password);
switch (result) {
case AuthenticationResult.Success:
return user;
case AuthenticationResult.NotFound:
return NotFound();
case AuthenticationResult.IncorrectPassword:
return Unauthorized();
default:
throw new ArgumentOutOfRangeException();
}
}
}
}

View file

@ -0,0 +1,6 @@
namespace Robware.Api.Auth.Models {
public class LoginRequest {
public string Username { get; set; }
public string Password { get; set; }
}
}

View file

@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
namespace Robware.Api.Auth {
public class Program {
public static void Main(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder => {
webBuilder.UseStartup<Startup>();
}).Build().Run();
}
}

View file

@ -0,0 +1,28 @@
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:61069",
"sslPort": 44309
}
},
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"Robware.Api.Auth": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "weatherforecast",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:5001;http://localhost:5000"
}
}
}

View file

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Robware.Auth\Robware.Auth.csproj" />
<ProjectReference Include="..\Robware.Data\Robware.Data.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,44 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Robware.Auth;
using Robware.Data;
namespace Robware.Api.Auth {
public class Startup {
public Startup(IConfiguration configuration) {
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services) {
services.AddControllers();
services.AddSingleton<ICryptographyProvider, CryptographyProvider>()
.AddSingleton<IAuthenticator, Authenticator>()
.AddSingleton<IDatabaseProvider>(new MySQLDatabaseProvider(Configuration.GetConnectionString("database")))
.AddSingleton<IUsers, UserRepository>();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) {
if (env.IsDevelopment()) {
app.UseDeveloperExceptionPage();
}
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(endpoints => {
endpoints.MapControllers();
});
}
}
}

View file

@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"ConnectionStrings": {
"database": "Server=localhost;User ID=user;Password=pass;Database=db"
}
}

View file

@ -0,0 +1,20 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"Kestrel": {
"EndPoints": {
"Http": {
"Url": "http://0.0.0.0:5003"
}
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"database": "<DatabaseConnectionString>"
}
}

View file

@ -0,0 +1,50 @@
using System.Threading.Tasks;
using FluentAssertions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Robware.Data;
using Xunit;
namespace Robware.Auth.Tests {
public class AuthenticatorTests {
[Fact]
public async Task Authenticate_ForUserThatExistsWithCorrectPassword_ReturnsOkResultWithUser() {
var users = Substitute.For<IUsers>();
var crypto = Substitute.For<ICryptographyProvider>();
crypto.Encrypt("password").Returns("password");
var user = new TestUser("test", "password");
users.GetByEmail("test").Returns(user);
var auth = new Authenticator(users, crypto);
(await auth.Authenticate("test", "password")).Should().BeEquivalentTo((AuthenticationResult.Success, user));
}
[Fact]
public async Task Authenticate_ForUserThatExistsWithIncorrectPassword_ReturnsIncorrectPassword() {
var users = Substitute.For<IUsers>();
var crypto = Substitute.For<ICryptographyProvider>();
crypto.Encrypt("password").Returns("password");
var user = new TestUser("test", "password");
users.GetByEmail("test").Returns(user);
var auth = new Authenticator(users, crypto);
(await auth.Authenticate("test", "wrong")).Should().BeEquivalentTo((AuthenticationResult.IncorrectPassword, null as User));
}
[Fact]
public async Task Authenticate_ForUserThatDoesntExist_ReturnsNotFound() {
var users = Substitute.For<IUsers>();
users.GetByEmail("test").Throws(new UserNotFoundException(""));
var crypto = Substitute.For<ICryptographyProvider>();
crypto.Encrypt("password").Returns("password");
var auth = new Authenticator(users, crypto);
(await auth.Authenticate("test", "password")).Should().BeEquivalentTo((AuthenticationResult.NotFound, null as User));
}
}
}

View file

@ -0,0 +1,12 @@
using FluentAssertions;
using Xunit;
namespace Robware.Auth.Tests {
public class CryoptographyProviderTests {
[Fact]
public void Encrypt_WithInput_ReturnsHash() {
var provider = new CryptographyProvider();
provider.Encrypt("password").Should().Be("5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8");
}
}
}

View file

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="5.10.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.2.0" />
<PackageReference Include="NSubstitute" Version="4.2.1" />
<PackageReference Include="xunit" Version="2.4.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.0" />
<PackageReference Include="coverlet.collector" Version="1.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Robware.Auth\Robware.Auth.csproj" />
<ProjectReference Include="..\Robware.Data\Robware.Data.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,8 @@
namespace Robware.Auth.Tests {
internal class TestUser : User {
public TestUser(string username, string password) {
Username = username;
Password = password;
}
}
}

View file

@ -0,0 +1,31 @@
using System;
using System.Threading.Tasks;
namespace Robware.Auth {
public class Authenticator : IAuthenticator {
private readonly IUsers _users;
private readonly ICryptographyProvider _crypto;
public Authenticator(IUsers users, ICryptographyProvider crypto) {
_users = users;
_crypto = crypto;
}
public async Task<(AuthenticationResult Result, User User)> Authenticate(string username, string password) {
try {
var user = await _users.GetByEmail(username);
return _crypto.Encrypt(password) == user.Password ? (AuthenticationResult.Success, user) : (AuthenticationResult.IncorrectPassword, null);
}
catch (UserNotFoundException) {
return (AuthenticationResult.NotFound, null);
}
}
}
public enum AuthenticationResult {
Unknown,
Success,
NotFound,
IncorrectPassword
}
}

View file

@ -0,0 +1,19 @@
using System.Security.Cryptography;
using System.Text;
namespace Robware.Auth {
public class CryptographyProvider : ICryptographyProvider {
public string Encrypt(string input) {
using (var sha256 = SHA256.Create()) {
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(input));
var builder = new StringBuilder();
foreach (var b in hash)
builder.Append(b.ToString("x2"));
var hashString = builder.ToString();
return hashString;
}
}
}
}

View file

@ -0,0 +1,7 @@
using System.Threading.Tasks;
namespace Robware.Auth {
public interface IAuthenticator {
Task<(AuthenticationResult Result, User User)> Authenticate(string username, string password);
}
}

View file

@ -0,0 +1,5 @@
namespace Robware.Auth {
public interface ICryptographyProvider {
string Encrypt(string input);
}
}

View file

@ -0,0 +1,7 @@
using System.Threading.Tasks;
namespace Robware.Auth {
public interface IUsers {
Task<User> GetByEmail(string email);
}
}

View file

@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>
</Project>

6
src/Robware.Auth/User.cs Normal file
View file

@ -0,0 +1,6 @@
namespace Robware.Auth {
public class User {
public string Username { get; protected set; }
public string Password { get; protected set; }
}
}

View file

@ -0,0 +1,9 @@
using System;
namespace Robware.Auth {
public class UserNotFoundException : Exception {
public UserNotFoundException(string username) : base("Could not find user " + username) {
}
}
}

View file

@ -0,0 +1,11 @@
using Robware.Auth;
using Robware.Data.States;
namespace Robware.Data {
public class DatabaseUser : User {
public DatabaseUser(UserState state) {
Username = state.User_Email;
Password = state.User_Password;
}
}
}

View file

@ -0,0 +1,7 @@
using System.Data;
namespace Robware.Data {
public interface IDatabaseProvider {
IDbConnection NewConnection();
}
}

View file

@ -0,0 +1,12 @@
using System.Data;
using MySql.Data.MySqlClient;
namespace Robware.Data {
public class MySQLDatabaseProvider : IDatabaseProvider {
private readonly string _connectionString;
public MySQLDatabaseProvider(string connectionString) => _connectionString = connectionString;
public IDbConnection NewConnection() => new MySqlConnection(_connectionString);
}
}

View file

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.0.35" />
<PackageReference Include="MySqlConnector" Version="0.63.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Robware.Auth\Robware.Auth.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,10 @@
namespace Robware.Data.States {
public class UserState {
public string User_Id { get; set; }
public string User_Email { get; set; }
public string User_Password { get; set; }
public string User_Created { get; set; }
public string User_Deleted { get; set; }
public string Group_Id { get; set; }
}
}

View file

@ -0,0 +1,29 @@
using System.Linq;
using System.Threading.Tasks;
using Dapper;
using Robware.Auth;
using Robware.Data.States;
namespace Robware.Data {
public class UserRepository : IUsers {
private readonly IDatabaseProvider _dbProvider;
public UserRepository(IDatabaseProvider dbProvider) {
_dbProvider = dbProvider;
}
public async Task<User> GetByEmail(string email) {
const string query = "SELECT * FROM users WHERE user_email=@email";
using (var connection = _dbProvider.NewConnection()) {
connection.Open();
var result = await connection.QueryAsync<UserState>(query, new { email });
if (!result.Any())
throw new UserNotFoundException(email);
return new DatabaseUser(result.Single());
}
}
}
}