commit dafe603a06c25226ddd4d3ea9cd29d0a81984b01 Author: Robert Marshall Date: Sun Apr 12 13:50:39 2020 +0100 Build auth API diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..7323df3 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,48 @@ +kind: pipeline +name: default + +clone: + skip_verify: true + +volumes: +- name: output + host: + path: /var/www/Api.Auth + +steps: +- name: build and publish + image: mcr.microsoft.com/dotnet/core/sdk:3.1 + volumes: + - name: output + path: /output + environment: + ConnectionString: + from_secret: ConnectionString + commands: + - chmod +x ./build.sh + - ./build.sh + - sed -i "s//$ConnectionString/g" output/appsettings.json + - cp api.auth.service output/ + - cp -r ./output/* /output +- name: restart service + privileged: true + image: appleboy/drone-ssh + settings: + host: 192.168.1.3 + username: + from_secret: ssh_user + password: + from_secret: ssh_password + script: + - systemctl daemon-reload + - service api.auth restart +- name: notify + image: drillster/drone-email + settings: + host: 192.168.1.3 + skip_verify: true + from: build@robware.uk + when: + status: + - changed + - failure \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f88f151 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,198 @@ +# Remove the line below if you want to inherit .editorconfig settings from higher directories +root = true + +[*] +indent_style = tab + +# C# files +[*.cs] +max_line_length = 180 +# New line preferences +end_of_line = crlf +insert_final_newline = false + +#### .NET Coding Conventions #### + +# Organize usings +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = false + +# this. and Me. preferences +dotnet_style_qualification_for_event = false:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_property = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent + +# Expression-level preferences +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion + +# Field preferences +dotnet_style_readonly_field = true:suggestion + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:suggestion + +#### C# Coding Conventions #### + +# var preferences +csharp_style_var_elsewhere = false:silent +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = false:silent + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_switch_expression = true:suggestion + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_prefer_static_local_function = true:suggestion +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent + +# Code-block preferences +csharp_prefer_braces = false:silent +csharp_prefer_simple_using_statement = true:suggestion + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:silent + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = none +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +[*.{yaml,yml}] +indent_style = space +indent_size = 2 \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1c21fb6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +src/Robware.Api.Auth/appsettings.Development.json filter=clean-config \ No newline at end of file diff --git a/.gitconfig b/.gitconfig new file mode 100644 index 0000000..169b515 --- /dev/null +++ b/.gitconfig @@ -0,0 +1,2 @@ +[filter "clean-config"] + clean = ./clean-config.sh \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3e8285e --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +*.swp +*.*~ +project.lock.json +.DS_Store +*.pyc +nupkg/ + +# Visual Studio Code +.vscode + +# Rider +.idea + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +#build/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +msbuild.log +msbuild.err +msbuild.wrn + +# Visual Studio 2015 +.vs/ + + +#lcov files +lcov.info + +#compiled and minified assets +*.css +*.min.css +*.min.js + +#nodejs +node_modules + +output +.tmp +*.ncrunchsolution +/_NCrunch_Website +*.DotSettings +*.cache diff --git a/.nuke b/.nuke new file mode 100644 index 0000000..c11a315 --- /dev/null +++ b/.nuke @@ -0,0 +1 @@ +src/Robware.Api.Auth.sln \ No newline at end of file diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..a41902e --- /dev/null +++ b/Readme.md @@ -0,0 +1,9 @@ +[![Build Status](https://build.robware.uk/api/badges/Robware/Api.Auth/status.svg)](https://build.robware.uk/Robware/Api.Auth) + +# Auth microservice + +Provides an API to authenticate users. + +## Setup + +After clone, please run `git config --local include.path ../.gitconfig`. This will set up the filters required to ignore local dev config. \ No newline at end of file diff --git a/api.auth.service b/api.auth.service new file mode 100644 index 0000000..aa4a390 --- /dev/null +++ b/api.auth.service @@ -0,0 +1,16 @@ +[Unit] +Description=Robware Auth API + +[Service] +WorkingDirectory=/var/www/Api.Auth +ExecStart=/usr/bin/dotnet /var/www/Api.Auth/Robware.Api.Auth.dll +Restart=always +# Restart service after 10 seconds if the dotnet service crashes: +RestartSec=10 +KillSignal=SIGINT +SyslogIdentifier=Api.Auth +User=www-data +Environment=ASPNETCORE_ENVIRONMENT=Production + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..cb7fbdb --- /dev/null +++ b/build.ps1 @@ -0,0 +1,68 @@ +[CmdletBinding()] +Param( + [Parameter(Position=0,Mandatory=$false,ValueFromRemainingArguments=$true)] + [string[]]$BuildArguments +) + +Write-Output "Windows PowerShell $($Host.Version)" + +Set-StrictMode -Version 2.0; $ErrorActionPreference = "Stop"; $ConfirmPreference = "None"; trap { exit 1 } +$PSScriptRoot = Split-Path $MyInvocation.MyCommand.Path -Parent + +########################################################################### +# CONFIGURATION +########################################################################### + +$BuildProjectFile = "$PSScriptRoot\build\_build.csproj" +$TempDirectory = "$PSScriptRoot\\.tmp" + +$DotNetGlobalFile = "$PSScriptRoot\\global.json" +$DotNetInstallUrl = "https://raw.githubusercontent.com/dotnet/cli/master/scripts/obtain/dotnet-install.ps1" +$DotNetChannel = "Current" + +$env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = 1 +$env:DOTNET_CLI_TELEMETRY_OPTOUT = 1 + +########################################################################### +# EXECUTION +########################################################################### + +function ExecSafe([scriptblock] $cmd) { + & $cmd + if ($LASTEXITCODE) { exit $LASTEXITCODE } +} + +# If global.json exists, load expected version +if (Test-Path $DotNetGlobalFile) { + $DotNetGlobal = $(Get-Content $DotNetGlobalFile | Out-String | ConvertFrom-Json) + if ($DotNetGlobal.PSObject.Properties["sdk"] -and $DotNetGlobal.sdk.PSObject.Properties["version"]) { + $DotNetVersion = $DotNetGlobal.sdk.version + } +} + +# If dotnet is installed locally, and expected version is not set or installation matches the expected version +if ((Get-Command "dotnet" -ErrorAction SilentlyContinue) -ne $null -and ` + (!(Test-Path variable:DotNetVersion) -or $(& dotnet --version) -eq $DotNetVersion)) { + $env:DOTNET_EXE = (Get-Command "dotnet").Path +} +else { + $DotNetDirectory = "$TempDirectory\dotnet-win" + $env:DOTNET_EXE = "$DotNetDirectory\dotnet.exe" + + # Download install script + $DotNetInstallFile = "$TempDirectory\dotnet-install.ps1" + md -force $TempDirectory > $null + (New-Object System.Net.WebClient).DownloadFile($DotNetInstallUrl, $DotNetInstallFile) + + # Install by channel or version + if (!(Test-Path variable:DotNetVersion)) { + ExecSafe { & $DotNetInstallFile -InstallDir $DotNetDirectory -Channel $DotNetChannel -NoPath } + } else { + ExecSafe { & $DotNetInstallFile -InstallDir $DotNetDirectory -Version $DotNetVersion -NoPath } + } +} + +Write-Output "Microsoft (R) .NET Core SDK version $(& $env:DOTNET_EXE --version)" + +ExecSafe { & $env:DOTNET_EXE build $BuildProjectFile /nodeReuse:false } +ExecSafe { & $env:DOTNET_EXE run --project $BuildProjectFile --no-build -- $BuildArguments } diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..6d6e2ad --- /dev/null +++ b/build.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash + +echo $(bash --version 2>&1 | head -n 1) + +set -eo pipefail +SCRIPT_DIR=$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd) + +########################################################################### +# CONFIGURATION +########################################################################### + +BUILD_PROJECT_FILE="$SCRIPT_DIR/build/_build.csproj" +TEMP_DIRECTORY="$SCRIPT_DIR//.tmp" + +DOTNET_GLOBAL_FILE="$SCRIPT_DIR//global.json" +DOTNET_INSTALL_URL="https://raw.githubusercontent.com/dotnet/cli/master/scripts/obtain/dotnet-install.sh" +DOTNET_CHANNEL="Current" + +export DOTNET_CLI_TELEMETRY_OPTOUT=1 +export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1 + +########################################################################### +# EXECUTION +########################################################################### + +function FirstJsonValue { + perl -nle 'print $1 if m{"'$1'": "([^"\-]+)",?}' <<< ${@:2} +} + +# If global.json exists, load expected version +if [ -f "$DOTNET_GLOBAL_FILE" ]; then + DOTNET_VERSION=$(FirstJsonValue "version" $(cat "$DOTNET_GLOBAL_FILE")) + if [ "$DOTNET_VERSION" == "" ]; then + unset DOTNET_VERSION + fi +fi + +# If dotnet is installed locally, and expected version is not set or installation matches the expected version +if [[ -x "$(command -v dotnet)" && (-z ${DOTNET_VERSION+x} || $(dotnet --version) == "$DOTNET_VERSION") ]]; then + export DOTNET_EXE="$(command -v dotnet)" +else + DOTNET_DIRECTORY="$TEMP_DIRECTORY/dotnet-unix" + export DOTNET_EXE="$DOTNET_DIRECTORY/dotnet" + + # Download install script + DOTNET_INSTALL_FILE="$TEMP_DIRECTORY/dotnet-install.sh" + mkdir -p "$TEMP_DIRECTORY" + curl -Lsfo "$DOTNET_INSTALL_FILE" "$DOTNET_INSTALL_URL" + chmod +x "$DOTNET_INSTALL_FILE" + + # Install by channel or version + if [ -z ${DOTNET_VERSION+x} ]; then + "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --channel "$DOTNET_CHANNEL" --no-path + else + "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --version "$DOTNET_VERSION" --no-path + fi +fi + +echo "Microsoft (R) .NET Core SDK version $("$DOTNET_EXE" --version)" + +"$DOTNET_EXE" build "$BUILD_PROJECT_FILE" /nodeReuse:false +"$DOTNET_EXE" run --project "$BUILD_PROJECT_FILE" --no-build -- "$@" diff --git a/build/.editorconfig b/build/.editorconfig new file mode 100644 index 0000000..94be682 --- /dev/null +++ b/build/.editorconfig @@ -0,0 +1,10 @@ +[*.cs] +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_property = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_event = false:warning +dotnet_style_require_accessibility_modifiers = never:warning + +csharp_style_expression_bodied_properties = true:warning +csharp_style_expression_bodied_indexers = true:warning +csharp_style_expression_bodied_accessors = true:warning diff --git a/build/Build.cs b/build/Build.cs new file mode 100644 index 0000000..3fc7b00 --- /dev/null +++ b/build/Build.cs @@ -0,0 +1,62 @@ +using Nuke.Common; +using Nuke.Common.Execution; +using Nuke.Common.IO; +using Nuke.Common.ProjectModel; +using Nuke.Common.Tools.DotNet; +using Nuke.Common.Utilities.Collections; +using static Nuke.Common.IO.FileSystemTasks; +using static Nuke.Common.Tools.DotNet.DotNetTasks; + +[CheckBuildProjectConfigurations] +[UnsetVisualStudioEnvironmentVariables] +class Build : NukeBuild { + + public static int Main() => Execute(x => x.Publish); + + [Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")] + readonly Configuration Configuration = IsLocalBuild ? Configuration.Debug : Configuration.Release; + + [Solution] readonly Solution Solution; + + AbsolutePath SourceDirectory => RootDirectory / "src"; + AbsolutePath OutputDirectory => RootDirectory / "output"; + + Target Clean => _ => _ + .Before(Restore) + .Executes(() => { + SourceDirectory.GlobDirectories("**/bin", "**/obj").ForEach(DeleteDirectory); + EnsureCleanDirectory(OutputDirectory); + }); + + Target Restore => _ => _ + .Executes(() => { + DotNetRestore(s => s + .SetProjectFile(Solution)); + }); + + Target Compile => _ => _ + .DependsOn(Restore) + .Executes(() => { + DotNetBuild(s => s + .SetProjectFile(Solution) + .SetConfiguration(Configuration) + .EnableNoRestore()); + }); + + Target Test => _ => _ + .DependsOn(Compile) + .Executes(() => { + DotNetTest(s => s + .SetProjectFile(Solution) + .EnableNoRestore()); + }); + + Target Publish => _ => _ + .DependsOn(Test) + .Executes(() => { + DotNetPublish(s => s + .SetProject(SourceDirectory / "Robware.Api.Auth/Robware.Api.Auth.csproj") + .SetConfiguration(Configuration) + .SetOutput(OutputDirectory)); + }); +} diff --git a/build/_build.csproj b/build/_build.csproj new file mode 100644 index 0000000..3bc05f7 --- /dev/null +++ b/build/_build.csproj @@ -0,0 +1,34 @@ + + + + Exe + netcoreapp3.1 + false + + False + CS0649;CS0169 + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/clean-config.sh b/clean-config.sh new file mode 100644 index 0000000..581bc42 --- /dev/null +++ b/clean-config.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +sed \ +-e 's/"database": "Server=.*;User ID=.*;Password=.*;Database=.*"/"database": "Server=localhost;User ID=user;Password=pass;Database=db"/g' \ +$1 \ No newline at end of file diff --git a/src/Robware.Api.Auth.Tests/AuthControllerTests.cs b/src/Robware.Api.Auth.Tests/AuthControllerTests.cs new file mode 100644 index 0000000..e906a0c --- /dev/null +++ b/src/Robware.Api.Auth.Tests/AuthControllerTests.cs @@ -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>(); + var authenticator = Substitute.For(); + 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>(); + var authenticator = Substitute.For(); + 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(); + } + + [Fact] + public async Task Authenticate_WithIncorrectPassword_Returns404() { + var logger = Substitute.For>(); + var authenticator = Substitute.For(); + 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(); + } + } +} diff --git a/src/Robware.Api.Auth.Tests/Robware.Api.Auth.Tests.csproj b/src/Robware.Api.Auth.Tests/Robware.Api.Auth.Tests.csproj new file mode 100644 index 0000000..a2e56d7 --- /dev/null +++ b/src/Robware.Api.Auth.Tests/Robware.Api.Auth.Tests.csproj @@ -0,0 +1,22 @@ + + + + netcoreapp3.1 + + false + + + + + + + + + + + + + + + + diff --git a/src/Robware.Api.Auth.sln b/src/Robware.Api.Auth.sln new file mode 100644 index 0000000..c3e6c6e --- /dev/null +++ b/src/Robware.Api.Auth.sln @@ -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 diff --git a/src/Robware.Api.Auth/Controllers/AuthController.cs b/src/Robware.Api.Auth/Controllers/AuthController.cs new file mode 100644 index 0000000..fa3bd1f --- /dev/null +++ b/src/Robware.Api.Auth/Controllers/AuthController.cs @@ -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 _logger; + private readonly IAuthenticator _authenticator; + + public AuthController(ILogger logger, IAuthenticator authenticator) { + _logger = logger; + _authenticator = authenticator; + } + + [HttpPost(nameof(Authenticate))] + public async Task> 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(); + } + } + } +} diff --git a/src/Robware.Api.Auth/Models/LoginRequest.cs b/src/Robware.Api.Auth/Models/LoginRequest.cs new file mode 100644 index 0000000..f421bc0 --- /dev/null +++ b/src/Robware.Api.Auth/Models/LoginRequest.cs @@ -0,0 +1,6 @@ +namespace Robware.Api.Auth.Models { + public class LoginRequest { + public string Username { get; set; } + public string Password { get; set; } + } +} \ No newline at end of file diff --git a/src/Robware.Api.Auth/Program.cs b/src/Robware.Api.Auth/Program.cs new file mode 100644 index 0000000..0ba2af9 --- /dev/null +++ b/src/Robware.Api.Auth/Program.cs @@ -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(); + }).Build().Run(); + } +} diff --git a/src/Robware.Api.Auth/Properties/launchSettings.json b/src/Robware.Api.Auth/Properties/launchSettings.json new file mode 100644 index 0000000..d81ccfd --- /dev/null +++ b/src/Robware.Api.Auth/Properties/launchSettings.json @@ -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" + } + } +} \ No newline at end of file diff --git a/src/Robware.Api.Auth/Robware.Api.Auth.csproj b/src/Robware.Api.Auth/Robware.Api.Auth.csproj new file mode 100644 index 0000000..a22588c --- /dev/null +++ b/src/Robware.Api.Auth/Robware.Api.Auth.csproj @@ -0,0 +1,13 @@ + + + + netcoreapp3.1 + + + + + + + + + diff --git a/src/Robware.Api.Auth/Startup.cs b/src/Robware.Api.Auth/Startup.cs new file mode 100644 index 0000000..3787933 --- /dev/null +++ b/src/Robware.Api.Auth/Startup.cs @@ -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() + .AddSingleton() + .AddSingleton(new MySQLDatabaseProvider(Configuration.GetConnectionString("database"))) + .AddSingleton(); + } + + // 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(); + }); + } + } +} diff --git a/src/Robware.Api.Auth/appsettings.Development.json b/src/Robware.Api.Auth/appsettings.Development.json new file mode 100644 index 0000000..6daac7a --- /dev/null +++ b/src/Robware.Api.Auth/appsettings.Development.json @@ -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" + } +} diff --git a/src/Robware.Api.Auth/appsettings.json b/src/Robware.Api.Auth/appsettings.json new file mode 100644 index 0000000..8769624 --- /dev/null +++ b/src/Robware.Api.Auth/appsettings.json @@ -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": "" + } +} diff --git a/src/Robware.Auth.Tests/AuthenticatorTests.cs b/src/Robware.Auth.Tests/AuthenticatorTests.cs new file mode 100644 index 0000000..1fa4677 --- /dev/null +++ b/src/Robware.Auth.Tests/AuthenticatorTests.cs @@ -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(); + var crypto = Substitute.For(); + 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(); + var crypto = Substitute.For(); + 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(); + users.GetByEmail("test").Throws(new UserNotFoundException("")); + + var crypto = Substitute.For(); + crypto.Encrypt("password").Returns("password"); + + var auth = new Authenticator(users, crypto); + + (await auth.Authenticate("test", "password")).Should().BeEquivalentTo((AuthenticationResult.NotFound, null as User)); + } + } +} \ No newline at end of file diff --git a/src/Robware.Auth.Tests/CryoptographyProviderTests.cs b/src/Robware.Auth.Tests/CryoptographyProviderTests.cs new file mode 100644 index 0000000..e25941c --- /dev/null +++ b/src/Robware.Auth.Tests/CryoptographyProviderTests.cs @@ -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"); + } + } +} \ No newline at end of file diff --git a/src/Robware.Auth.Tests/Robware.Auth.Tests.csproj b/src/Robware.Auth.Tests/Robware.Auth.Tests.csproj new file mode 100644 index 0000000..fdadf85 --- /dev/null +++ b/src/Robware.Auth.Tests/Robware.Auth.Tests.csproj @@ -0,0 +1,23 @@ + + + + netcoreapp3.1 + + false + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Robware.Auth.Tests/TestUser.cs b/src/Robware.Auth.Tests/TestUser.cs new file mode 100644 index 0000000..691e2a2 --- /dev/null +++ b/src/Robware.Auth.Tests/TestUser.cs @@ -0,0 +1,8 @@ +namespace Robware.Auth.Tests { + internal class TestUser : User { + public TestUser(string username, string password) { + Username = username; + Password = password; + } + } +} \ No newline at end of file diff --git a/src/Robware.Auth/Authenticator.cs b/src/Robware.Auth/Authenticator.cs new file mode 100644 index 0000000..7ee0475 --- /dev/null +++ b/src/Robware.Auth/Authenticator.cs @@ -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 + } +} \ No newline at end of file diff --git a/src/Robware.Auth/CryptographyProvider.cs b/src/Robware.Auth/CryptographyProvider.cs new file mode 100644 index 0000000..57f61a1 --- /dev/null +++ b/src/Robware.Auth/CryptographyProvider.cs @@ -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; + } + } + } +} \ No newline at end of file diff --git a/src/Robware.Auth/IAuthenticator.cs b/src/Robware.Auth/IAuthenticator.cs new file mode 100644 index 0000000..4718d67 --- /dev/null +++ b/src/Robware.Auth/IAuthenticator.cs @@ -0,0 +1,7 @@ +using System.Threading.Tasks; + +namespace Robware.Auth { + public interface IAuthenticator { + Task<(AuthenticationResult Result, User User)> Authenticate(string username, string password); + } +} \ No newline at end of file diff --git a/src/Robware.Auth/ICryptographyProvider.cs b/src/Robware.Auth/ICryptographyProvider.cs new file mode 100644 index 0000000..bb1c3f9 --- /dev/null +++ b/src/Robware.Auth/ICryptographyProvider.cs @@ -0,0 +1,5 @@ +namespace Robware.Auth { + public interface ICryptographyProvider { + string Encrypt(string input); + } +} \ No newline at end of file diff --git a/src/Robware.Auth/IUsers.cs b/src/Robware.Auth/IUsers.cs new file mode 100644 index 0000000..b7f8f5a --- /dev/null +++ b/src/Robware.Auth/IUsers.cs @@ -0,0 +1,7 @@ +using System.Threading.Tasks; + +namespace Robware.Auth { + public interface IUsers { + Task GetByEmail(string email); + } +} \ No newline at end of file diff --git a/src/Robware.Auth/Robware.Auth.csproj b/src/Robware.Auth/Robware.Auth.csproj new file mode 100644 index 0000000..cb63190 --- /dev/null +++ b/src/Robware.Auth/Robware.Auth.csproj @@ -0,0 +1,7 @@ + + + + netcoreapp3.1 + + + diff --git a/src/Robware.Auth/User.cs b/src/Robware.Auth/User.cs new file mode 100644 index 0000000..cfb8a65 --- /dev/null +++ b/src/Robware.Auth/User.cs @@ -0,0 +1,6 @@ +namespace Robware.Auth { + public class User { + public string Username { get; protected set; } + public string Password { get; protected set; } + } +} \ No newline at end of file diff --git a/src/Robware.Auth/UserNotFoundException.cs b/src/Robware.Auth/UserNotFoundException.cs new file mode 100644 index 0000000..dde2acb --- /dev/null +++ b/src/Robware.Auth/UserNotFoundException.cs @@ -0,0 +1,9 @@ +using System; + +namespace Robware.Auth { + public class UserNotFoundException : Exception { + public UserNotFoundException(string username) : base("Could not find user " + username) { + + } + } +} \ No newline at end of file diff --git a/src/Robware.Data/DatabaseUser.cs b/src/Robware.Data/DatabaseUser.cs new file mode 100644 index 0000000..be76924 --- /dev/null +++ b/src/Robware.Data/DatabaseUser.cs @@ -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; + } + } +} \ No newline at end of file diff --git a/src/Robware.Data/IDatabaseProvider.cs b/src/Robware.Data/IDatabaseProvider.cs new file mode 100644 index 0000000..a4493e2 --- /dev/null +++ b/src/Robware.Data/IDatabaseProvider.cs @@ -0,0 +1,7 @@ +using System.Data; + +namespace Robware.Data { + public interface IDatabaseProvider { + IDbConnection NewConnection(); + } +} \ No newline at end of file diff --git a/src/Robware.Data/MySQLDatabaseProvider.cs b/src/Robware.Data/MySQLDatabaseProvider.cs new file mode 100644 index 0000000..57fdc43 --- /dev/null +++ b/src/Robware.Data/MySQLDatabaseProvider.cs @@ -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); + } +} \ No newline at end of file diff --git a/src/Robware.Data/Robware.Data.csproj b/src/Robware.Data/Robware.Data.csproj new file mode 100644 index 0000000..fc1eb18 --- /dev/null +++ b/src/Robware.Data/Robware.Data.csproj @@ -0,0 +1,16 @@ + + + + netcoreapp3.1 + + + + + + + + + + + + diff --git a/src/Robware.Data/States/UserState.cs b/src/Robware.Data/States/UserState.cs new file mode 100644 index 0000000..2dfec41 --- /dev/null +++ b/src/Robware.Data/States/UserState.cs @@ -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; } + } +} \ No newline at end of file diff --git a/src/Robware.Data/UserRepository.cs b/src/Robware.Data/UserRepository.cs new file mode 100644 index 0000000..4bc391d --- /dev/null +++ b/src/Robware.Data/UserRepository.cs @@ -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 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(query, new { email }); + + if (!result.Any()) + throw new UserNotFoundException(email); + + return new DatabaseUser(result.Single()); + } + } + } +} \ No newline at end of file