diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..8e3fa284 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,252 @@ +# Remove the line below if you want to inherit .editorconfig settings from higher directories +root = true + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +indent_style = space +tab_width = 4 + +# 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 +file_header_template = unset + +# 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_operator_placement_when_wrapping = beginning_of_line +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_boolean_expressions = 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 + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +#### 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_not_pattern = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_switch_expression =true:error + +# 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 = true: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:error + +#### 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 = all +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 +dotnet_diagnostic.IDE0052.severity=error +dotnet_diagnostic.IDE0064.severity=error +dotnet_diagnostic.CA1507.severity=error +dotnet_diagnostic.IDE0019.severity=suggestion +dotnet_diagnostic.IDE0020.severity=suggestion +dotnet_diagnostic.IDE0021.severity=suggestion +dotnet_diagnostic.IDE0022.severity=suggestion +dotnet_diagnostic.IDE0029.severity=warning +dotnet_diagnostic.IDE0090.severity=error +dotnet_diagnostic.IDE0120.severity=error +dotnet_diagnostic.IDE0005.severity=error +dotnet_diagnostic.IDE0005_gen.severity=error +dotnet_diagnostic.IDE0028.severity=error +dotnet_diagnostic.IDE0031.severity=error +dotnet_diagnostic.IDE0035.severity=error +dotnet_diagnostic.IDE0045.severity=error +dotnet_diagnostic.IDE0056.severity=suggestion +dotnet_diagnostic.IDE0057.severity=suggestion +dotnet_diagnostic.IDE0063.severity=suggestion +dotnet_diagnostic.IDE0082.severity=error +dotnet_diagnostic.IDE0083.severity=suggestion +[*.{cs,vb}] +dotnet_diagnostic.CA2007.severity=silent +dotnet_code_quality_unused_parameters=all:warning +dotnet_style_coalesce_expression=true:error + +# Defining the 'public_symbols' symbol group +dotnet_naming_symbols.public_symbols.applicable_kinds = property,method,field,event,delegate +dotnet_naming_symbols.public_symbols.applicable_accessibilities = public +dotnet_naming_symbols.public_symbols.required_modifiers = readonly + +# Defining the `first_word_upper_case_style` naming style +dotnet_naming_style.first_word_upper_case_style.capitalization = first_word_upper + +# Defining the `public_members_must_be_capitalized` naming rule, by setting the symbol group to the 'public symbols' symbol group, +dotnet_naming_rule.public_members_must_be_capitalized.symbols = public_symbols +# setting the naming style to the `first_word_upper_case_style` naming style, +dotnet_naming_rule.public_members_must_be_capitalized.style = first_word_upper_case_style +# and setting the severity. +dotnet_naming_rule.public_members_must_be_capitalized.severity = suggestion +dotnet_style_null_propagation=true:error + +dotnet_diagnostic.IDE0051.severity = error +dotnet_diagnostic.IDE0055.severity = error +dotnet_diagnostic.CA1819.severity=suggestion +dotnet_diagnostic.CA1836.severity=error \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9deda086..dc0b6ab0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,15 +4,16 @@ on: [push] jobs: build: + runs-on: windows-latest runs-on: windows-latest - + steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Build everything run: dotnet publish --configuration Release --runtime win-x64 --output "build/" - name: Upload it ! - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: build path: build/ diff --git a/.github/workflows/build_windows.yml b/.github/workflows/build_windows.yml new file mode 100644 index 00000000..dd133d59 --- /dev/null +++ b/.github/workflows/build_windows.yml @@ -0,0 +1,48 @@ +name: Build Windows Binaries + +on: + workflow_dispatch: + +jobs: + build: + name: Windows Binaries on Windows Latest + runs-on: windows-latest + + steps: + - name: Cancel previous runs on the same branch + if: ${{ github.ref != 'refs/heads/main' }} + uses: styfle/cancel-workflow-action@0.9.1 + with: + access_token: ${{ github.token }} + + - name: Checkout Code + uses: actions/checkout@v2 + with: + fetch-depth: 0 + submodules: recursive + + - name: Set git urls to https instead of ssh + run: | + git config --global url."https://github.com/".insteadOf ssh://git@github.com/ + + - name: Setup .NET Core SDK + uses: actions/setup-dotnet@v2.0.0 + + - name: Build Windows binaries with build_scripts\build_windows.ps1 + run: | + $env:path="C:\Program` Files` (x86)\Microsoft` Visual` Studio\2019\Enterprise\SDK\ScopeCppSDK\vc15\VC\bin\;$env:path" + dotnet publish --configuration Release --runtime win-x64 --output ".\passcore_output\" + ls ${{ github.workspace }}\passcore_output\ + + - name: Upload Windows binaries to artifacts + uses: actions/upload-artifact@v2.2.2 + with: + name: passcore + path: ${{ github.workspace }}\passcore_output\ + + - name: Get tag name + if: startsWith(github.ref, 'refs/tags/') + id: tag-name + run: | + echo "::set-output name=TAG_NAME::$(echo ${{ github.ref }} | cut -d'/' -f 3)" + echo "::set-output name=REPO_NAME::$(echo ${{ github.repository }} | cut -d'/' -f 2)" \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index f1633adf..88f33ad9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM node:latest AS node_base RUN echo "NODE Version:" && node --version RUN echo "NPM Version:" && npm --version -FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build COPY --from=node_base . . @@ -16,7 +16,7 @@ RUN dotnet restore RUN dotnet publish -c Release -o /app /p:PASSCORE_PROVIDER=LDAP --no-restore # final stage/image -FROM mcr.microsoft.com/dotnet/aspnet:5.0 +FROM mcr.microsoft.com/dotnet/aspnet:6.0 WORKDIR /app COPY --from=build /app ./ EXPOSE 80 diff --git a/StyleCop.Analyzers.ruleset b/StyleCop.Analyzers.ruleset deleted file mode 100644 index 1c1bf34b..00000000 --- a/StyleCop.Analyzers.ruleset +++ /dev/null @@ -1,107 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Unosquare.PassCore.sln b/Unosquare.PassCore.sln index 92f164c3..1d92648f 100644 --- a/Unosquare.PassCore.sln +++ b/Unosquare.PassCore.sln @@ -1,19 +1,17 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.30011.22 +# Visual Studio Version 17 +VisualStudioVersion = 17.1.32328.378 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{0A003964-77CA-4779-BD97-BADDD710A745}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{3EA980E1-D295-4E61-B2EE-29CFE1EDB2F1}" ProjectSection(SolutionItems) = preProject - appveyor.yml = appveyor.yml .github\workflows\build.yml = .github\workflows\build.yml Dockerfile = Dockerfile src\Unosquare.PassCore.Web\ClientApp\package.json = src\Unosquare.PassCore.Web\ClientApp\package.json preview.png = preview.png README.md = README.md - StyleCop.Analyzers.ruleset = StyleCop.Analyzers.ruleset src\Unosquare.PassCore.Web\ClientApp\tsconfig.json = src\Unosquare.PassCore.Web\ClientApp\tsconfig.json src\Unosquare.PassCore.Web\ClientApp\tslint.json = src\Unosquare.PassCore.Web\ClientApp\tslint.json EndProjectSection diff --git a/src/PwnedPasswordsSearch/PwnedPasswordsSearch.csproj b/src/PwnedPasswordsSearch/PwnedPasswordsSearch.csproj index 353a6056..cca33ef9 100644 --- a/src/PwnedPasswordsSearch/PwnedPasswordsSearch.csproj +++ b/src/PwnedPasswordsSearch/PwnedPasswordsSearch.csproj @@ -1,9 +1,10 @@ - - - - net5.0 - https://github.com/mikepound/pwned-search/blob/master/csharp/pwned-search.cs - true - - + + + net6.0 + https://github.com/mikepound/pwned-search/blob/master/csharp/pwned-search.cs + true + true + AllEnabledByDefault + true + diff --git a/src/PwnedPasswordsSearch/PwnedSearch.cs b/src/PwnedPasswordsSearch/PwnedSearch.cs index 24d4779d..4e4bd0a7 100644 --- a/src/PwnedPasswordsSearch/PwnedSearch.cs +++ b/src/PwnedPasswordsSearch/PwnedSearch.cs @@ -4,65 +4,63 @@ using System.Security.Cryptography; using System.Text; -namespace PwnedPasswordsSearch -{ - // Based on https://github.com/mikepound/pwned-search/blob/master/csharp/pwned-search.cs +namespace PwnedPasswordsSearch; +// Based on https://github.com/mikepound/pwned-search/blob/master/csharp/pwned-search.cs - public static class PwnedSearch +public static class PwnedSearch +{ + /// + /// Makes a call to Pwned Passwords API, asking for a set of hashes of publicly known passwords that match a partial hash of a given password. + /// If any of the hashes returned by the API call fully matches the hash of the plaintext, it would mean that the password has been exposed + /// in publicly known data breaches and thus is not safe to use. + /// See https://haveibeenpwned.com/API/v2#PwnedPasswords + /// + /// Password to check against Pwned Passwords API + /// True when the password has been Pwned + public static bool IsPwnedPassword(string plaintext) { - /// - /// Makes a call to Pwned Passwords API, asking for a set of hashes of publicly known passwords that match a partial hash of a given password. - /// If any of the hashes returned by the API call fully matches the hash of the plaintext, it would mean that the password has been exposed - /// in publicly known data breaches and thus is not safe to use. - /// See https://haveibeenpwned.com/API/v2#PwnedPasswords - /// - /// Password to check against Pwned Passwords API - /// True when the password has been Pwned - public static bool IsPwnedPassword(string plaintext) + try { - try - { - SHA1 sha = new SHA1CryptoServiceProvider(); - byte[] data = sha.ComputeHash(Encoding.UTF8.GetBytes(plaintext)); + SHA1 sha = new SHA1CryptoServiceProvider(); + byte[] data = sha.ComputeHash(Encoding.UTF8.GetBytes(plaintext)); - // Loop through each byte of the hashed data and format each one as a hexadecimal string. - var sBuilder = new StringBuilder(); - for (int i = 0; i < data.Length; i++) - sBuilder.Append(data[i].ToString("x2")); + // Loop through each byte of the hashed data and format each one as a hexadecimal string. + var sBuilder = new StringBuilder(); + foreach (var t in data) + sBuilder.Append(t.ToString("x2")); - var result = sBuilder.ToString().ToUpper(); + var result = sBuilder.ToString().ToUpper(); - // Get a list of all the possible password hashes where the first 5 bytes of the hash are the same - var url = "https://api.pwnedpasswords.com/range/" + result.Substring(0, 5); - WebRequest request = WebRequest.Create(url); - using var response = request.GetResponse().GetResponseStream(); - using var reader = new StreamReader(response); - // Iterate through all possible matches and compare the rest of the hash to see if there is a full match - // TODO: optimize-async this - string hashToCheck = result.Substring(5); - string line = null; - do + // Get a list of all the possible password hashes where the first 5 bytes of the hash are the same + var url = $"https://api.pwnedpasswords.com/range/{result[..5]}"; + WebRequest request = WebRequest.Create(url); + using var response = request.GetResponse().GetResponseStream(); + using var reader = new StreamReader(response); + // Iterate through all possible matches and compare the rest of the hash to see if there is a full match + // TODO: optimize-async this + string hashToCheck = result[5..]; + string line; + do + { + line = reader.ReadLine(); + if (line != null) { - line = reader.ReadLine(); - if (line != null) + string[] parts = line.Split(':'); + if (parts[0] == hashToCheck) // This is a full match: plaintext compromised!!!! { - string[] parts = line.Split(':'); - if (parts[0] == hashToCheck) // This is a full match: plaintext compromised!!!! - { - System.Diagnostics.Debug.Print("The password '{plaintext}' is publicly known and can be used in dictionary attacks"); - return true; - } + System.Diagnostics.Debug.Print("The password '{plaintext}' is publicly known and can be used in dictionary attacks"); + return true; } - } while (line != null); + } + } while (line != null); - // We've run through all the candidates and none of them is a full match - return false; // This plaintext is not publicly known - } - catch (Exception) - { - // If any weird things happens, it is safer to suppose this plaintext is compromised (hence not to be used). - return true; // Better safe than sorry. - } + // We've run through all the candidates and none of them is a full match + return false; // This plaintext is not publicly known + } + catch (Exception) + { + // If any weird things happens, it is safer to suppose this plaintext is compromised (hence not to be used). + return true; // Better safe than sorry. } } -} +} \ No newline at end of file diff --git a/src/Unosquare.PassCore.Common/ApiErrorCode.cs b/src/Unosquare.PassCore.Common/ApiErrorCode.cs index f1e12011..c8fc0977 100644 --- a/src/Unosquare.PassCore.Common/ApiErrorCode.cs +++ b/src/Unosquare.PassCore.Common/ApiErrorCode.cs @@ -1,73 +1,72 @@ -namespace Unosquare.PassCore.Common +namespace Unosquare.PassCore.Common; + +/// +/// Represents API error codes. +/// +public enum ApiErrorCode { /// - /// Represents API error codes. + /// The generic /// - public enum ApiErrorCode - { - /// - /// The generic - /// - Generic = 0, + Generic = 0, - /// - /// The field required - /// - FieldRequired = 1, + /// + /// The field required + /// + FieldRequired = 1, - /// - /// The field mismatch - /// - FieldMismatch = 2, + /// + /// The field mismatch + /// + FieldMismatch = 2, - /// - /// The user not found - /// - UserNotFound = 3, + /// + /// The user not found + /// + UserNotFound = 3, - /// - /// The invalid credentials - /// - InvalidCredentials = 4, + /// + /// The invalid credentials + /// + InvalidCredentials = 4, - /// - /// The invalid captcha - /// - InvalidCaptcha = 5, + /// + /// The invalid captcha + /// + InvalidCaptcha = 5, - /// - /// The change not permitted - /// - ChangeNotPermitted = 6, + /// + /// The change not permitted + /// + ChangeNotPermitted = 6, - /// - /// The invalid domain - /// - InvalidDomain = 7, + /// + /// The invalid domain + /// + InvalidDomain = 7, - /// - /// LDAP problem connection - /// - LdapProblem = 8, + /// + /// LDAP problem connection + /// + LdapProblem = 8, - /// - /// Complex password issue - /// - ComplexPassword = 9, + /// + /// Complex password issue + /// + ComplexPassword = 9, - /// - /// The score is minor than the Minimum Score - /// - MinimumScore = 10, + /// + /// The score is minor than the Minimum Score + /// + MinimumScore = 10, - /// - /// The score is minor than the Minimum Score - /// - MinimumDistance = 11, + /// + /// The score is minor than the Minimum Score + /// + MinimumDistance = 11, - /// - /// The password is in Pwned database - /// - PwnedPassword = 12, - } + /// + /// The password is in Pwned database + /// + PwnedPassword = 12, } \ No newline at end of file diff --git a/src/Unosquare.PassCore.Common/ApiErrorException.cs b/src/Unosquare.PassCore.Common/ApiErrorException.cs index a278d8a2..39c0ad1f 100644 --- a/src/Unosquare.PassCore.Common/ApiErrorException.cs +++ b/src/Unosquare.PassCore.Common/ApiErrorException.cs @@ -1,36 +1,35 @@ -namespace Unosquare.PassCore.Common -{ - using System; +using System; - /// +namespace Unosquare.PassCore.Common; + +/// +/// +/// Special Exception to transport the ApiErrorItem. +/// +public class ApiErrorException : Exception +{ /// - /// Special Exception to transport the ApiErrorItem. + /// Initializes a new instance of the class. /// - public class ApiErrorException : Exception - { - /// - /// Initializes a new instance of the class. - /// - /// The message. - /// The error code. - public ApiErrorException(string message, ApiErrorCode errorCode = ApiErrorCode.Generic) + /// The message. + /// The error code. + public ApiErrorException(string message, ApiErrorCode errorCode = ApiErrorCode.Generic) : base(message) => ErrorCode = errorCode; - /// - /// Gets or sets the error code. - /// - /// - /// The error code. - /// - public ApiErrorCode ErrorCode { get; } + /// + /// Gets or sets the error code. + /// + /// + /// The error code. + /// + public ApiErrorCode ErrorCode { get; } - /// - public override string Message => $"Error Code: {ErrorCode}\r\n{base.Message}"; + /// + public override string Message => $"Error Code: {ErrorCode}\r\n{base.Message}"; - /// - /// To the API error item. - /// - /// An API Error Item. - public ApiErrorItem ToApiErrorItem() => new ApiErrorItem(ErrorCode, base.Message); - } -} + /// + /// To the API error item. + /// + /// An API Error Item. + public ApiErrorItem ToApiErrorItem() => new(ErrorCode, base.Message); +} \ No newline at end of file diff --git a/src/Unosquare.PassCore.Common/ApiErrorItem.cs b/src/Unosquare.PassCore.Common/ApiErrorItem.cs index c87d162e..6ce1f840 100644 --- a/src/Unosquare.PassCore.Common/ApiErrorItem.cs +++ b/src/Unosquare.PassCore.Common/ApiErrorItem.cs @@ -1,43 +1,42 @@ -namespace Unosquare.PassCore.Common +namespace Unosquare.PassCore.Common; + +/// +/// Defines the fields contained in one of the items of Api Errors. +/// +public class ApiErrorItem { /// - /// Defines the fields contained in one of the items of Api Errors. + /// Initializes a new instance of the class. /// - public class ApiErrorItem + /// The error code. + /// The message. + public ApiErrorItem(ApiErrorCode errorCode, string? message = null) { - /// - /// Initializes a new instance of the class. - /// - /// The error code. - /// The message. - public ApiErrorItem(ApiErrorCode errorCode, string? message = null) - { - ErrorCode = errorCode; - Message = message; - } + ErrorCode = errorCode; + Message = message; + } - /// - /// Gets or sets the error code. - /// - /// - /// The error code. - /// - public ApiErrorCode ErrorCode { get; } + /// + /// Gets or sets the error code. + /// + /// + /// The error code. + /// + public ApiErrorCode ErrorCode { get; } - /// - /// Gets or sets the name of the field. - /// - /// - /// The name of the field. - /// - public string? FieldName { get; set; } + /// + /// Gets or sets the name of the field. + /// + /// + /// The name of the field. + /// + public string? FieldName { get; set; } - /// - /// Gets the message. - /// - /// - /// The message. - /// - public string? Message { get; } - } -} + /// + /// Gets the message. + /// + /// + /// The message. + /// + public string? Message { get; } +} \ No newline at end of file diff --git a/src/Unosquare.PassCore.Common/IAppSettings.cs b/src/Unosquare.PassCore.Common/IAppSettings.cs index 0f781206..b81f07b7 100644 --- a/src/Unosquare.PassCore.Common/IAppSettings.cs +++ b/src/Unosquare.PassCore.Common/IAppSettings.cs @@ -1,59 +1,58 @@ -namespace Unosquare.PassCore.Common +namespace Unosquare.PassCore.Common; + +/// +/// Interface for any Application provider. +/// +public interface IAppSettings { /// - /// Interface for any Application provider. + /// Gets or sets the default domain. /// - public interface IAppSettings - { - /// - /// Gets or sets the default domain. - /// - /// - /// The default domain. - /// - string DefaultDomain { get; set; } + /// + /// The default domain. + /// + string DefaultDomain { get; set; } - /// - /// Gets or sets the LDAP port. - /// - /// - /// Optional, defaults to 636 -- the default port for LDAPS (i.e. LDAP over TLS). - /// A common alternative is to use the default LDAP port, 389, however this port - /// typically is not-secured and requires the "StartTLS" flag enabled. - /// - /// - /// The LDAP port. - /// - int LdapPort { get; set; } + /// + /// Gets or sets the LDAP port. + /// + /// + /// Optional, defaults to 636 -- the default port for LDAPS (i.e. LDAP over TLS). + /// A common alternative is to use the default LDAP port, 389, however this port + /// typically is not-secured and requires the "StartTLS" flag enabled. + /// + /// + /// The LDAP port. + /// + int LdapPort { get; set; } - /// - /// Gets or sets the LDAP hostnames. - /// - /// - /// Required, one or more hostnames or IP addresses which expose an LDAP/LDAPS - /// service endpoint that will be connected to. If more than one host is - /// specified, then each will be tried in turn until a successful, secure - /// connection is established. - /// - /// - /// The LDAP hostnames. - /// - string[] LdapHostnames { get; set; } + /// + /// Gets or sets the LDAP hostnames. + /// + /// + /// Required, one or more hostnames or IP addresses which expose an LDAP/LDAPS + /// service endpoint that will be connected to. If more than one host is + /// specified, then each will be tried in turn until a successful, secure + /// connection is established. + /// + /// + /// The LDAP hostnames. + /// + string[] LdapHostnames { get; set; } - /// - /// Gets or sets the LDAP password. - /// - /// - /// The LDAP password. - /// - string LdapPassword { get; set; } + /// + /// Gets or sets the LDAP password. + /// + /// + /// The LDAP password. + /// + string LdapPassword { get; set; } - /// - /// Gets or sets the LDAP username. - /// - /// - /// The LDAP username. - /// - string LdapUsername { get; set; } - } -} + /// + /// Gets or sets the LDAP username. + /// + /// + /// The LDAP username. + /// + string LdapUsername { get; set; } +} \ No newline at end of file diff --git a/src/Unosquare.PassCore.Common/IPasswordChangeProvider.cs b/src/Unosquare.PassCore.Common/IPasswordChangeProvider.cs index 4aeb43b6..a8185782 100644 --- a/src/Unosquare.PassCore.Common/IPasswordChangeProvider.cs +++ b/src/Unosquare.PassCore.Common/IPasswordChangeProvider.cs @@ -1,65 +1,63 @@ - -namespace Unosquare.PassCore.Common +using System; + +namespace Unosquare.PassCore.Common; + +/// +/// Represents a interface for a password change provider. +/// +public interface IPasswordChangeProvider { - using System; + /// + /// Performs the password change using the credentials provided. + /// + /// The username. + /// The current password. + /// The new password. + /// The API error item or null if the change password operation was successful. + ApiErrorItem? PerformPasswordChange(string username, string currentPassword, string newPassword); /// - /// Represents a interface for a password change provider. + /// Compute the distance between two strings. + /// Take it from https://www.csharpstar.com/csharp-string-distance-algorithm/. /// - public interface IPasswordChangeProvider + /// The current password. + /// The new password. + /// + /// The distance between strings. + /// + int MeasureNewPasswordDistance(string currentPassword, string newPassword) { - /// - /// Performs the password change using the credentials provided. - /// - /// The username. - /// The current password. - /// The new password. - /// The API error item or null if the change password operation was successful. - ApiErrorItem? PerformPasswordChange(string username, string currentPassword, string newPassword); + var n = currentPassword.Length; + var m = newPassword.Length; + var d = new int[n + 1, m + 1]; - /// - /// Compute the distance between two strings. - /// Take it from https://www.csharpstar.com/csharp-string-distance-algorithm/. - /// - /// The current password. - /// The new password. - /// - /// The distance between strings. - /// - int MeasureNewPasswordDistance(string currentPassword, string newPassword) - { - var n = currentPassword.Length; - var m = newPassword.Length; - var d = new int[n + 1, m + 1]; + // Step 1 + if (n == 0) + return m; - // Step 1 - if (n == 0) - return m; + if (m == 0) + return n; - if (m == 0) - return n; + // Step 2 + for (int i = 0; i <= n; d[i, 0] = i++) { } - // Step 2 - for (int i = 0; i <= n; d[i, 0] = i++) { } + for (int j = 0; j <= m; d[0, j] = j++) { } - for (int j = 0; j <= m; d[0, j] = j++) { } - - // Step 3 - for (int i = 1; i <= n; i++) + // Step 3 + for (int i = 1; i <= n; i++) + { + //Step 4 + for (int j = 1; j <= m; j++) { - //Step 4 - for (int j = 1; j <= m; j++) - { - // Step 5 - int cost = (newPassword[j - 1] == currentPassword[i - 1]) ? 0 : 1; - // Step 6 - d[i, j] = Math.Min(Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), d[i - 1, j - 1] + cost); - } + // Step 5 + int cost = (newPassword[j - 1] == currentPassword[i - 1]) ? 0 : 1; + // Step 6 + d[i, j] = Math.Min(Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), d[i - 1, j - 1] + cost); } + } - // Step 7 - return d[n, m]; + // Step 7 + return d[n, m]; - } } } \ No newline at end of file diff --git a/src/Unosquare.PassCore.Common/Unosquare.PassCore.Common.csproj b/src/Unosquare.PassCore.Common/Unosquare.PassCore.Common.csproj index 821e1afc..e5d5edad 100644 --- a/src/Unosquare.PassCore.Common/Unosquare.PassCore.Common.csproj +++ b/src/Unosquare.PassCore.Common/Unosquare.PassCore.Common.csproj @@ -1,16 +1,17 @@  - - - PassCore abstraction classes to implement custom providers. - Copyright (c) 2018-2021 - Unosquare - net5.0 - enable - true - 1.0.0 - ..\..\StyleCop.Analyzers.ruleset - true - Unosquare - https://github.com/unosquare/passcore - https://raw.githubusercontent.com/unosquare/passcore/master/LICENSE - + + PassCore abstraction classes to implement custom providers. + Copyright (c) 2018-2022 - Unosquare + net6.0 + enable + true + true + true + AllEnabledByDefault + 1.0.0 + true + Unosquare + https://github.com/unosquare/passcore + https://raw.githubusercontent.com/unosquare/passcore/master/LICENSE + diff --git a/src/Unosquare.PassCore.PasswordProvider/NativeMethods.cs b/src/Unosquare.PassCore.PasswordProvider/NativeMethods.cs index b2465b0d..710becc4 100644 --- a/src/Unosquare.PassCore.PasswordProvider/NativeMethods.cs +++ b/src/Unosquare.PassCore.PasswordProvider/NativeMethods.cs @@ -1,53 +1,52 @@ -namespace Unosquare.PassCore.PasswordProvider +using System; + +namespace Unosquare.PassCore.PasswordProvider; + +/// +/// This code is taken from the answer https://stackoverflow.com/a/1766203 +/// from https://stackoverflow.com/questions/1394025/active-directory-ldap-check-account-locked-out-password-expired. +/// +public class NativeMethods { - using System; + // See http://support.microsoft.com/kb/155012 + internal const int ErrorPasswordMustChange = 1907; + + // It gives this error if the account is locked, REGARDLESS OF WHETHER VALID CREDENTIALS WERE PROVIDED!!! + internal const int ErrorPasswordExpired = 1330; - /// - /// This code is taken from the answer https://stackoverflow.com/a/1766203 - /// from https://stackoverflow.com/questions/1394025/active-directory-ldap-check-account-locked-out-password-expired. - /// - public partial class NativeMethods + // here are enums + internal enum LogonTypes : uint { - // See http://support.microsoft.com/kb/155012 - internal const int ErrorPasswordMustChange = 1907; - - // It gives this error if the account is locked, REGARDLESS OF WHETHER VALID CREDENTIALS WERE PROVIDED!!! - internal const int ErrorPasswordExpired = 1330; - - // here are enums - internal enum LogonTypes : uint - { - /// - /// The interactive - /// - Interactive = 2, - - /// - /// The network - /// - Network = 3, - - /// - /// The service - /// - Service = 5, - } - - internal enum LogonProviders : uint - { - /// - /// The default for platform (use this!) - /// - Default = 0, - } - - [System.Runtime.InteropServices.DllImport("advapi32.dll", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Unicode)] - internal static extern bool LogonUser( - string principal, - string authority, - string password, - LogonTypes logonType, - LogonProviders logonProvider, - out IntPtr token); + /// + /// The interactive + /// + Interactive = 2, + + /// + /// The network + /// + Network = 3, + + /// + /// The service + /// + Service = 5, } -} + + internal enum LogonProviders : uint + { + /// + /// The default for platform (use this!) + /// + Default = 0, + } + + [System.Runtime.InteropServices.DllImport("advapi32.dll", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Unicode)] + internal static extern bool LogonUser( + string principal, + string authority, + string password, + LogonTypes logonType, + LogonProviders logonProvider, + out IntPtr token); +} \ No newline at end of file diff --git a/src/Unosquare.PassCore.PasswordProvider/PasswordChangeOptions.cs b/src/Unosquare.PassCore.PasswordProvider/PasswordChangeOptions.cs index 404a43c3..f9a9ec9b 100644 --- a/src/Unosquare.PassCore.PasswordProvider/PasswordChangeOptions.cs +++ b/src/Unosquare.PassCore.PasswordProvider/PasswordChangeOptions.cs @@ -1,88 +1,87 @@ -namespace Unosquare.PassCore.PasswordProvider +using System.Collections.Generic; +using Unosquare.PassCore.Common; + +namespace Unosquare.PassCore.PasswordProvider; + +/// +/// Represents the options of this provider. +/// +/// +public class PasswordChangeOptions : IAppSettings { - using Common; - using System.Collections.Generic; + private string? _defaultDomain; + private string? _ldapPassword; + private string[]? _ldapHostnames; + private string? _ldapUsername; /// - /// Represents the options of this provider. + /// Gets or sets a value indicating whether [use automatic context]. /// - /// - public class PasswordChangeOptions : IAppSettings - { - private string? defaultDomain; - private string? ldapPassword; - private string[]? ldapHostnames; - private string? ldapUsername; + /// + /// true if [use automatic context]; otherwise, false. + /// + public bool UseAutomaticContext { get; set; } = true; - /// - /// Gets or sets a value indicating whether [use automatic context]. - /// - /// - /// true if [use automatic context]; otherwise, false. - /// - public bool UseAutomaticContext { get; set; } = true; - - /// - /// Gets or sets the restricted ad groups. - /// - /// - /// The restricted ad groups. - /// - public List? RestrictedADGroups { get; set; } + /// + /// Gets or sets the restricted ad groups. + /// + /// + /// The restricted ad groups. + /// + public List? RestrictedAdGroups { get; set; } - /// - /// Gets or sets the allowed ad groups. - /// - /// - /// The allowed ad groups. - /// - public List? AllowedADGroups { get; set; } + /// + /// Gets or sets the allowed ad groups. + /// + /// + /// The allowed ad groups. + /// + public List? AllowedAdGroups { get; set; } - /// - /// Gets or sets the identifier type for user. - /// - /// - /// The identifier type for user. - /// - public string? IdTypeForUser { get; set; } + /// + /// Gets or sets the identifier type for user. + /// + /// + /// The identifier type for user. + /// + public string? IdTypeForUser { get; set; } - /// - /// Gets or sets a value indicating whether [update last password]. - /// - /// - /// true if [update last password]; otherwise, false. - /// - public bool UpdateLastPassword { get; set; } + /// + /// Gets or sets a value indicating whether [update last password]. + /// + /// + /// true if [update last password]; otherwise, false. + /// + public bool UpdateLastPassword { get; set; } - /// - public string DefaultDomain - { - get => defaultDomain ?? string.Empty; - set => defaultDomain = value; - } + /// + public string DefaultDomain + { + get => _defaultDomain ?? string.Empty; + set => _defaultDomain = value; + } - /// - public int LdapPort { get; set; } + /// + public int LdapPort { get; set; } - /// - public string[] LdapHostnames - { - get => ldapHostnames ?? new string[] { }; - set => ldapHostnames = value; - } + /// + public string[] LdapHostnames + { + get => _ldapHostnames ?? new string[] { }; + set => _ldapHostnames = value; + } - /// - public string LdapPassword - { - get => ldapPassword ?? string.Empty; - set => ldapPassword = value; - } + /// + public string LdapPassword + { + get => _ldapPassword ?? string.Empty; + set => _ldapPassword = value; + } - /// - public string LdapUsername - { - get => ldapUsername ?? string.Empty; - set => ldapUsername = value; - } + /// + public string LdapUsername + { + get => _ldapUsername ?? string.Empty; + set => _ldapUsername = value; } -} +} \ No newline at end of file diff --git a/src/Unosquare.PassCore.PasswordProvider/PasswordChangeProvider.cs b/src/Unosquare.PassCore.PasswordProvider/PasswordChangeProvider.cs index cf49394e..bf907e44 100644 --- a/src/Unosquare.PassCore.PasswordProvider/PasswordChangeProvider.cs +++ b/src/Unosquare.PassCore.PasswordProvider/PasswordChangeProvider.cs @@ -1,309 +1,309 @@ -namespace Unosquare.PassCore.PasswordProvider +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System; +using System.DirectoryServices; +using System.DirectoryServices.AccountManagement; +using System.DirectoryServices.ActiveDirectory; +using System.Linq; +using Unosquare.PassCore.Common; + +namespace Unosquare.PassCore.PasswordProvider; + +/// +/// +/// Default Change Password Provider using 'System.DirectoryServices' from Microsoft. +/// +/// +public class PasswordChangeProvider : IPasswordChangeProvider { - using Common; - using Microsoft.Extensions.Logging; - using Microsoft.Extensions.Options; - using System; - using System.DirectoryServices; - using System.DirectoryServices.AccountManagement; - using System.DirectoryServices.ActiveDirectory; - using System.Linq; + private readonly PasswordChangeOptions _options; + private readonly ILogger _logger; + private IdentityType _idType = IdentityType.UserPrincipalName; - /// /// - /// Default Change Password Provider using 'System.DirectoryServices' from Microsoft. + /// Initializes a new instance of the class. /// - /// - public partial class PasswordChangeProvider : IPasswordChangeProvider + /// The logger. + /// The options. + public PasswordChangeProvider( + ILogger logger, + IOptions options) { - private readonly PasswordChangeOptions _options; - private readonly ILogger _logger; - private IdentityType _idType = IdentityType.UserPrincipalName; - - /// - /// Initializes a new instance of the class. - /// - /// The logger. - /// The options. - public PasswordChangeProvider( - ILogger logger, - IOptions options) - { - _logger = logger; - _options = options.Value; - SetIdType(); - } + _logger = logger; + _options = options.Value; + SetIdType(); + } - /// - public ApiErrorItem? PerformPasswordChange(string username, string currentPassword, string newPassword) + /// + public ApiErrorItem? PerformPasswordChange(string username, string currentPassword, string newPassword) + { + try { - try - { - var fixedUsername = FixUsernameWithDomain(username); - using var principalContext = AcquirePrincipalContext(); - var userPrincipal = UserPrincipal.FindByIdentity(principalContext, _idType, fixedUsername); - - // Check if the user principal exists - if (userPrincipal == null) - { - _logger.LogWarning($"The User principal ({fixedUsername}) doesn't exist"); - - return new ApiErrorItem(ApiErrorCode.UserNotFound); - } + var fixedUsername = FixUsernameWithDomain(username); + using var principalContext = AcquirePrincipalContext(); + var userPrincipal = UserPrincipal.FindByIdentity(principalContext, _idType, fixedUsername); - var minPwdLength = AcquireDomainPasswordLength(); + // Check if the user principal exists + if (userPrincipal == null) + { + _logger.LogWarning($"The User principal ({fixedUsername}) doesn't exist"); - if (newPassword.Length < minPwdLength) - { - _logger.LogError("Failed due to password complex policies: New password length is shorter than AD minimum password length"); + return new ApiErrorItem(ApiErrorCode.UserNotFound); + } - return new ApiErrorItem(ApiErrorCode.ComplexPassword); - } + var minPwdLength = AcquireDomainPasswordLength(); - // Check if the newPassword is Pwned - if (PwnedPasswordsSearch.PwnedSearch.IsPwnedPassword(newPassword)) - { - _logger.LogError("Failed due to pwned password: New password is publicly known and can be used in dictionary attacks"); + if (newPassword.Length < minPwdLength) + { + _logger.LogError("Failed due to password complex policies: New password length is shorter than AD minimum password length"); - return new ApiErrorItem(ApiErrorCode.PwnedPassword); - } + return new ApiErrorItem(ApiErrorCode.ComplexPassword); + } - _logger.LogInformation($"PerformPasswordChange for user {fixedUsername}"); + // Check if the newPassword is Pwned + if (PwnedPasswordsSearch.PwnedSearch.IsPwnedPassword(newPassword)) + { + _logger.LogError("Failed due to pwned password: New password is publicly known and can be used in dictionary attacks"); - var item = ValidateGroups(userPrincipal); + return new ApiErrorItem(ApiErrorCode.PwnedPassword); + } - if (item != null) - return item; + _logger.LogInformation($"PerformPasswordChange for user {fixedUsername}"); - // Check if password change is allowed - if (userPrincipal.UserCannotChangePassword) - { - _logger.LogWarning("The User principal cannot change the password"); + var item = ValidateGroups(userPrincipal); - return new ApiErrorItem(ApiErrorCode.ChangeNotPermitted); - } + if (item != null) + return item; - // Check if password expired or must be changed - if (_options.UpdateLastPassword && userPrincipal.LastPasswordSet == null) - { - SetLastPassword(userPrincipal); - } + // Check if password change is allowed + if (userPrincipal.UserCannotChangePassword) + { + _logger.LogWarning("The User principal cannot change the password"); - // Use always UPN for password check. - if (!ValidateUserCredentials(userPrincipal.UserPrincipalName, currentPassword, principalContext)) - { - _logger.LogWarning("The User principal password is not valid"); + return new ApiErrorItem(ApiErrorCode.ChangeNotPermitted); + } - return new ApiErrorItem(ApiErrorCode.InvalidCredentials); - } + // Check if password expired or must be changed + if (_options.UpdateLastPassword && userPrincipal.LastPasswordSet == null) + { + SetLastPassword(userPrincipal); + } - // Change the password via 2 different methods. Try SetPassword if ChangePassword fails. - ChangePassword(currentPassword, newPassword, userPrincipal); + // Use always UPN for password check. + if (!ValidateUserCredentials(userPrincipal.UserPrincipalName, currentPassword, principalContext)) + { + _logger.LogWarning("The User principal password is not valid"); - userPrincipal.Save(); - _logger.LogDebug("The User principal password updated with setPassword"); + return new ApiErrorItem(ApiErrorCode.InvalidCredentials); } - catch (PasswordException passwordEx) - { - var item = new ApiErrorItem(ApiErrorCode.ComplexPassword, passwordEx.Message); - _logger.LogWarning(item.Message, passwordEx); + // Change the password via 2 different methods. Try SetPassword if ChangePassword fails. + ChangePassword(currentPassword, newPassword, userPrincipal); - return item; - } - catch (Exception ex) - { - var item = ex is ApiErrorException apiError - ? apiError.ToApiErrorItem() - : new ApiErrorItem(ApiErrorCode.Generic, ex.InnerException?.Message ?? ex.Message); + userPrincipal.Save(); + _logger.LogDebug("The User principal password updated with setPassword"); + } + catch (PasswordException passwordEx) + { + var item = new ApiErrorItem(ApiErrorCode.ComplexPassword, passwordEx.Message); - _logger.LogWarning(item.Message, ex); + _logger.LogWarning(item.Message, passwordEx); - return item; - } + return item; + } + catch (Exception ex) + { + var item = ex is ApiErrorException apiError + ? apiError.ToApiErrorItem() + : new ApiErrorItem(ApiErrorCode.Generic, ex.InnerException?.Message ?? ex.Message); + + _logger.LogWarning(item.Message, ex); - return null; + return item; } - private bool ValidateUserCredentials( - string upn, - string currentPassword, - PrincipalContext principalContext) - { - if (principalContext.ValidateCredentials(upn, currentPassword)) - return true; + return null; + } - if (NativeMethods.LogonUser(upn, string.Empty, currentPassword, NativeMethods.LogonTypes.Network, NativeMethods.LogonProviders.Default, out _)) - return true; + private bool ValidateUserCredentials( + string upn, + string currentPassword, + PrincipalContext principalContext) + { + if (principalContext.ValidateCredentials(upn, currentPassword)) + return true; - var errorCode = System.Runtime.InteropServices.Marshal.GetLastWin32Error(); + if (NativeMethods.LogonUser(upn, string.Empty, currentPassword, NativeMethods.LogonTypes.Network, NativeMethods.LogonProviders.Default, out _)) + return true; - _logger.LogDebug($"ValidateUserCredentials GetLastWin32Error {errorCode}"); + var errorCode = System.Runtime.InteropServices.Marshal.GetLastWin32Error(); - // Both of these means that the password CAN change and that we got the correct password - return errorCode == NativeMethods.ErrorPasswordMustChange || errorCode == NativeMethods.ErrorPasswordExpired; - } + _logger.LogDebug($"ValidateUserCredentials GetLastWin32Error {errorCode}"); - private string FixUsernameWithDomain(string username) - { - if (_idType != IdentityType.UserPrincipalName) return username; + // Both of these means that the password CAN change and that we got the correct password + return errorCode is NativeMethods.ErrorPasswordMustChange or NativeMethods.ErrorPasswordExpired; + } + + private string FixUsernameWithDomain(string username) + { + if (_idType != IdentityType.UserPrincipalName) return username; - // Check for default domain: if none given, ensure EFLD can be used as an override. - var parts = username.Split(new[] { '@' }, StringSplitOptions.RemoveEmptyEntries); - var domain = parts.Length > 1 ? parts[1] : _options.DefaultDomain; + // Check for default domain: if none given, ensure EFLD can be used as an override. + var parts = username.Split(new[] { '@' }, StringSplitOptions.RemoveEmptyEntries); + var domain = parts.Length > 1 ? parts[1] : _options.DefaultDomain; - return string.IsNullOrWhiteSpace(domain) || parts.Length > 1 ? username : $"{username}@{domain}"; - } + return string.IsNullOrWhiteSpace(domain) || parts.Length > 1 ? username : $"{username}@{domain}"; + } - private ApiErrorItem? ValidateGroups(UserPrincipal userPrincipal) + private ApiErrorItem? ValidateGroups(UserPrincipal userPrincipal) + { + try { + PrincipalSearchResult groups; + try { - PrincipalSearchResult groups; - - try - { - groups = userPrincipal.GetGroups(); - } - catch (Exception exception) - { - _logger.LogError(new EventId(887), exception, nameof(ValidateGroups)); - - groups = userPrincipal.GetAuthorizationGroups(); - } - - if (_options.RestrictedADGroups != null) - if (groups.Any(x => _options.RestrictedADGroups.Contains(x.Name))) - { - return new ApiErrorItem(ApiErrorCode.ChangeNotPermitted, - "The User principal is listed as restricted"); - } - - return groups?.Any(x => _options.AllowedADGroups?.Contains(x.Name) != false) == true - ? null - : new ApiErrorItem(ApiErrorCode.ChangeNotPermitted, "The User principal is not listed as allowed"); + groups = userPrincipal.GetGroups(); } catch (Exception exception) { - _logger.LogError(new EventId(888), exception, nameof(ValidateGroups)); - } + _logger.LogError(new EventId(887), exception, nameof(ValidateGroups)); - return null; - } - - private void SetLastPassword(Principal userPrincipal) - { - var directoryEntry = (DirectoryEntry)userPrincipal.GetUnderlyingObject(); - var prop = directoryEntry.Properties["pwdLastSet"]; - - if (prop == null) - { - _logger.LogWarning("The User principal password have no last password, but the property is missing"); - return; + groups = userPrincipal.GetAuthorizationGroups(); } - try + if (_options.RestrictedAdGroups == null) { - prop.Value = -1; - directoryEntry.CommitChanges(); - _logger.LogWarning("The User principal last password was updated"); + return groups.Any(x => _options.AllowedAdGroups?.Contains(x.Name) == true) + ? null + : new ApiErrorItem(ApiErrorCode.ChangeNotPermitted, "The User principal is not listed as allowed"); } - catch (Exception ex) + + if (groups.Any(x => _options.RestrictedAdGroups.Contains(x.Name))) { - throw new ApiErrorException($"Failed to update password: {ex.Message}", - ApiErrorCode.ChangeNotPermitted); + return new ApiErrorItem(ApiErrorCode.ChangeNotPermitted, + "The User principal is listed as restricted"); } - } - private void ChangePassword( - string currentPassword, - string newPassword, - AuthenticablePrincipal userPrincipal) + return groups.Any(x => _options.AllowedAdGroups?.Contains(x.Name) == true) + ? null + : new ApiErrorItem(ApiErrorCode.ChangeNotPermitted, "The User principal is not listed as allowed"); + } + catch (Exception exception) { - try - { - // Try by regular ChangePassword method - userPrincipal.ChangePassword(currentPassword, newPassword); - } - catch - { - if (_options.UseAutomaticContext) - { - _logger.LogWarning("The User principal password cannot be changed and setPassword won't be called"); + _logger.LogError(new EventId(888), exception, nameof(ValidateGroups)); + } - throw; - } + return null; + } - // If the previous attempt failed, use the SetPassword method. - userPrincipal.SetPassword(newPassword); + private void SetLastPassword(Principal userPrincipal) + { + var directoryEntry = (DirectoryEntry)userPrincipal.GetUnderlyingObject(); + var prop = directoryEntry.Properties["pwdLastSet"]; - _logger.LogDebug("The User principal password updated with setPassword"); - } + if (prop == null) + { + _logger.LogWarning("The User principal password have no last password, but the property is missing"); + return; } - /// - /// Use the values from appsettings.IdTypeForUser as fault-tolerant as possible. - /// - private void SetIdType() + try { - _idType = _options.IdTypeForUser?.Trim().ToLower() switch - { - "distinguishedname" => IdentityType.DistinguishedName, - "distinguished name" => IdentityType.DistinguishedName, - "dn" => IdentityType.DistinguishedName, - "globally unique identifier" => IdentityType.Guid, - "globallyuniqueidentifier" => IdentityType.Guid, - "guid" => IdentityType.Guid, - "name" => IdentityType.Name, - "nm" => IdentityType.Name, - "samaccountname" => IdentityType.SamAccountName, - "accountname" => IdentityType.SamAccountName, - "sam account" => IdentityType.SamAccountName, - "sam account name" => IdentityType.SamAccountName, - "sam" => IdentityType.SamAccountName, - "securityidentifier" => IdentityType.Sid, - "securityid" => IdentityType.Sid, - "secid" => IdentityType.Sid, - "security identifier" => IdentityType.Sid, - "sid" => IdentityType.Sid, - _ => IdentityType.UserPrincipalName - }; + prop.Value = -1; + directoryEntry.CommitChanges(); + _logger.LogWarning("The User principal last password was updated"); + } + catch (Exception ex) + { + throw new ApiErrorException($"Failed to update password: {ex.Message}", + ApiErrorCode.ChangeNotPermitted); } + } - private PrincipalContext AcquirePrincipalContext() + private void ChangePassword( + string currentPassword, + string newPassword, + AuthenticablePrincipal userPrincipal) + { + try + { + // Try by regular ChangePassword method + userPrincipal.ChangePassword(currentPassword, newPassword); + } + catch { if (_options.UseAutomaticContext) { - _logger.LogWarning("Using AutomaticContext"); - return new PrincipalContext(ContextType.Domain); + _logger.LogWarning("The User principal password cannot be changed and setPassword won't be called"); + + throw; } - var domain = $"{_options.LdapHostnames.First()}:{_options.LdapPort}"; - _logger.LogWarning($"Not using AutomaticContext {domain}"); + // If the previous attempt failed, use the SetPassword method. + userPrincipal.SetPassword(newPassword); - return new PrincipalContext( - ContextType.Domain, - domain, - _options.LdapUsername, - _options.LdapPassword); + _logger.LogDebug("The User principal password updated with setPassword"); } + } - private int AcquireDomainPasswordLength() + /// + /// Use the values from appsettings.IdTypeForUser as fault-tolerant as possible. + /// + private void SetIdType() + { + _idType = _options.IdTypeForUser?.Trim().ToLower() switch { - DirectoryEntry entry; - if (_options.UseAutomaticContext) - { - entry = Domain.GetCurrentDomain().GetDirectoryEntry(); - } - else - { - entry = new DirectoryEntry( - $"{_options.LdapHostnames.First()}:{_options.LdapPort}", - _options.LdapUsername, - _options.LdapPassword - ); - } - return (int)entry.Properties["minPwdLength"].Value; + "distinguishedname" => IdentityType.DistinguishedName, + "distinguished name" => IdentityType.DistinguishedName, + "dn" => IdentityType.DistinguishedName, + "globally unique identifier" => IdentityType.Guid, + "globallyuniqueidentifier" => IdentityType.Guid, + "guid" => IdentityType.Guid, + "name" => IdentityType.Name, + "nm" => IdentityType.Name, + "samaccountname" => IdentityType.SamAccountName, + "accountname" => IdentityType.SamAccountName, + "sam account" => IdentityType.SamAccountName, + "sam account name" => IdentityType.SamAccountName, + "sam" => IdentityType.SamAccountName, + "securityidentifier" => IdentityType.Sid, + "securityid" => IdentityType.Sid, + "secid" => IdentityType.Sid, + "security identifier" => IdentityType.Sid, + "sid" => IdentityType.Sid, + _ => IdentityType.UserPrincipalName + }; + } + + private PrincipalContext AcquirePrincipalContext() + { + if (_options.UseAutomaticContext) + { + _logger.LogWarning("Using AutomaticContext"); + return new PrincipalContext(ContextType.Domain); } + + var domain = $"{_options.LdapHostnames.First()}:{_options.LdapPort}"; + _logger.LogWarning($"Not using AutomaticContext {domain}"); + + return new PrincipalContext( + ContextType.Domain, + domain, + _options.LdapUsername, + _options.LdapPassword); + } + + private int AcquireDomainPasswordLength() + { + DirectoryEntry entry = _options.UseAutomaticContext + ? Domain.GetCurrentDomain().GetDirectoryEntry() + : new DirectoryEntry( + $"{_options.LdapHostnames.First()}:{_options.LdapPort}", + _options.LdapUsername, + _options.LdapPassword + ); + + return (int)entry.Properties["minPwdLength"].Value; } } diff --git a/src/Unosquare.PassCore.PasswordProvider/Unosquare.PassCore.PasswordProvider.csproj b/src/Unosquare.PassCore.PasswordProvider/Unosquare.PassCore.PasswordProvider.csproj index c04fea13..efabba16 100644 --- a/src/Unosquare.PassCore.PasswordProvider/Unosquare.PassCore.PasswordProvider.csproj +++ b/src/Unosquare.PassCore.PasswordProvider/Unosquare.PassCore.PasswordProvider.csproj @@ -1,28 +1,27 @@  - - Copyright (c) 2018-2021 - Unosquare - true - net5.0 - enable - true - ..\..\StyleCop.Analyzers.ruleset - + + Copyright (c) 2018-2022 - Unosquare + true + net6.0 + enable + true + true + AllEnabledByDefault + true + - - - - - - - All - - - + + + + + + + - - - - + + + + diff --git a/src/Unosquare.PassCore.Web/ClientApp/Components/Footer.tsx b/src/Unosquare.PassCore.Web/ClientApp/Components/Footer.tsx index 69cceb01..1077e188 100644 --- a/src/Unosquare.PassCore.Web/ClientApp/Components/Footer.tsx +++ b/src/Unosquare.PassCore.Web/ClientApp/Components/Footer.tsx @@ -25,10 +25,10 @@ export const Footer: React.FunctionComponent = () => ( - Powered by PassCore v4.3.0 - Open Source Initiative and MIT Licensed + Powered by PassCore v4.5.0 - Open Source Initiative and MIT Licensed - Copyright © 2016-2020 Unosquare + Copyright © 2016-2022 Unosquare diff --git a/src/Unosquare.PassCore.Web/ClientApp/package-lock.json b/src/Unosquare.PassCore.Web/ClientApp/package-lock.json index fcc495e6..2883a65f 100644 --- a/src/Unosquare.PassCore.Web/ClientApp/package-lock.json +++ b/src/Unosquare.PassCore.Web/ClientApp/package-lock.json @@ -6278,9 +6278,9 @@ } }, "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "dev": true }, "mixin-deep": { @@ -7900,9 +7900,9 @@ "dev": true }, "react": { - "version": "16.13.0", - "resolved": "https://registry.npmjs.org/react/-/react-16.13.0.tgz", - "integrity": "sha512-TSavZz2iSLkq5/oiE7gnFzmURKZMltmi193rm5HEoUDAXpzT9Kzw6oNZnGoai/4+fUnm7FqS5dwgUL34TujcWQ==", + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz", + "integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==", "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -7910,14 +7910,14 @@ } }, "react-dom": { - "version": "16.13.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.13.0.tgz", - "integrity": "sha512-y09d2c4cG220DzdlFkPTnVvGTszVvNpC73v+AaLGLHbkpy3SSgvYq8x0rNwPJ/Rk/CicTNgk0hbHNw1gMEZAXg==", + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz", + "integrity": "sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==", "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", "prop-types": "^15.6.2", - "scheduler": "^0.19.0" + "scheduler": "^0.19.1" } }, "react-form-validator-core": { @@ -8317,9 +8317,9 @@ } }, "scheduler": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.0.tgz", - "integrity": "sha512-xowbVaTPe9r7y7RUejcK73/j8tt2jfiyTednOvHbA8JoClvMYCp+r8QegLwK/n8zWQAtZb1fFnER4XLBZXrCxA==", + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz", + "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==", "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" diff --git a/src/Unosquare.PassCore.Web/ClientApp/package.json b/src/Unosquare.PassCore.Web/ClientApp/package.json index d6e6c416..924c676f 100644 --- a/src/Unosquare.PassCore.Web/ClientApp/package.json +++ b/src/Unosquare.PassCore.Web/ClientApp/package.json @@ -23,8 +23,8 @@ "dependencies": { "@material-ui/core": "^4.9.5", "@material-ui/icons": "^4.9.1", - "react": "^16.13.0", - "react-dom": "^16.13.0", + "react": "^16.14.0", + "react-dom": "^16.14.0", "uno-material-ui": "^1.7.40", "uno-react": "^0.14.4", "zxcvbn": "^4.4.2" @@ -44,7 +44,7 @@ "eslint-plugin-prettier": "3.2.0", "eslint-plugin-promise": "^4.3.1", "eslint-plugin-react": "7.21.5", - "minimist": "^1.2.5", + "minimist": "^1.2.6", "node-forge": "^0.10.0", "parcel-bundler": "^1.12.5", "parcel-plugin-clean-easy-unsafe": "^1.0.2", diff --git a/src/Unosquare.PassCore.Web/Controllers/HomeController.cs b/src/Unosquare.PassCore.Web/Controllers/HomeController.cs index d015c9f2..d90ded8b 100644 --- a/src/Unosquare.PassCore.Web/Controllers/HomeController.cs +++ b/src/Unosquare.PassCore.Web/Controllers/HomeController.cs @@ -1,15 +1,14 @@ -namespace Unosquare.PassCore.Web.Controllers -{ - using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; + +namespace Unosquare.PassCore.Web.Controllers; - /// - /// This controller is simply a placeholder to redirect any non-matching URL - /// to provide the context of the SPA (single page application) index - /// Examine the routing configuration in the Startup class. - /// - public class HomeController : Controller - { - // GET: // - public IActionResult Index() => File("~/index.html", "text/html"); - } +/// +/// This controller is simply a placeholder to redirect any non-matching URL +/// to provide the context of the SPA (single page application) index +/// Examine the routing configuration in the Startup class. +/// +public class HomeController : Controller +{ + // GET: // + public IActionResult Index() => File("~/index.html", "text/html"); } \ No newline at end of file diff --git a/src/Unosquare.PassCore.Web/Controllers/PasswordController.cs b/src/Unosquare.PassCore.Web/Controllers/PasswordController.cs index 4413e7c4..d5599656 100644 --- a/src/Unosquare.PassCore.Web/Controllers/PasswordController.cs +++ b/src/Unosquare.PassCore.Web/Controllers/PasswordController.cs @@ -1,156 +1,143 @@ -namespace Unosquare.PassCore.Web.Controllers +using Unosquare.PassCore.Web.Helpers; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Unosquare.PassCore.Web.Models; +using Swan.Net; +using Zxcvbn; + +namespace Unosquare.PassCore.Web.Controllers; + +/// +/// Represents a controller class holding all of the server-side functionality of this tool. +/// +[Route("api/[controller]")] +public class PasswordController : Controller { - using Common; - using Helpers; - using Microsoft.AspNetCore.Mvc; - using Microsoft.Extensions.Logging; - using Microsoft.Extensions.Options; - using Models; - using Swan.Net; - using System; - using System.Collections.Generic; - using System.Threading.Tasks; - using Zxcvbn; + private readonly ILogger _logger; + private readonly ClientSettings _options; + private readonly IPasswordChangeProvider _passwordChangeProvider; /// - /// Represents a controller class holding all of the server-side functionality of this tool. + /// Initializes a new instance of the class. /// - [Route("api/[controller]")] - public class PasswordController : Controller + /// The logger. + /// The options accessor. + /// The password change provider. + public PasswordController( + ILogger logger, + IOptions optionsAccessor, + IPasswordChangeProvider passwordChangeProvider) { - private readonly ILogger _logger; - private readonly ClientSettings _options; - private readonly IPasswordChangeProvider _passwordChangeProvider; - - /// - /// Initializes a new instance of the class. - /// - /// The logger. - /// The options accessor. - /// The password change provider. - public PasswordController( - ILogger logger, - IOptions optionsAccessor, - IPasswordChangeProvider passwordChangeProvider) - { - _logger = logger; - _options = optionsAccessor.Value; - _passwordChangeProvider = passwordChangeProvider; - } + _logger = logger; + _options = optionsAccessor.Value; + _passwordChangeProvider = passwordChangeProvider; + } + + /// + /// Returns the ClientSettings object as a JSON string. + /// + /// A Json representation of the ClientSettings object. + [HttpGet] + public IActionResult Get() => Json(_options); + + /// + /// Returns generated password as a JSON string. + /// + /// A Json with a password property which contains a random generated password. + [HttpGet] + [Route("generated")] + public IActionResult GetGeneratedPassword() + { + using var generator = new PasswordGenerator(); + return Json(new { password = generator.Generate(_options.PasswordEntropy) }); + } - /// - /// Returns the ClientSettings object as a JSON string. - /// - /// A Json representation of the ClientSettings object. - [HttpGet] - public IActionResult Get() => Json(_options); - - /// - /// Returns generated password as a JSON string. - /// - /// A Json with a password property which contains a random generated password. - [HttpGet] - [Route("generated")] - public IActionResult GetGeneratedPassword() + /// + /// Given a POST request, processes and changes a User's password. + /// + /// The value. + /// A task representing the async operation. + [HttpPost] + public async Task Post([FromBody] ChangePasswordModel model) + { + if (model.NewPassword != model.NewPasswordVerify) { - using var generator = new PasswordGenerator(); - return Json(new { password = generator.Generate(_options.PasswordEntropy) }); + _logger.LogWarning("Invalid model, passwords don't match"); + + return BadRequest(ApiResult.InvalidRequest()); } - /// - /// Given a POST request, processes and changes a User's password. - /// - /// The value. - /// A task representing the async operation. - [HttpPost] - public async Task Post([FromBody] ChangePasswordModel model) + // Validate the model + if (ModelState.IsValid == false) { - // Validate the request - if (model == null) - { - _logger.LogWarning("Null model"); + _logger.LogWarning("Invalid model, validation failed"); - return BadRequest(ApiResult.InvalidRequest()); - } + return BadRequest(ApiResult.FromModelStateErrors(ModelState)); + } - if (model.NewPassword != model.NewPasswordVerify) - { - _logger.LogWarning("Invalid model, passwords don't match"); + // Validate the Captcha + try + { + if (await ValidateRecaptcha(model.Recaptcha).ConfigureAwait(false) == false) + throw new InvalidOperationException("Invalid Recaptcha response"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Invalid Recaptcha"); + return BadRequest(ApiResult.InvalidCaptcha()); + } - return BadRequest(ApiResult.InvalidRequest()); - } + var result = new ApiResult(); - // Validate the model - if (ModelState.IsValid == false) + try + { + if (_options.MinimumDistance > 0 && + _passwordChangeProvider.MeasureNewPasswordDistance(model.CurrentPassword, model.NewPassword) < _options.MinimumDistance) { - _logger.LogWarning("Invalid model, validation failed"); - - return BadRequest(ApiResult.FromModelStateErrors(ModelState)); + result.Errors.Add(new ApiErrorItem(ApiErrorCode.MinimumDistance)); + return BadRequest(result); } - // Validate the Captcha - try - { - if (await ValidateRecaptcha(model.Recaptcha).ConfigureAwait(false) == false) - throw new InvalidOperationException("Invalid Recaptcha response"); - } - catch (Exception ex) + if (_options.MinimumScore > 0 && Core.EvaluatePassword(model.NewPassword).Score < _options.MinimumScore) { - _logger.LogWarning(ex, "Invalid Recaptcha"); - return BadRequest(ApiResult.InvalidCaptcha()); + result.Errors.Add(new ApiErrorItem(ApiErrorCode.MinimumScore)); + return BadRequest(result); } - var result = new ApiResult(); + var resultPasswordChange = _passwordChangeProvider.PerformPasswordChange( + model.Username, + model.CurrentPassword, + model.NewPassword); - try - { - if (_options.MinimumDistance > 0 && - _passwordChangeProvider.MeasureNewPasswordDistance(model.CurrentPassword, model.NewPassword) < _options.MinimumDistance) - { - result.Errors.Add(new ApiErrorItem(ApiErrorCode.MinimumDistance)); - return BadRequest(result); - } - - if (_options.MinimumScore > 0 && Core.EvaluatePassword(model.NewPassword).Score < _options.MinimumScore) - { - result.Errors.Add(new ApiErrorItem(ApiErrorCode.MinimumScore)); - return BadRequest(result); - } - - var resultPasswordChange = _passwordChangeProvider.PerformPasswordChange( - model.Username, - model.CurrentPassword, - model.NewPassword); - - if (resultPasswordChange == null) - return Json(result); - - result.Errors.Add(resultPasswordChange); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to update password"); + if (resultPasswordChange == null) + return Json(result); - result.Errors.Add(new ApiErrorItem(ApiErrorCode.Generic, ex.Message)); - } + result.Errors.Add(resultPasswordChange); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update password"); - return BadRequest(result); + result.Errors.Add(new ApiErrorItem(ApiErrorCode.Generic, ex.Message)); } - private async Task ValidateRecaptcha(string? recaptchaResponse) - { - // skip validation if we don't enable recaptcha - if ((_options.Recaptcha != null) && string.IsNullOrWhiteSpace(_options.Recaptcha.PrivateKey)) - return true; - else if ((_options.Recaptcha != null) && (string.IsNullOrEmpty(recaptchaResponse) != true)) - { - var requestUrl = new Uri( - $"https://www.google.com/recaptcha/api/siteverify?secret={_options.Recaptcha.PrivateKey}&response={recaptchaResponse}"); - var validationResponse = await JsonClient.Get>(requestUrl) - .ConfigureAwait(false); - return Convert.ToBoolean(validationResponse["success"], System.Globalization.CultureInfo.InvariantCulture); - } + return BadRequest(result); + } + + private async Task ValidateRecaptcha(string? recaptchaResponse) + { + // skip validation if we don't enable recaptcha + if (_options.Recaptcha != null && string.IsNullOrWhiteSpace(_options.Recaptcha.PrivateKey)) + return true; + + if (_options.Recaptcha == null || string.IsNullOrEmpty(recaptchaResponse) == true) return false; - } + + var requestUrl = new Uri( + $"https://www.google.com/recaptcha/api/siteverify?secret={_options.Recaptcha.PrivateKey}&response={recaptchaResponse}"); + var validationResponse = await JsonClient.Get>(requestUrl); + + return Convert.ToBoolean(validationResponse["success"], System.Globalization.CultureInfo.InvariantCulture); } } diff --git a/src/Unosquare.PassCore.Web/GlobalUsings.cs b/src/Unosquare.PassCore.Web/GlobalUsings.cs new file mode 100644 index 00000000..7ee5c93f --- /dev/null +++ b/src/Unosquare.PassCore.Web/GlobalUsings.cs @@ -0,0 +1,5 @@ +global using System.Collections.Generic; +global using System.Linq; +global using System; +global using System.Threading.Tasks; +global using Unosquare.PassCore.Common; \ No newline at end of file diff --git a/src/Unosquare.PassCore.Web/Helpers/DebugAppSettings.cs b/src/Unosquare.PassCore.Web/Helpers/DebugAppSettings.cs index 27504d65..cbc2a947 100644 --- a/src/Unosquare.PassCore.Web/Helpers/DebugAppSettings.cs +++ b/src/Unosquare.PassCore.Web/Helpers/DebugAppSettings.cs @@ -1,34 +1,31 @@ -namespace Unosquare.PassCore.Web.Helpers +namespace Unosquare.PassCore.Web.Helpers; + +public class DebugAppSettings : IAppSettings { - using Common; + private string? defaultDomain; + private string[]? ldapHostnames; + private string? ldapPassword; + private string? ldapUsername; - public class DebugAppSettings : IAppSettings + public string DefaultDomain { - private string? defaultDomain; - private string[]? ldapHostnames; - private string? ldapPassword; - private string? ldapUsername; - - public string DefaultDomain - { - get => defaultDomain ?? string.Empty; - set => defaultDomain = value; - } - public int LdapPort { get; set; } - public string[] LdapHostnames - { - get => ldapHostnames ?? new string[] { }; - set => ldapHostnames = value; - } - public string LdapPassword - { - get => ldapPassword ?? string.Empty; - set => ldapPassword = value; - } - public string LdapUsername - { - get => ldapUsername ?? string.Empty; - set => ldapUsername = value; - } + get => defaultDomain ?? string.Empty; + set => defaultDomain = value; + } + public int LdapPort { get; set; } + public string[] LdapHostnames + { + get => ldapHostnames ?? new string[] { }; + set => ldapHostnames = value; + } + public string LdapPassword + { + get => ldapPassword ?? string.Empty; + set => ldapPassword = value; + } + public string LdapUsername + { + get => ldapUsername ?? string.Empty; + set => ldapUsername = value; } -} +} \ No newline at end of file diff --git a/src/Unosquare.PassCore.Web/Helpers/DebugPasswordChangeProvider.cs b/src/Unosquare.PassCore.Web/Helpers/DebugPasswordChangeProvider.cs index 1f623455..7c1ecfae 100644 --- a/src/Unosquare.PassCore.Web/Helpers/DebugPasswordChangeProvider.cs +++ b/src/Unosquare.PassCore.Web/Helpers/DebugPasswordChangeProvider.cs @@ -1,34 +1,30 @@ -namespace Unosquare.PassCore.Web.Helpers -{ - using System; - using Common; +namespace Unosquare.PassCore.Web.Helpers; - internal class DebugPasswordChangeProvider : IPasswordChangeProvider +internal class DebugPasswordChangeProvider : IPasswordChangeProvider +{ + public ApiErrorItem? PerformPasswordChange(string username, string currentPassword, string newPassword) { - public ApiErrorItem? PerformPasswordChange(string username, string currentPassword, string newPassword) - { - var currentUsername = username.IndexOf("@", StringComparison.Ordinal) > 0 - ? username.Substring(0, username.IndexOf("@", StringComparison.Ordinal)) - : username; + var currentUsername = username.IndexOf("@", StringComparison.Ordinal) > 0 + ? username[..username.IndexOf("@", StringComparison.Ordinal)] + : username; - // Even in DEBUG, it is safe to make this call and check the password anyway - if (PwnedPasswordsSearch.PwnedSearch.IsPwnedPassword(newPassword)) - return new ApiErrorItem(ApiErrorCode.PwnedPassword); + // Even in DEBUG, it is safe to make this call and check the password anyway + if (PwnedPasswordsSearch.PwnedSearch.IsPwnedPassword(newPassword)) + return new ApiErrorItem(ApiErrorCode.PwnedPassword); - return currentUsername switch - { - "error" => new ApiErrorItem(ApiErrorCode.Generic, "Error"), - "changeNotPermitted" => new ApiErrorItem(ApiErrorCode.ChangeNotPermitted), - "fieldMismatch" => new ApiErrorItem(ApiErrorCode.FieldMismatch), - "fieldRequired" => new ApiErrorItem(ApiErrorCode.FieldRequired), - "invalidCaptcha" => new ApiErrorItem(ApiErrorCode.InvalidCaptcha), - "invalidCredentials" => new ApiErrorItem(ApiErrorCode.InvalidCredentials), - "invalidDomain" => new ApiErrorItem(ApiErrorCode.InvalidDomain), - "userNotFound" => new ApiErrorItem(ApiErrorCode.UserNotFound), - "ldapProblem" => new ApiErrorItem(ApiErrorCode.LdapProblem), - "pwnedPassword" => new ApiErrorItem(ApiErrorCode.PwnedPassword), - _ => null - }; - } + return currentUsername switch + { + "error" => new ApiErrorItem(ApiErrorCode.Generic, "Error"), + "changeNotPermitted" => new ApiErrorItem(ApiErrorCode.ChangeNotPermitted), + "fieldMismatch" => new ApiErrorItem(ApiErrorCode.FieldMismatch), + "fieldRequired" => new ApiErrorItem(ApiErrorCode.FieldRequired), + "invalidCaptcha" => new ApiErrorItem(ApiErrorCode.InvalidCaptcha), + "invalidCredentials" => new ApiErrorItem(ApiErrorCode.InvalidCredentials), + "invalidDomain" => new ApiErrorItem(ApiErrorCode.InvalidDomain), + "userNotFound" => new ApiErrorItem(ApiErrorCode.UserNotFound), + "ldapProblem" => new ApiErrorItem(ApiErrorCode.LdapProblem), + "pwnedPassword" => new ApiErrorItem(ApiErrorCode.PwnedPassword), + _ => null + }; } -} +} \ No newline at end of file diff --git a/src/Unosquare.PassCore.Web/Helpers/PasswordGenerator.cs b/src/Unosquare.PassCore.Web/Helpers/PasswordGenerator.cs index c3165687..dfbd6fe5 100644 --- a/src/Unosquare.PassCore.Web/Helpers/PasswordGenerator.cs +++ b/src/Unosquare.PassCore.Web/Helpers/PasswordGenerator.cs @@ -1,21 +1,20 @@ -namespace Unosquare.PassCore.Web.Helpers -{ - using SimpleBase; - using System.Security.Cryptography; +using SimpleBase; +using System.Security.Cryptography; - internal class PasswordGenerator : System.IDisposable - { - private readonly RNGCryptoServiceProvider _rngCsp = new RNGCryptoServiceProvider(); +namespace Unosquare.PassCore.Web.Helpers; - public string Generate(int entropy) - { - var pswBytes = new byte[entropy]; - _rngCsp.GetBytes(pswBytes); +internal class PasswordGenerator : System.IDisposable +{ + private readonly RNGCryptoServiceProvider _rngCsp = new(); - var encoder = new Base85(Base85Alphabet.Ascii85); - return encoder.Encode(pswBytes); - } + public string Generate(int entropy) + { + var pswBytes = new byte[entropy]; + _rngCsp.GetBytes(pswBytes); - public void Dispose() => _rngCsp.Dispose(); + var encoder = new Base85(Base85Alphabet.Ascii85); + return encoder.Encode(pswBytes); } -} + + public void Dispose() => _rngCsp.Dispose(); +} \ No newline at end of file diff --git a/src/Unosquare.PassCore.Web/Models/ApiResult.cs b/src/Unosquare.PassCore.Web/Models/ApiResult.cs index 4330b15a..6e9ea1d5 100644 --- a/src/Unosquare.PassCore.Web/Models/ApiResult.cs +++ b/src/Unosquare.PassCore.Web/Models/ApiResult.cs @@ -1,124 +1,120 @@ -namespace Unosquare.PassCore.Web.Models -{ - using System.Collections.Generic; - using System.Linq; - using Microsoft.AspNetCore.Mvc.ModelBinding; - using Common; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Unosquare.PassCore.Web.Models; +/// +/// Represent a generic response from a REST API call. +/// +public class ApiResult +{ /// - /// Represent a generic response from a REST API call. + /// Initializes a new instance of the class. /// - public class ApiResult + /// The payload. + public ApiResult(object? payload = null) { - /// - /// Initializes a new instance of the class. - /// - /// The payload. - public ApiResult(object? payload = null) - { - Errors = new List(); - Payload = payload; - } + Errors = new List(); + Payload = payload; + } - /// - /// Gets or sets the errors. - /// - public List Errors { get; } + /// + /// Gets or sets the errors. + /// + public List Errors { get; } - /// - /// Gets or sets the payload. - /// - public object? Payload { get; } + /// + /// Gets or sets the payload. + /// + public object? Payload { get; } - /// - /// Creates a generic invalid request response. - /// - /// The ApiResult wih Invalid request error. - public static ApiResult InvalidRequest() - { - var result = new ApiResult("Invalid Request"); - result.Errors.Add(new ApiErrorItem(ApiErrorCode.Generic, "Invalid Request")); + /// + /// Creates a generic invalid request response. + /// + /// The ApiResult wih Invalid request error. + public static ApiResult InvalidRequest() + { + var result = new ApiResult("Invalid Request"); + result.Errors.Add(new ApiErrorItem(ApiErrorCode.Generic, "Invalid Request")); - return result; - } + return result; + } - /// - /// Invalids the captcha. - /// - /// The ApiResult from Invalid Recaptcha. - public static ApiResult InvalidCaptcha() - { - var result = new ApiResult("Invalid Recaptcha"); - result.Errors.Add(new ApiErrorItem(ApiErrorCode.InvalidCaptcha)); + /// + /// Invalids the captcha. + /// + /// The ApiResult from Invalid Recaptcha. + public static ApiResult InvalidCaptcha() + { + var result = new ApiResult("Invalid Recaptcha"); + result.Errors.Add(new ApiErrorItem(ApiErrorCode.InvalidCaptcha)); - return result; - } + return result; + } - /// - /// Adds the model state errors. - /// - /// State of the model. - /// The ApiResult from Model State. - public static ApiResult FromModelStateErrors(ModelStateDictionary modelState) + /// + /// Adds the model state errors. + /// + /// State of the model. + /// The ApiResult from Model State. + public static ApiResult FromModelStateErrors(ModelStateDictionary modelState) + { + var result = new ApiResult(); + + foreach (var (key, value) in modelState.Where(x => x.Value.Errors.Any())) { - var result = new ApiResult(); + var error = value.Errors.First(); - foreach (var (key, value) in modelState.Where(x => x.Value.Errors.Any())) + switch (error.ErrorMessage) { - var error = value.Errors.First(); - - switch (error.ErrorMessage) - { - case nameof(ApiErrorCode.FieldRequired): - result.AddFieldRequiredValidationError(key); - break; - case nameof(ApiErrorCode.FieldMismatch): - result.AddFieldMismatchValidationError(key); - break; - default: - result.AddGenericFieldValidationError(key, error.ErrorMessage); - break; - } + case nameof(ApiErrorCode.FieldRequired): + result.AddFieldRequiredValidationError(key); + break; + case nameof(ApiErrorCode.FieldMismatch): + result.AddFieldMismatchValidationError(key); + break; + default: + result.AddGenericFieldValidationError(key, error.ErrorMessage); + break; } - - return result; } - /// - /// Adds the field required validation error. - /// - /// Name of the field. - private void AddFieldRequiredValidationError(string fieldName) + return result; + } + + /// + /// Adds the field required validation error. + /// + /// Name of the field. + private void AddFieldRequiredValidationError(string fieldName) + { + Errors.Add(new ApiErrorItem(ApiErrorCode.FieldRequired, nameof(ApiErrorCode.FieldRequired)) { - Errors.Add(new ApiErrorItem(ApiErrorCode.FieldRequired, nameof(ApiErrorCode.FieldRequired)) - { - FieldName = fieldName, - }); - } + FieldName = fieldName, + }); + } - /// - /// Adds the field mismatch validation error. - /// - /// Name of the field. - private void AddFieldMismatchValidationError(string fieldName) + /// + /// Adds the field mismatch validation error. + /// + /// Name of the field. + private void AddFieldMismatchValidationError(string fieldName) + { + Errors.Add(new ApiErrorItem(ApiErrorCode.FieldMismatch, nameof(ApiErrorCode.FieldMismatch)) { - Errors.Add(new ApiErrorItem(ApiErrorCode.FieldMismatch, nameof(ApiErrorCode.FieldMismatch)) - { - FieldName = fieldName, - }); - } + FieldName = fieldName, + }); + } - /// - /// Adds the generic field validation error. - /// - /// Name of the field. - /// The message. - private void AddGenericFieldValidationError(string fieldName, string message) + /// + /// Adds the generic field validation error. + /// + /// Name of the field. + /// The message. + private void AddGenericFieldValidationError(string fieldName, string message) + { + Errors.Add(new ApiErrorItem(ApiErrorCode.Generic, message) { - Errors.Add(new ApiErrorItem(ApiErrorCode.Generic, message) - { - FieldName = fieldName, - }); - } + FieldName = fieldName, + }); } } \ No newline at end of file diff --git a/src/Unosquare.PassCore.Web/Models/ChangePasswordForm.cs b/src/Unosquare.PassCore.Web/Models/ChangePasswordForm.cs index 143b94da..b1557f35 100644 --- a/src/Unosquare.PassCore.Web/Models/ChangePasswordForm.cs +++ b/src/Unosquare.PassCore.Web/Models/ChangePasswordForm.cs @@ -1,17 +1,16 @@ -namespace Unosquare.PassCore.Web.Models +namespace Unosquare.PassCore.Web.Models; + +public class ChangePasswordForm { - public class ChangePasswordForm - { - public string? ChangePasswordButtonLabel { get; set; } - public string? CurrentPasswordHelpblock { get; set; } - public string? CurrentPasswordLabel { get; set; } - public string? HelpText { get; set; } - public string? NewPasswordHelpblock { get; set; } - public string? NewPasswordLabel { get; set; } - public string? NewPasswordVerifyHelpblock { get; set; } - public string? NewPasswordVerifyLabel { get; set; } - public string? UsernameDefaultDomainHelperBlock { get; set; } - public string? UsernameHelpblock { get; set; } - public string? UsernameLabel { get; set; } - } + public string? ChangePasswordButtonLabel { get; set; } + public string? CurrentPasswordHelpblock { get; set; } + public string? CurrentPasswordLabel { get; set; } + public string? HelpText { get; set; } + public string? NewPasswordHelpblock { get; set; } + public string? NewPasswordLabel { get; set; } + public string? NewPasswordVerifyHelpblock { get; set; } + public string? NewPasswordVerifyLabel { get; set; } + public string? UsernameDefaultDomainHelperBlock { get; set; } + public string? UsernameHelpblock { get; set; } + public string? UsernameLabel { get; set; } } \ No newline at end of file diff --git a/src/Unosquare.PassCore.Web/Models/ChangePasswordModel.cs b/src/Unosquare.PassCore.Web/Models/ChangePasswordModel.cs index 2c894367..c843eb97 100644 --- a/src/Unosquare.PassCore.Web/Models/ChangePasswordModel.cs +++ b/src/Unosquare.PassCore.Web/Models/ChangePasswordModel.cs @@ -1,49 +1,47 @@ -namespace Unosquare.PassCore.Web.Models +using System.ComponentModel.DataAnnotations; + +namespace Unosquare.PassCore.Web.Models; + +public class ChangePasswordModel { - using Common; - using System.ComponentModel.DataAnnotations; + private string? _username; + private string? _currentPassword; + private string? _newPassword; + private string? _newPasswordVerify; + private string? _recaptcha; - public class ChangePasswordModel + [Required(ErrorMessage = nameof(ApiErrorCode.FieldRequired))] + public string Username { - private string? username; - private string? currentPassword; - private string? newPassword; - private string? newPasswordVerify; - private string? recaptcha; - - [Required(ErrorMessage = nameof(ApiErrorCode.FieldRequired))] - public string Username - { - get => username ?? string.Empty; - set => username = value; - } + get => _username ?? string.Empty; + set => _username = value; + } - [Required(ErrorMessage = nameof(ApiErrorCode.FieldRequired))] - public string CurrentPassword - { - get => currentPassword ?? string.Empty; - set => currentPassword = value; - } + [Required(ErrorMessage = nameof(ApiErrorCode.FieldRequired))] + public string CurrentPassword + { + get => _currentPassword ?? string.Empty; + set => _currentPassword = value; + } - [Required(ErrorMessage = nameof(ApiErrorCode.FieldRequired))] - public string NewPassword - { - get => newPassword ?? string.Empty; - set => newPassword = value; - } + [Required(ErrorMessage = nameof(ApiErrorCode.FieldRequired))] + public string NewPassword + { + get => _newPassword ?? string.Empty; + set => _newPassword = value; + } - [Required(ErrorMessage = nameof(ApiErrorCode.FieldRequired))] - [Compare(nameof(NewPassword), ErrorMessage = nameof(ApiErrorCode.FieldMismatch))] - public string NewPasswordVerify - { - get => newPasswordVerify ?? string.Empty; - set => newPasswordVerify = value; - } + [Required(ErrorMessage = nameof(ApiErrorCode.FieldRequired))] + [Compare(nameof(NewPassword), ErrorMessage = nameof(ApiErrorCode.FieldMismatch))] + public string NewPasswordVerify + { + get => _newPasswordVerify ?? string.Empty; + set => _newPasswordVerify = value; + } - public string Recaptcha - { - get => recaptcha ?? string.Empty; - set => recaptcha = value; - } + public string Recaptcha + { + get => _recaptcha ?? string.Empty; + set => _recaptcha = value; } -} +} \ No newline at end of file diff --git a/src/Unosquare.PassCore.Web/Models/ClientSettings.cs b/src/Unosquare.PassCore.Web/Models/ClientSettings.cs index 86dcd4cc..187bc3cc 100644 --- a/src/Unosquare.PassCore.Web/Models/ClientSettings.cs +++ b/src/Unosquare.PassCore.Web/Models/ClientSettings.cs @@ -1,65 +1,64 @@ -namespace Unosquare.PassCore.Web.Models -{ - using System.Text.Json.Serialization; +using System.Text.Json.Serialization; - /// - /// Represents all of the strongly-typed application settings loaded from a JSON file. - /// - public class ClientSettings - { - public Alerts? Alerts { get; set; } - public bool UsePasswordGeneration { get; set; } - public int MinimumDistance { get; set; } - public int PasswordEntropy { get; set; } - public int MinimumScore { get; set; } - public bool ShowPasswordMeter { get; set; } - public bool UseEmail { get; set; } - public ChangePasswordForm? ChangePasswordForm { get; set; } - public ErrorsPasswordForm? ErrorsPasswordForm { get; set; } - public Recaptcha? Recaptcha { get; set; } - public string? ApplicationTitle { get; set; } - public string? ChangePasswordTitle { get; set; } - public ValidationRegex? ValidationRegex { get; set; } - } +namespace Unosquare.PassCore.Web.Models; - public class Recaptcha - { - public string? LanguageCode { get; set; } - public string? SiteKey { get; set; } +/// +/// Represents all of the strongly-typed application settings loaded from a JSON file. +/// +public class ClientSettings +{ + public Alerts? Alerts { get; set; } + public bool UsePasswordGeneration { get; set; } + public int MinimumDistance { get; set; } + public int PasswordEntropy { get; set; } + public int MinimumScore { get; set; } + public bool ShowPasswordMeter { get; set; } + public bool UseEmail { get; set; } + public ChangePasswordForm? ChangePasswordForm { get; set; } + public ErrorsPasswordForm? ErrorsPasswordForm { get; set; } + public Recaptcha? Recaptcha { get; set; } + public string? ApplicationTitle { get; set; } + public string? ChangePasswordTitle { get; set; } + public ValidationRegex? ValidationRegex { get; set; } +} - [JsonIgnore] - public string? PrivateKey { get; set; } - } +public class Recaptcha +{ + public string? LanguageCode { get; set; } + public string? SiteKey { get; set; } - public class Alerts - { - public string? ErrorInvalidCredentials { get; set; } - public string? ErrorInvalidDomain { get; set; } - public string? ErrorPasswordChangeNotAllowed { get; set; } - public string? SuccessAlertBody { get; set; } - public string? SuccessAlertTitle { get; set; } - public string? ErrorInvalidUser { get; set; } - public string? ErrorCaptcha { get; set; } - public string? ErrorFieldRequired { get; set; } - public string? ErrorFieldMismatch { get; set; } - public string? ErrorComplexPassword { get; set; } - public string? ErrorConnectionLdap { get; set; } - public string? ErrorScorePassword { get; set; } - public string? ErrorDistancePassword { get; set; } - public string? ErrorPwnedPassword { get; set; } - } + [JsonIgnore] + public string? PrivateKey { get; set; } +} - public class ErrorsPasswordForm - { - public string? FieldRequired { get; set; } - public string? PasswordMatch { get; set; } - public string? UsernameEmailPattern { get; set; } - public string? UsernamePattern { get; set; } - } +public class Alerts +{ + public string? ErrorInvalidCredentials { get; set; } + public string? ErrorInvalidDomain { get; set; } + public string? ErrorPasswordChangeNotAllowed { get; set; } + public string? SuccessAlertBody { get; set; } + public string? SuccessAlertTitle { get; set; } + public string? ErrorInvalidUser { get; set; } + public string? ErrorCaptcha { get; set; } + public string? ErrorFieldRequired { get; set; } + public string? ErrorFieldMismatch { get; set; } + public string? ErrorComplexPassword { get; set; } + public string? ErrorConnectionLdap { get; set; } + public string? ErrorScorePassword { get; set; } + public string? ErrorDistancePassword { get; set; } + public string? ErrorPwnedPassword { get; set; } +} - public class ValidationRegex - { - public string? EmailRegex { get; set; } - public string? UsernameRegex { get; set; } - } +public class ErrorsPasswordForm +{ + public string? FieldRequired { get; set; } + public string? PasswordMatch { get; set; } + public string? UsernameEmailPattern { get; set; } + public string? UsernamePattern { get; set; } +} + +public class ValidationRegex +{ + public string? EmailRegex { get; set; } + public string? UsernameRegex { get; set; } } \ No newline at end of file diff --git a/src/Unosquare.PassCore.Web/Models/WebSettings.cs b/src/Unosquare.PassCore.Web/Models/WebSettings.cs index 655abffc..c0ea117d 100644 --- a/src/Unosquare.PassCore.Web/Models/WebSettings.cs +++ b/src/Unosquare.PassCore.Web/Models/WebSettings.cs @@ -1,16 +1,15 @@ -namespace Unosquare.PassCore.Web.Models +namespace Unosquare.PassCore.Web.Models; + +/// +/// Represents the Web server settings. +/// +public class WebSettings { /// - /// Represents the Web server settings. + /// Gets or sets a value indicating whether [enable HTTPS redirect]. /// - public class WebSettings - { - /// - /// Gets or sets a value indicating whether [enable HTTPS redirect]. - /// - /// - /// true if [enable HTTPS redirect]; otherwise, false. - /// - public bool EnableHttpsRedirect { get; set; } - } -} + /// + /// true if [enable HTTPS redirect]; otherwise, false. + /// + public bool EnableHttpsRedirect { get; set; } +} \ No newline at end of file diff --git a/src/Unosquare.PassCore.Web/Startup.cs b/src/Unosquare.PassCore.Web/Startup.cs index 2c55bf28..c20badcd 100644 --- a/src/Unosquare.PassCore.Web/Startup.cs +++ b/src/Unosquare.PassCore.Web/Startup.cs @@ -1,110 +1,107 @@ -namespace Unosquare.PassCore.Web -{ - using Common; - using Microsoft.AspNetCore; - using Microsoft.AspNetCore.Builder; - using Microsoft.AspNetCore.Hosting; - using Microsoft.Extensions.Configuration; - using Microsoft.Extensions.DependencyInjection; - using Microsoft.Extensions.Options; - using Models; - using System.Threading.Tasks; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Unosquare.PassCore.Web.Models; #if DEBUG - using Helpers; +using Unosquare.PassCore.Web.Helpers; #elif PASSCORE_LDAP_PROVIDER - using Zyborg.PassCore.PasswordProvider.LDAP; - using Microsoft.Extensions.Logging; +using Zyborg.PassCore.PasswordProvider.LDAP; +using Microsoft.Extensions.Logging; #else - using PasswordProvider; +using Unosquare.PassCore.PasswordProvider; #endif +namespace Unosquare.PassCore.Web; + +/// +/// Represents this application's main class. +/// +public class Startup +{ + private const string AppSettingsSectionName = "AppSettings"; + /// - /// Represents this application's main class. + /// Initializes a new instance of the class. + /// This class gets instantiated by the Main method. The hosting environment gets provided via DI. /// - public class Startup - { - private const string AppSettingsSectionName = "AppSettings"; - - /// - /// Initializes a new instance of the class. - /// This class gets instantiated by the Main method. The hosting environment gets provided via DI. - /// - /// The configuration. - public Startup(IConfiguration config) => Configuration = config; + /// The configuration. + public Startup(IConfiguration config) => Configuration = config; - /// - /// Gets or sets the configuration. - /// - /// - /// The configuration. - /// - public IConfiguration Configuration { get; } + /// + /// Gets or sets the configuration. + /// + /// + /// The configuration. + /// + public IConfiguration Configuration { get; } - /// - /// Application's entry point. - /// - /// The arguments. - public static async Task Main(string[] args) => await WebHost.CreateDefaultBuilder(args).UseStartup().Build().RunAsync(); + /// + /// Application's entry point. + /// + /// The arguments. + public static async Task Main(string[] args) => await WebHost.CreateDefaultBuilder(args).UseStartup().Build().RunAsync(); - /// - /// Creates the web host builder. - /// - /// The arguments. - /// The web host builder. - public static IWebHostBuilder CreateWebHostBuilder(string[] args) => - WebHost.CreateDefaultBuilder(args) + /// + /// Creates the web host builder. + /// + /// The arguments. + /// The web host builder. + public static IWebHostBuilder CreateWebHostBuilder(string[] args) => + WebHost.CreateDefaultBuilder(args) .UseStartup(); - /// - /// This method gets called by the runtime. Use this method to add services to the container. - /// All arguments are provided through dependency injection. - /// - /// The services. - public void ConfigureServices(IServiceCollection services) - { - services.Configure(Configuration.GetSection(nameof(ClientSettings))); - services.Configure(Configuration.GetSection(nameof(WebSettings))); + /// + /// This method gets called by the runtime. Use this method to add services to the container. + /// All arguments are provided through dependency injection. + /// + /// The services. + public void ConfigureServices(IServiceCollection services) + { + services.Configure(Configuration.GetSection(nameof(ClientSettings))); + services.Configure(Configuration.GetSection(nameof(WebSettings))); #if DEBUG - services.Configure(Configuration.GetSection(AppSettingsSectionName)); - services.AddSingleton(); + services.Configure(Configuration.GetSection(AppSettingsSectionName)); + services.AddSingleton(); #elif PASSCORE_LDAP_PROVIDER - services.Configure(Configuration.GetSection(AppSettingsSectionName)); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(typeof(ILogger), sp => - { - var loggerFactory = sp.GetService(); - return loggerFactory.CreateLogger("PassCoreLDAPProvider"); - }); + services.Configure(Configuration.GetSection(AppSettingsSectionName)); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(typeof(ILogger), sp => + { + var loggerFactory = sp.GetService(); + return loggerFactory.CreateLogger("PassCoreLDAPProvider"); + }); #else - services.Configure(Configuration.GetSection(AppSettingsSectionName)); - services.AddSingleton(); + services.Configure(Configuration.GetSection(AppSettingsSectionName)); + services.AddSingleton(); #endif - // Add framework services. - services.AddControllers(); - } + // Add framework services. + services.AddControllers(); + } - /// - /// This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - /// All arguments are provided through dependency injection. - /// - /// The application. - /// The settings. - public void Configure(IApplicationBuilder app, IOptions settings) - { - if (settings.Value.EnableHttpsRedirect) - app.UseHttpsRedirection(); + /// + /// This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + /// All arguments are provided through dependency injection. + /// + /// The application. + /// The settings. + public void Configure(IApplicationBuilder app, IOptions settings) + { + if (settings.Value.EnableHttpsRedirect) + app.UseHttpsRedirection(); - app.UseDefaultFiles(); - app.UseStaticFiles(); + app.UseDefaultFiles(); + app.UseStaticFiles(); - app.UseRouting(); + app.UseRouting(); - app.UseEndpoints(endpoints => - { - endpoints.MapControllers(); - }); - } + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); } } diff --git a/src/Unosquare.PassCore.Web/Unosquare.PassCore.Web.csproj b/src/Unosquare.PassCore.Web/Unosquare.PassCore.Web.csproj index c1c9ed73..3bce6b1a 100644 --- a/src/Unosquare.PassCore.Web/Unosquare.PassCore.Web.csproj +++ b/src/Unosquare.PassCore.Web/Unosquare.PassCore.Web.csproj @@ -1,66 +1,64 @@  - - $(DefaultItemExcludes);**\node_modules\**;node_modules\** - Copyright (c) 2018-2021 - Unosquare - net5.0 - Unosquare.PassCore.Web - Unosquare.PassCore.Web - true - false - ..\..\StyleCop.Analyzers.ruleset - 4.2 - true - Unosquare.PassCore.Web - 8.0 - enable - true - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - all - - - - - - - - - - - Always - - - - - - - - - - - - - - - - PASSCORE_LDAP_PROVIDER - - - - - - - - - - - - - + + $(DefaultItemExcludes);**\node_modules\**;node_modules\** + Copyright (c) 2018-2021 - Unosquare + net6.0 + Unosquare.PassCore.Web + Unosquare.PassCore.Web + true + false + 4.2 + true + Unosquare.PassCore.Web + true + AllEnabledByDefault + true + enable + true + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + Always + + + + + + + + + + + + + + + + PASSCORE_LDAP_PROVIDER + + + + + + + + + + + + + diff --git a/src/Zyborg.PassCore.PasswordProvider.LDAP/LdapPasswordChangeOptions.cs b/src/Zyborg.PassCore.PasswordProvider.LDAP/LdapPasswordChangeOptions.cs index c0e81075..a59d80fa 100644 --- a/src/Zyborg.PassCore.PasswordProvider.LDAP/LdapPasswordChangeOptions.cs +++ b/src/Zyborg.PassCore.PasswordProvider.LDAP/LdapPasswordChangeOptions.cs @@ -1,139 +1,138 @@ -namespace Zyborg.PassCore.PasswordProvider.LDAP +using Novell.Directory.Ldap; +using Unosquare.PassCore.Common; + +namespace Zyborg.PassCore.PasswordProvider.LDAP; + +/// +/// Represents the options of this provider. +/// +/// +public class LdapPasswordChangeOptions : IAppSettings { - using Novell.Directory.Ldap; - using Unosquare.PassCore.Common; + private string[]? ldapHostnames; + private string? ldapPassword; + private string? ldapUsername; + private string? defaultDomain; - /// - /// Represents the options of this provider. - /// - /// - public class LdapPasswordChangeOptions : IAppSettings + /// + public string[] LdapHostnames { - private string[]? ldapHostnames; - private string? ldapPassword; - private string? ldapUsername; - private string? defaultDomain; - - /// - public string[] LdapHostnames - { - get => ldapHostnames ?? new string[] { }; - set => ldapHostnames = value; - } + get => ldapHostnames ?? new string[] { }; + set => ldapHostnames = value; + } - /// - public string LdapPassword - { - get => ldapPassword ?? string.Empty; - set => ldapPassword = value; - } + /// + public string LdapPassword + { + get => ldapPassword ?? string.Empty; + set => ldapPassword = value; + } - /// - public string LdapUsername - { - get => ldapUsername ?? string.Empty; - set => ldapUsername = value; - } + /// + public string LdapUsername + { + get => ldapUsername ?? string.Empty; + set => ldapUsername = value; + } - /// - public string DefaultDomain - { - get => defaultDomain ?? string.Empty; - set => defaultDomain = value; - } + /// + public string DefaultDomain + { + get => defaultDomain ?? string.Empty; + set => defaultDomain = value; + } - /// - public int LdapPort { get; set; } = LdapConnection.DefaultSslPort; + /// + public int LdapPort { get; set; } = LdapConnection.DefaultSslPort; - /// - /// Gets or sets a value indicating whether [LDAP uses SSL]. - /// - /// - /// Optional, if 'true', then the specified port is using SSL encryption. - /// By default this should set to 'true' when using port 636. - /// - /// - /// true if [LDAP uses SSL]; otherwise, false. - /// - public bool LdapSecureSocketLayer { get; set; } + /// + /// Gets or sets a value indicating whether [LDAP uses SSL]. + /// + /// + /// Optional, if 'true', then the specified port is using SSL encryption. + /// By default this should set to 'true' when using port 636. + /// + /// + /// true if [LDAP uses SSL]; otherwise, false. + /// + public bool LdapSecureSocketLayer { get; set; } - /// - /// Gets or sets a value indicating whether [LDAP start TLS]. - /// - /// - /// Optional, if 'true', then the specified port is a non-secured port by default - /// and requires the use of the "StartTLS" command over LDAP to enable TLS. - /// - /// - /// true if [LDAP start TLS]; otherwise, false. - /// - public bool LdapStartTls { get; set; } + /// + /// Gets or sets a value indicating whether [LDAP start TLS]. + /// + /// + /// Optional, if 'true', then the specified port is a non-secured port by default + /// and requires the use of the "StartTLS" command over LDAP to enable TLS. + /// + /// + /// true if [LDAP start TLS]; otherwise, false. + /// + public bool LdapStartTls { get; set; } - /// - /// Gets or sets a value indicating whether [LDAP ignore TLS errors]. - /// - /// - /// Optional, if 'true', then server certificates will be ignored for expiration - /// or common name mismatch. Note this is a SUPERSET of the LdapIgnoreTlsValidation - /// options, so you don't have to set both. - /// - /// - /// true if [LDAP ignore TLS errors]; otherwise, false. - /// - public bool LdapIgnoreTlsErrors { get; set; } + /// + /// Gets or sets a value indicating whether [LDAP ignore TLS errors]. + /// + /// + /// Optional, if 'true', then server certificates will be ignored for expiration + /// or common name mismatch. Note this is a SUPERSET of the LdapIgnoreTlsValidation + /// options, so you don't have to set both. + /// + /// + /// true if [LDAP ignore TLS errors]; otherwise, false. + /// + public bool LdapIgnoreTlsErrors { get; set; } - /// - /// Gets or sets a value indicating whether [LDAP ignore TLS validation]. - /// - /// - /// Optional, if 'true', then server certificates will be accepted regardless - /// of being signed by a trusted CA or intermediary (e.g. self-signed). - /// - /// - /// true if [LDAP ignore TLS validation]; otherwise, false. - /// - public bool LdapIgnoreTlsValidation { get; set; } + /// + /// Gets or sets a value indicating whether [LDAP ignore TLS validation]. + /// + /// + /// Optional, if 'true', then server certificates will be accepted regardless + /// of being signed by a trusted CA or intermediary (e.g. self-signed). + /// + /// + /// true if [LDAP ignore TLS validation]; otherwise, false. + /// + public bool LdapIgnoreTlsValidation { get; set; } - /// - /// Gets or sets the LDAP search base. - /// - /// - /// Distinguished Name (DN) of the base OU from which to search for - /// the target users by their username (SAM Account Name). - /// - /// - /// The LDAP search base. - /// - public string? LdapSearchBase { get; set; } + /// + /// Gets or sets the LDAP search base. + /// + /// + /// Distinguished Name (DN) of the base OU from which to search for + /// the target users by their username (SAM Account Name). + /// + /// + /// The LDAP search base. + /// + public string? LdapSearchBase { get; set; } - /// - /// Gets or sets a value indicating whether [hide user not found]. - /// - /// - /// When the user cannot be located in the directory, you can - /// either expose that error, or hide it and treat like an arbitrary - /// bad credential -- in order to prevent brute force attack to - /// discover the presence or absence of a username. - /// - /// - /// true if [hide user not found]; otherwise, false. - /// - public bool HideUserNotFound { get; set; } = true; + /// + /// Gets or sets a value indicating whether [hide user not found]. + /// + /// + /// When the user cannot be located in the directory, you can + /// either expose that error, or hide it and treat like an arbitrary + /// bad credential -- in order to prevent brute force attack to + /// discover the presence or absence of a username. + /// + /// + /// true if [hide user not found]; otherwise, false. + /// + public bool HideUserNotFound { get; set; } = true; - /// - /// Gets or sets a value indicating whether [LDAP change password with delete add]. - /// - /// - /// true if [LDAP change password with delete add]; otherwise, false. - /// - public bool LdapChangePasswordWithDelAdd { get; set; } = true; + /// + /// Gets or sets a value indicating whether [LDAP change password with delete add]. + /// + /// + /// true if [LDAP change password with delete add]; otherwise, false. + /// + public bool LdapChangePasswordWithDelAdd { get; set; } = true; - /// - /// Gets or sets the LDAP search filter. - /// - /// - /// The LDAP search filter. - /// - public string LdapSearchFilter { get; set; } = "(sAMAccountName={Username})"; - } -} + /// + /// Gets or sets the LDAP search filter. + /// + /// + /// The LDAP search filter. + /// + public string LdapSearchFilter { get; set; } = "(sAMAccountName={Username})"; +} \ No newline at end of file diff --git a/src/Zyborg.PassCore.PasswordProvider.LDAP/LdapPasswordChangeProvider.cs b/src/Zyborg.PassCore.PasswordProvider.LDAP/LdapPasswordChangeProvider.cs index e5645ce2..34aa04eb 100644 --- a/src/Zyborg.PassCore.PasswordProvider.LDAP/LdapPasswordChangeProvider.cs +++ b/src/Zyborg.PassCore.PasswordProvider.LDAP/LdapPasswordChangeProvider.cs @@ -1,364 +1,361 @@ -namespace Zyborg.PassCore.PasswordProvider.LDAP +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Novell.Directory.Ldap; +using System; +using System.Globalization; +using System.Linq; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.RegularExpressions; +using Unosquare.PassCore.Common; +using LdapRemoteCertificateValidationCallback = + Novell.Directory.Ldap.RemoteCertificateValidationCallback; + +namespace Zyborg.PassCore.PasswordProvider.LDAP; + +/// +/// Represents a LDAP password change provider using Novell LDAP Connection. +/// +/// +public class LdapPasswordChangeProvider : IPasswordChangeProvider { - using Microsoft.Extensions.Logging; - using Microsoft.Extensions.Options; - using Novell.Directory.Ldap; - using System; - using System.Globalization; - using System.Linq; - using System.Net.Security; - using System.Security.Cryptography.X509Certificates; - using System.Text; - using System.Text.RegularExpressions; - using Unosquare.PassCore.Common; - using LdapRemoteCertificateValidationCallback = - Novell.Directory.Ldap.RemoteCertificateValidationCallback; + private readonly LdapPasswordChangeOptions _options; + private readonly ILogger _logger; + + // First find user DN by username (SAM Account Name) + private readonly LdapSearchConstraints _searchConstraints = new( + 0, + 0, + LdapSearchConstraints.DerefNever, + 1000, + true, + 1, + null, + 10); + + // TODO: is this related to https://github.com/dsbenghe/Novell.Directory.Ldap.NETStandard/issues/101 at all??? + // Had to mark this as nullable. + private LdapRemoteCertificateValidationCallback? _ldapRemoteCertValidator; /// - /// Represents a LDAP password change provider using Novell LDAP Connection. + /// Initializes a new instance of the class. /// - /// - public class LdapPasswordChangeProvider : IPasswordChangeProvider + /// The logger. + /// The _options. + public LdapPasswordChangeProvider( + ILogger logger, + IOptions options) { - private readonly LdapPasswordChangeOptions _options; - private readonly ILogger _logger; - - // First find user DN by username (SAM Account Name) - private readonly LdapSearchConstraints _searchConstraints = new LdapSearchConstraints( - 0, - 0, - LdapSearchConstraints.DerefNever, - 1000, - true, - 1, - null, - 10); - - // TODO: is this related to https://github.com/dsbenghe/Novell.Directory.Ldap.NETStandard/issues/101 at all??? - // Had to mark this as nullable. - private LdapRemoteCertificateValidationCallback? _ldapRemoteCertValidator; - - /// - /// Initializes a new instance of the class. - /// - /// The logger. - /// The _options. - public LdapPasswordChangeProvider( - ILogger logger, - IOptions options) - { - _logger = logger; - _options = options.Value; - Init(); - } + _logger = logger; + _options = options.Value; + Init(); + } - /// - /// - /// Based on: - /// * https://www.cs.bham.ac.uk/~smp/resources/ad-passwds/ - /// * https://support.microsoft.com/en-us/help/269190/how-to-change-a-windows-active-directory-and-lds-user-password-through - /// * https://ltb-project.org/documentation/self-service-password/latest/config_ldap#active_directory - /// * https://technet.microsoft.com/en-us/library/ff848710.aspx?f=255&MSPPError=-2147217396 - /// - /// Check the above links for more information. - /// - [Obsolete] - public ApiErrorItem? PerformPasswordChange( - string username, - string currentPassword, - string newPassword) + /// + /// + /// Based on: + /// * https://www.cs.bham.ac.uk/~smp/resources/ad-passwds/ + /// * https://support.microsoft.com/en-us/help/269190/how-to-change-a-windows-active-directory-and-lds-user-password-through + /// * https://ltb-project.org/documentation/self-service-password/latest/config_ldap#active_directory + /// * https://technet.microsoft.com/en-us/library/ff848710.aspx?f=255&MSPPError=-2147217396 + /// + /// Check the above links for more information. + /// + [Obsolete] + public ApiErrorItem? PerformPasswordChange( + string username, + string currentPassword, + string newPassword) + { + try { - try + var cleanUsername = CleaningUsername(username); + + var searchFilter = _options.LdapSearchFilter.Replace("{Username}", cleanUsername); + + _logger.LogWarning("LDAP query: {0}", searchFilter); + + using var ldap = BindToLdap(); + var search = ldap.Search( + _options.LdapSearchBase, + LdapConnection.ScopeSub, + searchFilter, + new[] { "distinguishedName" }, + false, + _searchConstraints); + + // We cannot use search.Count here -- apparently it does not + // wait for the results to return before resolving the count + // but fortunately hasMore seems to block until final result + if (!search.HasMore()) { - var cleanUsername = CleaningUsername(username); - - var searchFilter = _options.LdapSearchFilter.Replace("{Username}", cleanUsername); - - _logger.LogWarning("LDAP query: {0}", searchFilter); - - using var ldap = BindToLdap(); - var search = ldap.Search( - _options.LdapSearchBase, - LdapConnection.ScopeSub, - searchFilter, - new[] { "distinguishedName" }, - false, - _searchConstraints); - - // We cannot use search.Count here -- apparently it does not - // wait for the results to return before resolving the count - // but fortunately hasMore seems to block until final result - if (!search.HasMore()) - { - _logger.LogWarning("Unable to find username: [{0}]", cleanUsername); - - return new ApiErrorItem( - _options.HideUserNotFound ? ApiErrorCode.InvalidCredentials : ApiErrorCode.UserNotFound, - _options.HideUserNotFound ? "Invalid credentials" : "Username could not be located"); - } - - if (search.Count > 1) - { - _logger.LogWarning("Found multiple with same username: [{0}] - Count {1}", cleanUsername, search.Count); - - // Hopefully this should not ever happen if AD is preserving SAM Account Name - // uniqueness constraint, but just in case, handling this corner case - return new ApiErrorItem(ApiErrorCode.UserNotFound, "Multiple matching user entries resolved"); - } - - var userDN = search.Next().Dn; - - if (_options.LdapChangePasswordWithDelAdd) - { - ChangePasswordDelAdd(currentPassword, newPassword, ldap, userDN); - } - else - { - ChangePasswordReplace(newPassword, ldap, userDN); - } - - if (_options.LdapStartTls) - ldap.StopTls(); - - ldap.Disconnect(); + _logger.LogWarning("Unable to find username: [{0}]", cleanUsername); + + return new ApiErrorItem( + _options.HideUserNotFound ? ApiErrorCode.InvalidCredentials : ApiErrorCode.UserNotFound, + _options.HideUserNotFound ? "Invalid credentials" : "Username could not be located"); } - catch (LdapException ex) - { - var item = ParseLdapException(ex); - _logger.LogWarning(item.Message, ex); + if (search.Count > 1) + { + _logger.LogWarning("Found multiple with same username: [{0}] - Count {1}", cleanUsername, search.Count); - return item; + // Hopefully this should not ever happen if AD is preserving SAM Account Name + // uniqueness constraint, but just in case, handling this corner case + return new ApiErrorItem(ApiErrorCode.UserNotFound, "Multiple matching user entries resolved"); } - catch (Exception ex) - { - var item = ex is ApiErrorException apiError - ? apiError.ToApiErrorItem() - : new ApiErrorItem(ApiErrorCode.InvalidCredentials, $"Failed to update password: {ex.Message}"); - _logger.LogWarning(item.Message, ex); + var userDN = search.Next().Dn; - return item; + if (_options.LdapChangePasswordWithDelAdd) + { + ChangePasswordDelAdd(currentPassword, newPassword, ldap, userDN); + } + else + { + ChangePasswordReplace(newPassword, ldap, userDN); } - // Everything seems to have worked: - return null; - } + if (_options.LdapStartTls) + ldap.StopTls(); - private static void ChangePasswordReplace(string newPassword, ILdapConnection ldap, string userDN) - { - // If you don't have the rights to Add and/or Delete the Attribute, you might have the right to change the password-attribute. - // In this case uncomment the next 2 lines and comment the region 'Change Password by Delete/Add' - var attribute = new LdapAttribute("userPassword", newPassword); - var ldapReplace = new LdapModification(LdapModification.Replace, attribute); - ldap.Modify(userDN, new[] { ldapReplace }); // Change with Replace + ldap.Disconnect(); } + catch (LdapException ex) + { + var item = ParseLdapException(ex); - private static void ChangePasswordDelAdd(string currentPassword, string newPassword, ILdapConnection ldap, string userDN) + _logger.LogWarning(item.Message, ex); + + return item; + } + catch (Exception ex) { - var oldPassBytes = Encoding.Unicode.GetBytes($@"""{currentPassword}"""); - var newPassBytes = Encoding.Unicode.GetBytes($@"""{newPassword}"""); + var item = ex is ApiErrorException apiError + ? apiError.ToApiErrorItem() + : new ApiErrorItem(ApiErrorCode.InvalidCredentials, $"Failed to update password: {ex.Message}"); - var oldAttr = new LdapAttribute("unicodePwd", oldPassBytes); - var newAttr = new LdapAttribute("unicodePwd", newPassBytes); + _logger.LogWarning(item.Message, ex); - var ldapDel = new LdapModification(LdapModification.Delete, oldAttr); - var ldapAdd = new LdapModification(LdapModification.Add, newAttr); - ldap.Modify(userDN, new[] { ldapDel, ldapAdd }); // Change with Delete/Add + return item; } - private static ApiErrorItem ParseLdapException(LdapException ex) - { - // If the LDAP server returned an error, it will be formatted - // similar to this: - // "0000052D: AtrErr: DSID-03191083, #1:\n\t0: 0000052D: DSID-03191083, problem 1005 (CONSTRAINT_ATT_TYPE), data 0, Att 9005a (unicodePwd)\n\0" - // - // The leading number before the ':' is the Win32 API Error Code in HEX - if (ex.LdapErrorMessage == null) - { - return new ApiErrorItem(ApiErrorCode.LdapProblem, "Unexpected null exception"); - } + // Everything seems to have worked: + return null; + } - var m = Regex.Match(ex.LdapErrorMessage, "([0-9a-fA-F]+):"); + private static void ChangePasswordReplace(string newPassword, ILdapConnection ldap, string userDN) + { + // If you don't have the rights to Add and/or Delete the Attribute, you might have the right to change the password-attribute. + // In this case uncomment the next 2 lines and comment the region 'Change Password by Delete/Add' + var attribute = new LdapAttribute("userPassword", newPassword); + var ldapReplace = new LdapModification(LdapModification.Replace, attribute); + ldap.Modify(userDN, new[] { ldapReplace }); // Change with Replace + } - if (!m.Success) - { - return new ApiErrorItem(ApiErrorCode.LdapProblem, $"Unexpected error: {ex.LdapErrorMessage}"); - } + private static void ChangePasswordDelAdd(string currentPassword, string newPassword, ILdapConnection ldap, string userDN) + { + var oldPassBytes = Encoding.Unicode.GetBytes($@"""{currentPassword}"""); + var newPassBytes = Encoding.Unicode.GetBytes($@"""{newPassword}"""); - var errCodeString = m.Groups[1].Value; - var errCode = int.Parse(errCodeString, NumberStyles.HexNumber, CultureInfo.InvariantCulture); - var err = Win32ErrorCode.ByCode(errCode); + var oldAttr = new LdapAttribute("unicodePwd", oldPassBytes); + var newAttr = new LdapAttribute("unicodePwd", newPassBytes); - return err == null - ? new ApiErrorItem(ApiErrorCode.LdapProblem, $"Unexpected Win32 API error; error code: {errCodeString}") - : new ApiErrorItem(ApiErrorCode.InvalidCredentials, - $"Resolved Win32 API Error: code={err.Code} name={err.CodeName} desc={err.Description}"); - } + var ldapDel = new LdapModification(LdapModification.Delete, oldAttr); + var ldapAdd = new LdapModification(LdapModification.Add, newAttr); + ldap.Modify(userDN, new[] { ldapDel, ldapAdd }); // Change with Delete/Add + } - private string CleaningUsername(string username) + private static ApiErrorItem ParseLdapException(LdapException ex) + { + // If the LDAP server returned an error, it will be formatted + // similar to this: + // "0000052D: AtrErr: DSID-03191083, #1:\n\t0: 0000052D: DSID-03191083, problem 1005 (CONSTRAINT_ATT_TYPE), data 0, Att 9005a (unicodePwd)\n\0" + // + // The leading number before the ':' is the Win32 API Error Code in HEX + if (ex.LdapErrorMessage == null) { - var cleanUsername = username; - var index = cleanUsername.IndexOf("@", StringComparison.Ordinal); - if (index >= 0) - cleanUsername = cleanUsername.Substring(0, index); + return new ApiErrorItem(ApiErrorCode.LdapProblem, "Unexpected null exception"); + } - // Must sanitize the username to eliminate the possibility of injection attacks: - // * https://docs.microsoft.com/en-us/windows/desktop/adschema/a-samaccountname - // * https://docs.microsoft.com/en-us/previous-versions/windows/it-pro/windows-2000-server/bb726984(v=technet.10) - var invalidChars = "\"/\\[]:;|=,+*?<>\r\n\t".ToCharArray(); + var m = Regex.Match(ex.LdapErrorMessage, "([0-9a-fA-F]+):"); - if (cleanUsername.IndexOfAny(invalidChars) >= 0) - { - throw new ApiErrorException("Username contains one or more invalid characters", ApiErrorCode.InvalidCredentials); - } + if (!m.Success) + { + return new ApiErrorItem(ApiErrorCode.LdapProblem, $"Unexpected error: {ex.LdapErrorMessage}"); + } - // LDAP filters require escaping of some special chars: - // * http://www.ldapexplorer.com/en/manual/109010000-ldap-filter-syntax.htm - var escape = "()&|=>= 0) + cleanUsername = cleanUsername[..index]; - while (escapeIndex >= 0) - { - buff.Append(cleanUsername.Substring(copyFrom, escapeIndex)); - buff.Append(string.Format("\\{0:X}", (int)cleanUsername[escapeIndex])); - copyFrom = escapeIndex + 1; - escapeIndex = cleanUsername.IndexOfAny(escape, copyFrom); - } + // Must sanitize the username to eliminate the possibility of injection attacks: + // * https://docs.microsoft.com/en-us/windows/desktop/adschema/a-samaccountname + // * https://docs.microsoft.com/en-us/previous-versions/windows/it-pro/windows-2000-server/bb726984(v=technet.10) + var invalidChars = "\"/\\[]:;|=,+*?<>\r\n\t".ToCharArray(); - if (copyFrom < maxLen) - buff.Append(cleanUsername.Substring(copyFrom)); - cleanUsername = buff.ToString(); - _logger.LogWarning("Had to clean username: [{0}] => [{1}]", username, cleanUsername); + if (cleanUsername.IndexOfAny(invalidChars) >= 0) + throw new ApiErrorException("Username contains one or more invalid characters", ApiErrorCode.InvalidCredentials); - return cleanUsername ?? string.Empty; - } + // LDAP filters require escaping of some special chars: + // * http://www.ldapexplorer.com/en/manual/109010000-ldap-filter-syntax.htm + var escape = "()&|=>= 0) + { + buff.Append(cleanUsername.Substring(copyFrom, escapeIndex)); + buff.Append($"\\{cleanUsername[escapeIndex]:X}"); + copyFrom = escapeIndex + 1; + escapeIndex = cleanUsername.IndexOfAny(escape, copyFrom); + } - if (string.IsNullOrEmpty(_options.LdapPassword)) - { - throw new ArgumentException("Options missing or invalid LDAP bind password", - nameof(_options.LdapPassword)); - } + if (copyFrom < maxLen) + buff.Append(cleanUsername.Substring(copyFrom)); + cleanUsername = buff.ToString(); + _logger.LogWarning("Had to clean username: [{0}] => [{1}]", username, cleanUsername); - if (string.IsNullOrEmpty(_options.LdapSearchBase)) - { - throw new ArgumentException("Options must specify LDAP search base", - nameof(_options.LdapSearchBase)); - } + return cleanUsername; + } - if (string.IsNullOrWhiteSpace(_options.LdapSearchFilter)) - { - throw new ArgumentException( - $"No {nameof(_options.LdapSearchFilter)} is set. Fill attribute {nameof(_options.LdapSearchFilter)} in file appsettings.json", - nameof(_options.LdapSearchFilter)); - } + private void Init() + { + // Validate required options + if (_options.LdapIgnoreTlsErrors || _options.LdapIgnoreTlsValidation) + _ldapRemoteCertValidator = CustomServerCertValidation; - if (!_options.LdapSearchFilter.Contains("{Username}")) - { - throw new ArgumentException( - $"The {nameof(_options.LdapSearchFilter)} should include {{Username}} value in the template string", - nameof(_options.LdapSearchFilter)); - } + if (_options.LdapHostnames?.Length < 1) + { + throw new ArgumentException("Options must specify at least one LDAP hostname", + nameof(_options.LdapHostnames)); + } - // All other configuration is optional, but some may warrant attention - if (!_options.HideUserNotFound) - _logger.LogWarning($"Option [{nameof(_options.HideUserNotFound)}] is DISABLED; the presence or absence of usernames can be harvested"); + if (string.IsNullOrEmpty(_options.LdapUsername)) + { + throw new ArgumentException("Options missing or invalid LDAP bind distinguished name (DN)", + nameof(_options.LdapUsername)); + } - if (_options.LdapIgnoreTlsErrors) - _logger.LogWarning($"Option [{nameof(_options.LdapIgnoreTlsErrors)}] is ENABLED; invalid certificates will be allowed"); - else if (_options.LdapIgnoreTlsValidation) - _logger.LogWarning($"Option [{nameof(_options.LdapIgnoreTlsValidation)}] is ENABLED; untrusted certificate roots will be allowed"); + if (string.IsNullOrEmpty(_options.LdapPassword)) + { + throw new ArgumentException("Options missing or invalid LDAP bind password", + nameof(_options.LdapPassword)); + } - if (_options.LdapPort == LdapConnection.DefaultSslPort && !_options.LdapSecureSocketLayer) - _logger.LogWarning($"Option [{nameof(_options.LdapSecureSocketLayer)}] is DISABLED in combination with standard SSL port [{_options.LdapPort}]"); + if (string.IsNullOrEmpty(_options.LdapSearchBase)) + { + throw new ArgumentException("Options must specify LDAP search base", + nameof(_options.LdapSearchBase)); + } - if (_options.LdapPort != LdapConnection.DefaultSslPort && !_options.LdapStartTls) - _logger.LogWarning($"Option [{nameof(_options.LdapStartTls)}] is DISABLED in combination with non-standard TLS port [{_options.LdapPort}]"); + if (string.IsNullOrWhiteSpace(_options.LdapSearchFilter)) + { + throw new ArgumentException( + $"No {nameof(_options.LdapSearchFilter)} is set. Fill attribute {nameof(_options.LdapSearchFilter)} in file appsettings.json", + nameof(_options.LdapSearchFilter)); } - [Obsolete] - private LdapConnection BindToLdap() + if (!_options.LdapSearchFilter.Contains("{Username}")) { - var ldap = new LdapConnection(); - if (_ldapRemoteCertValidator != null) - ldap.UserDefinedServerCertValidationDelegate += _ldapRemoteCertValidator; + throw new ArgumentException( + $"The {nameof(_options.LdapSearchFilter)} should include {{Username}} value in the template string", + nameof(_options.LdapSearchFilter)); + } + + // All other configuration is optional, but some may warrant attention + if (!_options.HideUserNotFound) + _logger.LogWarning($"Option [{nameof(_options.HideUserNotFound)}] is DISABLED; the presence or absence of usernames can be harvested"); + + if (_options.LdapIgnoreTlsErrors) + _logger.LogWarning($"Option [{nameof(_options.LdapIgnoreTlsErrors)}] is ENABLED; invalid certificates will be allowed"); + else if (_options.LdapIgnoreTlsValidation) + _logger.LogWarning($"Option [{nameof(_options.LdapIgnoreTlsValidation)}] is ENABLED; untrusted certificate roots will be allowed"); + + if (_options.LdapPort == LdapConnection.DefaultSslPort && !_options.LdapSecureSocketLayer) + _logger.LogWarning($"Option [{nameof(_options.LdapSecureSocketLayer)}] is DISABLED in combination with standard SSL port [{_options.LdapPort}]"); - ldap.SecureSocketLayer = _options.LdapSecureSocketLayer; + if (_options.LdapPort != LdapConnection.DefaultSslPort && !_options.LdapStartTls) + _logger.LogWarning($"Option [{nameof(_options.LdapStartTls)}] is DISABLED in combination with non-standard TLS port [{_options.LdapPort}]"); + } + + [Obsolete] + private LdapConnection BindToLdap() + { + var ldap = new LdapConnection(); + if (_ldapRemoteCertValidator != null) + ldap.UserDefinedServerCertValidationDelegate += _ldapRemoteCertValidator; - string? bindHostname = null; + ldap.SecureSocketLayer = _options.LdapSecureSocketLayer; - foreach (var h in _options.LdapHostnames) + string? bindHostname = null; + + foreach (var h in _options.LdapHostnames) + { + try { - try - { - ldap.Connect(h, _options.LdapPort); - bindHostname = h; - break; - } - catch (Exception ex) - { - _logger.LogWarning($"Failed to connect to host [{h}]", ex); - } + ldap.Connect(h, _options.LdapPort); + bindHostname = h; + break; } - - if (string.IsNullOrEmpty(bindHostname)) + catch (Exception ex) { - throw new ApiErrorException("Failed to connect to any configured hostname", ApiErrorCode.InvalidCredentials); + _logger.LogWarning($"Failed to connect to host [{h}]", ex); } + } - if (_options.LdapStartTls) - ldap.StartTls(); + if (string.IsNullOrEmpty(bindHostname)) + { + throw new ApiErrorException("Failed to connect to any configured hostname", ApiErrorCode.InvalidCredentials); + } - ldap.Bind(_options.LdapUsername, _options.LdapPassword); + if (_options.LdapStartTls) + ldap.StartTls(); - return ldap; - } + ldap.Bind(_options.LdapUsername, _options.LdapPassword); - /// - /// Custom server certificate validation logic that handles our special - /// cases based on configuration. This implements the logic of either - /// ignoring just untrusted root errors or ignoring all TLS errors. - /// - /// The sender. - /// The certificate. - /// The chain. - /// The SSL policy errors. - /// true if the certificate validation was successful. - private bool CustomServerCertValidation( - object sender, - X509Certificate certificate, - X509Chain chain, - SslPolicyErrors sslPolicyErrors) => - _options.LdapIgnoreTlsErrors || sslPolicyErrors == SslPolicyErrors.None || chain.ChainStatus - .Any(x => x.Status switch - { - X509ChainStatusFlags.UntrustedRoot when _options.LdapIgnoreTlsValidation => true, - _ => x.Status == X509ChainStatusFlags.NoError - }); + return ldap; } -} + + /// + /// Custom server certificate validation logic that handles our special + /// cases based on configuration. This implements the logic of either + /// ignoring just untrusted root errors or ignoring all TLS errors. + /// + /// The sender. + /// The certificate. + /// The chain. + /// The SSL policy errors. + /// true if the certificate validation was successful. + private bool CustomServerCertValidation( + object sender, + X509Certificate certificate, + X509Chain chain, + SslPolicyErrors sslPolicyErrors) => + _options.LdapIgnoreTlsErrors || sslPolicyErrors == SslPolicyErrors.None || chain.ChainStatus + .Any(x => x.Status switch + { + X509ChainStatusFlags.UntrustedRoot when _options.LdapIgnoreTlsValidation => true, + _ => x.Status == X509ChainStatusFlags.NoError + }); +} \ No newline at end of file diff --git a/src/Zyborg.PassCore.PasswordProvider.LDAP/Win32ErrorCode.cs b/src/Zyborg.PassCore.PasswordProvider.LDAP/Win32ErrorCode.cs index b797850f..780725e8 100644 --- a/src/Zyborg.PassCore.PasswordProvider.LDAP/Win32ErrorCode.cs +++ b/src/Zyborg.PassCore.PasswordProvider.LDAP/Win32ErrorCode.cs @@ -1,123 +1,121 @@ -namespace Zyborg.PassCore.PasswordProvider.LDAP -{ - using System.Collections.Generic; +using System.Collections.Generic; + +namespace Zyborg.PassCore.PasswordProvider.LDAP; +/// +/// Represents a container of Win32 Error Code. +/// +public class Win32ErrorCode +{ /// - /// Represents a container of Win32 Error Code. + /// Based on + /// docs. + /// provides a list of commonly anticipated error codes from a password change request. /// - public class Win32ErrorCode + public static readonly IEnumerable Codes = new[] { - /// - /// Based on - /// docs. - /// provides a list of commonly anticipated error codes from a password change request. - /// - public static readonly IEnumerable Codes = new[] - { - new Win32ErrorCode(0x00000005, "ERROR_ACCESS_DENIED", - "Access is denied."), - new Win32ErrorCode(0x00000056, "ERROR_INVALID_PASSWORD", - "The specified network password is not correct."), - new Win32ErrorCode(0x00000523, "ERROR_INVALID_ACCOUNT_NAME", - "The name provided is not a properly formed account name."), - new Win32ErrorCode(0x00000524, "ERROR_USER_EXISTS", - "The specified account already exists."), - new Win32ErrorCode(0x00000525, "ERROR_NO_SUCH_USER", - "The specified account does not exist."), - new Win32ErrorCode(0x0000052B, "ERROR_WRONG_PASSWORD", - "Unable to update the password. The value provided as the current password is incorrect."), - new Win32ErrorCode(0x0000052C, "ERROR_ILL_FORMED_PASSWORD", - "Unable to update the password. The value provided for the new password contains values that are not allowed in passwords."), - new Win32ErrorCode(0x0000052D, "ERROR_PASSWORD_RESTRICTION", - "Unable to update the password. The value provided for the new password does not meet the length, complexity, or history requirements of the domain."), - new Win32ErrorCode(0x0000052E, "ERROR_LOGON_FAILURE", - "Logon failure: Unknown user name or bad password."), - new Win32ErrorCode(0x0000052F, "ERROR_ACCOUNT_RESTRICTION", - "Logon failure: User account restriction. Possible reasons are blank passwords not allowed, logon hour restrictions, or a policy restriction has been enforced."), - new Win32ErrorCode(0x00000530, "ERROR_INVALID_LOGON_HOURS", - "Logon failure: Account logon time restriction violation."), - new Win32ErrorCode(0x00000531, "ERROR_INVALID_WORKSTATION", - "Logon failure: User not allowed to log on to this computer."), - new Win32ErrorCode(0x00000532, "ERROR_PASSWORD_EXPIRED", - "Logon failure: The specified account password has expired."), - new Win32ErrorCode(0x00000533, "ERROR_ACCOUNT_DISABLED", - "Logon failure: Account currently disabled."), - new Win32ErrorCode(0x00000773, "ERROR_PASSWORD_MUST_CHANGE", - "The user's password must be changed before logging on the first time."), - new Win32ErrorCode(0x00000774, "ERROR_DOMAIN_CONTROLLER_NOT_FOUND", - "Could not find the domain controller for this domain."), - new Win32ErrorCode(0x00000775, "ERROR_ACCOUNT_LOCKED_OUT", - "The referenced account is currently locked out and cannot be logged on to."), - }; + new Win32ErrorCode(0x00000005, "ERROR_ACCESS_DENIED", + "Access is denied."), + new Win32ErrorCode(0x00000056, "ERROR_INVALID_PASSWORD", + "The specified network password is not correct."), + new Win32ErrorCode(0x00000523, "ERROR_INVALID_ACCOUNT_NAME", + "The name provided is not a properly formed account name."), + new Win32ErrorCode(0x00000524, "ERROR_USER_EXISTS", + "The specified account already exists."), + new Win32ErrorCode(0x00000525, "ERROR_NO_SUCH_USER", + "The specified account does not exist."), + new Win32ErrorCode(0x0000052B, "ERROR_WRONG_PASSWORD", + "Unable to update the password. The value provided as the current password is incorrect."), + new Win32ErrorCode(0x0000052C, "ERROR_ILL_FORMED_PASSWORD", + "Unable to update the password. The value provided for the new password contains values that are not allowed in passwords."), + new Win32ErrorCode(0x0000052D, "ERROR_PASSWORD_RESTRICTION", + "Unable to update the password. The value provided for the new password does not meet the length, complexity, or history requirements of the domain."), + new Win32ErrorCode(0x0000052E, "ERROR_LOGON_FAILURE", + "Logon failure: Unknown user name or bad password."), + new Win32ErrorCode(0x0000052F, "ERROR_ACCOUNT_RESTRICTION", + "Logon failure: User account restriction. Possible reasons are blank passwords not allowed, logon hour restrictions, or a policy restriction has been enforced."), + new Win32ErrorCode(0x00000530, "ERROR_INVALID_LOGON_HOURS", + "Logon failure: Account logon time restriction violation."), + new Win32ErrorCode(0x00000531, "ERROR_INVALID_WORKSTATION", + "Logon failure: User not allowed to log on to this computer."), + new Win32ErrorCode(0x00000532, "ERROR_PASSWORD_EXPIRED", + "Logon failure: The specified account password has expired."), + new Win32ErrorCode(0x00000533, "ERROR_ACCOUNT_DISABLED", + "Logon failure: Account currently disabled."), + new Win32ErrorCode(0x00000773, "ERROR_PASSWORD_MUST_CHANGE", + "The user's password must be changed before logging on the first time."), + new Win32ErrorCode(0x00000774, "ERROR_DOMAIN_CONTROLLER_NOT_FOUND", + "Could not find the domain controller for this domain."), + new Win32ErrorCode(0x00000775, "ERROR_ACCOUNT_LOCKED_OUT", + "The referenced account is currently locked out and cannot be logged on to."), + }; - private static readonly Dictionary ErrorByCode = - new Dictionary(); + private static readonly Dictionary ErrorByCode = new(); - static Win32ErrorCode() + static Win32ErrorCode() + { + foreach (var c in Codes) { - foreach (var c in Codes) - { - ErrorByCode[c.Code] = c; - } + ErrorByCode[c.Code] = c; } + } - private Win32ErrorCode(int code, string codeName, string desc) - { - Code = code; - CodeName = codeName; - Description = desc; - } + private Win32ErrorCode(int code, string codeName, string desc) + { + Code = code; + CodeName = codeName; + Description = desc; + } - /// - /// Gets the code. - /// - /// - /// The code. - /// - public int Code { get; } + /// + /// Gets the code. + /// + /// + /// The code. + /// + public int Code { get; } - /// - /// Gets the name of the code. - /// - /// - /// The name of the code. - /// - public string CodeName { get; } + /// + /// Gets the name of the code. + /// + /// + /// The name of the code. + /// + public string CodeName { get; } - /// - /// Gets the description. - /// - /// - /// The description. - /// - public string Description { get; } + /// + /// Gets the description. + /// + /// + /// The description. + /// + public string Description { get; } - /// - /// Get Error Code by the code. - /// - /// The code. - /// A Win32ErrorCode from the code. - public static Win32ErrorCode? ByCode(int code) => - ErrorByCode.TryGetValue(code, out var err) ? err : null; + /// + /// Get Error Code by the code. + /// + /// The code. + /// A Win32ErrorCode from the code. + public static Win32ErrorCode? ByCode(int code) => + ErrorByCode.TryGetValue(code, out var err) ? err : null; - /// - /// Returns a hash code for this instance. - /// - /// - /// A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table. - /// - public override int GetHashCode() => Code; + /// + /// Returns a hash code for this instance. + /// + /// + /// A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table. + /// + public override int GetHashCode() => Code; - /// - /// Determines whether the specified , is equal to this instance. - /// - /// The to compare with this instance. - /// - /// true if the specified is equal to this instance; otherwise, false. - /// - public override bool Equals(object? obj) - { - return obj != null && obj is Win32ErrorCode err && Code == err.Code; - } + /// + /// Determines whether the specified , is equal to this instance. + /// + /// The to compare with this instance. + /// + /// true if the specified is equal to this instance; otherwise, false. + /// + public override bool Equals(object? obj) + { + return obj is Win32ErrorCode err && Code == err.Code; } -} +} \ No newline at end of file diff --git a/src/Zyborg.PassCore.PasswordProvider.LDAP/Zyborg.PassCore.PasswordProvider.LDAP.csproj b/src/Zyborg.PassCore.PasswordProvider.LDAP/Zyborg.PassCore.PasswordProvider.LDAP.csproj index efcc098e..28166d5a 100644 --- a/src/Zyborg.PassCore.PasswordProvider.LDAP/Zyborg.PassCore.PasswordProvider.LDAP.csproj +++ b/src/Zyborg.PassCore.PasswordProvider.LDAP/Zyborg.PassCore.PasswordProvider.LDAP.csproj @@ -1,25 +1,24 @@  - - net5.0 - true - enable - true - ..\..\StyleCop.Analyzers.ruleset - Based on Novell.Directory.Ldap.NETStandard & other work. - + + net6.0 + true + enable + true + true + AllEnabledByDefault + true + Based on Novell.Directory.Ldap.NETStandard & other work. + - - - - - - All - - + + + + + - - - + + +