From 73cd6bf1db3dc66d6a47a3a4498b69b8abe0f539 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn Date: Wed, 29 Jul 2026 16:29:08 -0500 Subject: [PATCH] feat: add backend .NET project with Domain, Infrastructure, API, and CLI --- src/ParagonPlayground/backend/.dockerignore | 8 + src/ParagonPlayground/backend/.editorconfig | 393 ++++++++++++++ src/ParagonPlayground/backend/.gitignore | 491 ++++++++++++++++++ .../backend/Directory.Build.props | 16 + src/ParagonPlayground/backend/Dockerfile | 27 + .../backend/ParagonPlayground.slnx | 8 + .../backend/src/Directory.Build.props | 10 + .../backend/src/Directory.Packages.props | 18 + .../src/ParagonPlayground.Api/.editorconfig | 2 + .../Context/ContextKeys.cs | 9 + .../Context/HttpContextExtensions.cs | 46 ++ .../Endpoints/AuthEndpoints.cs | 116 +++++ .../Infrastructure/TokenHelper.cs | 17 + .../Middleware/SessionAuthMiddleware.cs | 52 ++ .../Middleware/TenantResolutionMiddleware.cs | 24 + .../Options/MongoDbOptions.cs | 8 + .../ParagonPlayground.Api.csproj | 12 + .../src/ParagonPlayground.Api/Program.cs | 36 ++ .../Properties/launchSettings.json | 14 + .../src/ParagonPlayground.Cli/.editorconfig | 3 + .../ParagonPlayground.Cli/CliConfiguration.cs | 25 + .../Commands/ProvisionOrgCommand.cs | 50 ++ .../Commands/ProvisionUserCommand.cs | 72 +++ .../Commands/SeedCommand.cs | 78 +++ .../Infrastructure/TypeRegistrar.cs | 28 + .../Infrastructure/TypeResolver.cs | 11 + .../ParagonPlayground.Cli.csproj | 26 + .../src/ParagonPlayground.Cli/Program.cs | 31 ++ .../DTOs/LoginRequest.cs | 11 + .../DTOs/UserResponse.cs | 23 + .../Entities/Organization.cs | 17 + .../Entities/Session.cs | 20 + .../ParagonPlayground.Domain/Entities/User.cs | 23 + .../ParagonPlayground.Domain.csproj | 3 + .../Data/MongoDbContext.cs | 39 ++ .../Data/OrganizationRepository.cs | 29 ++ .../Data/SessionRepository.cs | 35 ++ .../Data/UserRepository.cs | 29 ++ .../ParagonPlayground.Infrastructure.csproj | 14 + .../Services/CookieService.cs | 94 ++++ .../Services/PasswordService.cs | 17 + 41 files changed, 1985 insertions(+) create mode 100644 src/ParagonPlayground/backend/.dockerignore create mode 100644 src/ParagonPlayground/backend/.editorconfig create mode 100644 src/ParagonPlayground/backend/.gitignore create mode 100644 src/ParagonPlayground/backend/Directory.Build.props create mode 100644 src/ParagonPlayground/backend/Dockerfile create mode 100644 src/ParagonPlayground/backend/ParagonPlayground.slnx create mode 100644 src/ParagonPlayground/backend/src/Directory.Build.props create mode 100644 src/ParagonPlayground/backend/src/Directory.Packages.props create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Api/.editorconfig create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Api/Context/ContextKeys.cs create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Api/Context/HttpContextExtensions.cs create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/AuthEndpoints.cs create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Api/Infrastructure/TokenHelper.cs create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Api/Middleware/SessionAuthMiddleware.cs create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Api/Middleware/TenantResolutionMiddleware.cs create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Api/Options/MongoDbOptions.cs create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Api/ParagonPlayground.Api.csproj create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Api/Program.cs create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Api/Properties/launchSettings.json create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Cli/.editorconfig create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Cli/CliConfiguration.cs create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Commands/ProvisionOrgCommand.cs create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Commands/ProvisionUserCommand.cs create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Commands/SeedCommand.cs create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Infrastructure/TypeRegistrar.cs create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Infrastructure/TypeResolver.cs create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Cli/ParagonPlayground.Cli.csproj create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Program.cs create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/LoginRequest.cs create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/UserResponse.cs create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/Organization.cs create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/Session.cs create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/User.cs create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Domain/ParagonPlayground.Domain.csproj create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/MongoDbContext.cs create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/OrganizationRepository.cs create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/SessionRepository.cs create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/UserRepository.cs create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/ParagonPlayground.Infrastructure.csproj create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Services/CookieService.cs create mode 100644 src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Services/PasswordService.cs diff --git a/src/ParagonPlayground/backend/.dockerignore b/src/ParagonPlayground/backend/.dockerignore new file mode 100644 index 0000000..1128836 --- /dev/null +++ b/src/ParagonPlayground/backend/.dockerignore @@ -0,0 +1,8 @@ +**/.git/ +**/bin/ +**/obj/ +**/node_modules/ +**/appsettings.Development.json +**/.dockerignore +Dockerfile +README.md diff --git a/src/ParagonPlayground/backend/.editorconfig b/src/ParagonPlayground/backend/.editorconfig new file mode 100644 index 0000000..16e4632 --- /dev/null +++ b/src/ParagonPlayground/backend/.editorconfig @@ -0,0 +1,393 @@ +root = true + +# All files +[*] +indent_style = space + +# Xml files +[*.xml] +indent_size = 2 + +# Xml project files +[*.{csproj,fsproj,vbproj,proj,slnx}] +indent_size = 2 + +# Xml config files +[*.{props,targets,config,nuspec}] +indent_size = 2 + +[*.json] +indent_size = 2 + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 2 +tab_width = 2 + +# New line preferences +insert_final_newline = false + +#### .NET Coding Conventions #### +[*.{cs,vb}] + +# dotnet analyzer settings +dotnet_diagnostic.IDE0100.severity = none +dotnet_diagnostic.CA1822.severity = none + +# Organize usings +dotnet_separate_import_directive_groups = true +dotnet_sort_system_directives_first = true +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_namespace_match_folder = 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:suggestion +dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = false:silent +dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:suggestion +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:warning + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:suggestion + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +#### C# Coding Conventions #### +[*.cs] + +# var preferences +csharp_style_var_elsewhere = true:suggestion +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion + +# 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 = false: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_extended_property_pattern = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_switch_expression = true:suggestion + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_prefer_static_anonymous_function = true:suggestion +csharp_prefer_static_local_function = true:warning +csharp_preferred_modifier_order = public,private,protected,internal,file,const,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion +csharp_style_prefer_readonly_struct = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion + +# Code-block preferences +csharp_prefer_braces = true:silent +csharp_prefer_simple_using_statement = true:suggestion +csharp_style_namespace_declarations = file_scoped:suggestion +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_top_level_statements = true:silent + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:silent + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = 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 #### +[*.{cs,vb}] + +# Naming rules + +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion +dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces +dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase + +dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion +dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters +dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase + +dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods +dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties +dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.events_should_be_pascalcase.symbols = events +dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables +dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase + +dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants +dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase + +dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion +dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters +dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase + +dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields +dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion +dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields +dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase + +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase + +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums +dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase + +# Symbol specifications + +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interfaces.required_modifiers = + +dotnet_naming_symbols.enums.applicable_kinds = enum +dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.enums.required_modifiers = + +dotnet_naming_symbols.events.applicable_kinds = event +dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.events.required_modifiers = + +dotnet_naming_symbols.methods.applicable_kinds = method +dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.methods.required_modifiers = + +dotnet_naming_symbols.properties.applicable_kinds = property +dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.properties.required_modifiers = + +dotnet_naming_symbols.public_fields.applicable_kinds = field +dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_fields.required_modifiers = + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_fields.required_modifiers = + +dotnet_naming_symbols.private_static_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_fields.required_modifiers = static + +dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum +dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types_and_namespaces.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 = + +dotnet_naming_symbols.type_parameters.applicable_kinds = namespace +dotnet_naming_symbols.type_parameters.applicable_accessibilities = * +dotnet_naming_symbols.type_parameters.required_modifiers = + +dotnet_naming_symbols.private_constant_fields.applicable_kinds = field +dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_constant_fields.required_modifiers = const + +dotnet_naming_symbols.local_variables.applicable_kinds = local +dotnet_naming_symbols.local_variables.applicable_accessibilities = local +dotnet_naming_symbols.local_variables.required_modifiers = + +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.applicable_accessibilities = local +dotnet_naming_symbols.local_constants.required_modifiers = const + +dotnet_naming_symbols.parameters.applicable_kinds = parameter +dotnet_naming_symbols.parameters.applicable_accessibilities = * +dotnet_naming_symbols.parameters.required_modifiers = + +dotnet_naming_symbols.public_constant_fields.applicable_kinds = field +dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_constant_fields.required_modifiers = const + +dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function +dotnet_naming_symbols.local_functions.applicable_accessibilities = * +dotnet_naming_symbols.local_functions.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascalcase.required_prefix = +dotnet_naming_style.pascalcase.required_suffix = +dotnet_naming_style.pascalcase.word_separator = +dotnet_naming_style.pascalcase.capitalization = pascal_case + +dotnet_naming_style.ipascalcase.required_prefix = I +dotnet_naming_style.ipascalcase.required_suffix = +dotnet_naming_style.ipascalcase.word_separator = +dotnet_naming_style.ipascalcase.capitalization = pascal_case + +dotnet_naming_style.tpascalcase.required_prefix = T +dotnet_naming_style.tpascalcase.required_suffix = +dotnet_naming_style.tpascalcase.word_separator = +dotnet_naming_style.tpascalcase.capitalization = pascal_case + +dotnet_naming_style._camelcase.required_prefix = _ +dotnet_naming_style._camelcase.required_suffix = +dotnet_naming_style._camelcase.word_separator = +dotnet_naming_style._camelcase.capitalization = camel_case + +dotnet_naming_style.camelcase.required_prefix = +dotnet_naming_style.camelcase.required_suffix = +dotnet_naming_style.camelcase.word_separator = +dotnet_naming_style.camelcase.capitalization = camel_case + +dotnet_naming_style.s_camelcase.required_prefix = s_ +dotnet_naming_style.s_camelcase.required_suffix = +dotnet_naming_style.s_camelcase.word_separator = +dotnet_naming_style.s_camelcase.capitalization = camel_case + diff --git a/src/ParagonPlayground/backend/.gitignore b/src/ParagonPlayground/backend/.gitignore new file mode 100644 index 0000000..034b62f --- /dev/null +++ b/src/ParagonPlayground/backend/.gitignore @@ -0,0 +1,491 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# appsettings +appsettings*.json +!appsettings.Example.json + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e +# but not directories ending in .e2e +!*.e2e/ + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Official VS Code C# Dev Kit Extension exclusion +*.lscache + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/src/ParagonPlayground/backend/Directory.Build.props b/src/ParagonPlayground/backend/Directory.Build.props new file mode 100644 index 0000000..85bef00 --- /dev/null +++ b/src/ParagonPlayground/backend/Directory.Build.props @@ -0,0 +1,16 @@ + + + + net10.0 + enable + enable + latest + All + true + true + true + true + $(NoWarn);NU1903 + + + diff --git a/src/ParagonPlayground/backend/Dockerfile b/src/ParagonPlayground/backend/Dockerfile new file mode 100644 index 0000000..6c42d05 --- /dev/null +++ b/src/ParagonPlayground/backend/Dockerfile @@ -0,0 +1,27 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +COPY ParagonPlayground.slnx ./ +COPY Directory.Build.props ./ +COPY src/Directory.Build.props src/ +COPY src/Directory.Packages.props src/ +COPY src/ParagonPlayground.Domain/ParagonPlayground.Domain.csproj src/ParagonPlayground.Domain/ +COPY src/ParagonPlayground.Infrastructure/ParagonPlayground.Infrastructure.csproj src/ParagonPlayground.Infrastructure/ +COPY src/ParagonPlayground.Api/ParagonPlayground.Api.csproj src/ParagonPlayground.Api/ +RUN dotnet restore src/ParagonPlayground.Api/ParagonPlayground.Api.csproj + +COPY . . +RUN dotnet publish src/ParagonPlayground.Api/ParagonPlayground.Api.csproj -c Debug -o /app + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +EXPOSE 8080 +COPY --from=build /app . + +RUN apt-get update && \ + apt-get install -y unzip curl && \ + curl -sSL https://aka.ms/getvsdbgsh | /bin/sh /dev/stdin -v latest -l /vsdbg && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +ENTRYPOINT ["dotnet", "ParagonPlayground.Api.dll"] diff --git a/src/ParagonPlayground/backend/ParagonPlayground.slnx b/src/ParagonPlayground/backend/ParagonPlayground.slnx new file mode 100644 index 0000000..de9bc87 --- /dev/null +++ b/src/ParagonPlayground/backend/ParagonPlayground.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/ParagonPlayground/backend/src/Directory.Build.props b/src/ParagonPlayground/backend/src/Directory.Build.props new file mode 100644 index 0000000..63d0a9f --- /dev/null +++ b/src/ParagonPlayground/backend/src/Directory.Build.props @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/ParagonPlayground/backend/src/Directory.Packages.props b/src/ParagonPlayground/backend/src/Directory.Packages.props new file mode 100644 index 0000000..f222313 --- /dev/null +++ b/src/ParagonPlayground/backend/src/Directory.Packages.props @@ -0,0 +1,18 @@ + + + + true + + + + + + + + + + + + + + diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/.editorconfig b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/.editorconfig new file mode 100644 index 0000000..8162ae3 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/.editorconfig @@ -0,0 +1,2 @@ +[*.{cs,vb}] +dotnet_diagnostic.CA2007.severity = none diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Context/ContextKeys.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Context/ContextKeys.cs new file mode 100644 index 0000000..3a5ca48 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Context/ContextKeys.cs @@ -0,0 +1,9 @@ +namespace ParagonPlayground.Api.Context; + +internal static class ContextKeys +{ + internal const string Session = nameof(Session); + internal const string SessionToken = nameof(SessionToken); + internal const string User = nameof(User); + internal const string Organization = nameof(Organization); +} diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Context/HttpContextExtensions.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Context/HttpContextExtensions.cs new file mode 100644 index 0000000..20f1ae8 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Context/HttpContextExtensions.cs @@ -0,0 +1,46 @@ +using ParagonPlayground.Domain.Entities; + +namespace ParagonPlayground.Api.Context; + +internal static class HttpContextExtensions +{ + internal static void SetSession(this HttpContext context, Session? session) + { + context.Items[ContextKeys.Session] = session; + } + + internal static Session? GetSession(this HttpContext context) + { + return context.Items[ContextKeys.Session] as Session; + } + + internal static void SetSessionToken(this HttpContext context, string? token) + { + context.Items[ContextKeys.SessionToken] = token; + } + + internal static string? GetSessionToken(this HttpContext context) + { + return context.Items[ContextKeys.SessionToken] as string; + } + + internal static void SetUser(this HttpContext context, User? user) + { + context.Items[ContextKeys.User] = user; + } + + internal static User? GetUser(this HttpContext context) + { + return context.Items[ContextKeys.User] as User; + } + + internal static void SetOrganization(this HttpContext context, Organization? org) + { + context.Items[ContextKeys.Organization] = org; + } + + internal static Organization? GetOrganization(this HttpContext context) + { + return context.Items[ContextKeys.Organization] as Organization; + } +} diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/AuthEndpoints.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/AuthEndpoints.cs new file mode 100644 index 0000000..90b4d75 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Endpoints/AuthEndpoints.cs @@ -0,0 +1,116 @@ +using MongoDB.Bson; + +using ParagonPlayground.Api.Context; +using ParagonPlayground.Api.Infrastructure; +using ParagonPlayground.Domain.DTOs; +using ParagonPlayground.Domain.Entities; +using ParagonPlayground.Infrastructure.Data; +using ParagonPlayground.Infrastructure.Services; + +namespace ParagonPlayground.Api.Endpoints; + +internal static class AuthEndpoints +{ + private static readonly TimeSpan SessionDuration = TimeSpan.FromDays(30); + + internal static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group) + { + _ = group.MapPost("/login", LoginAsync); + _ = group.MapPost("/logout", LogoutAsync); + _ = group.MapGet("/me", Me); + return group; + } + + private static async Task LoginAsync( + LoginRequest request, + UserRepository userRepo, + OrganizationRepository orgRepo, + SessionRepository sessionRepo, + PasswordService passwordService, + CookieService cookieService, + HttpContext context, + CancellationToken ct + ) + { + ArgumentNullException.ThrowIfNull(request); + + var user = await userRepo.GetByEmailAsync(request.Email, ct); + + if (user is null || passwordService.Verify(request.Password, user.PasswordHash) is false) + { + return Results.Json(new { error = "Invalid email or password" }, statusCode: StatusCodes.Status401Unauthorized); + } + + var org = await orgRepo.GetByIdAsync(user.OrganizationId, ct); + + var sessionToken = TokenHelper.GenerateToken(); + var session = new Session + { + Id = ObjectId.GenerateNewId().ToString(), + UserId = user.Id, + TokenHash = TokenHelper.HashToken(sessionToken), + CreatedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.Add(SessionDuration), + }; + + await sessionRepo.CreateAsync(session, ct); + + var xsrfToken = TokenHelper.GenerateToken(); + + cookieService.SetSessionCookie(context, sessionToken, SessionDuration); + cookieService.SetXsrfCookie(context, xsrfToken, SessionDuration); + + return Results.Ok(new UserResponse + { + Id = user.Id, + Email = user.Email, + DisplayName = user.DisplayName, + OrganizationId = org?.Id ?? string.Empty, + OrganizationName = org?.Name ?? string.Empty, + OrganizationSlug = org?.Slug ?? string.Empty, + }); + } + + private static async Task LogoutAsync( + SessionRepository sessionRepo, + CookieService cookieService, + HttpContext context, + CancellationToken ct + ) + { + var session = context.GetSession(); + + if (session is not null) + { + await sessionRepo.DeleteAsync(session.Id, ct); + } + + cookieService.ClearSessionCookie(context); + cookieService.ClearXsrfCookie(context); + + return Results.Ok(new { message = "Logged out" }); + } + + private static IResult Me(HttpContext context) + { + var user = context.GetUser(); + + if (user is null) + { + return Results.Json(new { error = "Not authenticated" }, statusCode: StatusCodes.Status401Unauthorized); + } + + var org = context.GetOrganization(); + + return Results.Ok(new UserResponse + { + Id = user.Id, + Email = user.Email, + DisplayName = user.DisplayName, + OrganizationId = org?.Id ?? string.Empty, + OrganizationName = org?.Name ?? string.Empty, + OrganizationSlug = org?.Slug ?? string.Empty, + }); + } + +} diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Infrastructure/TokenHelper.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Infrastructure/TokenHelper.cs new file mode 100644 index 0000000..79778ca --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Infrastructure/TokenHelper.cs @@ -0,0 +1,17 @@ +using System.Security.Cryptography; +using System.Text; + +namespace ParagonPlayground.Api.Infrastructure; + +internal static class TokenHelper +{ + internal static string GenerateToken() + { + return Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); + } + + internal static string HashToken(string token) + { + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); + } +} diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Middleware/SessionAuthMiddleware.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Middleware/SessionAuthMiddleware.cs new file mode 100644 index 0000000..330a20d --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Middleware/SessionAuthMiddleware.cs @@ -0,0 +1,52 @@ +using ParagonPlayground.Api.Context; +using ParagonPlayground.Api.Infrastructure; +using ParagonPlayground.Infrastructure.Data; +using ParagonPlayground.Infrastructure.Services; + +namespace ParagonPlayground.Api.Middleware; + +internal class SessionAuthMiddleware(RequestDelegate next) +{ + private readonly RequestDelegate _next = next; + + public async Task InvokeAsync( + HttpContext context, + SessionRepository sessionRepo, + UserRepository userRepo, + OrganizationRepository orgRepo, + CookieService cookieService + ) + { + var ct = context.RequestAborted; + var sessionToken = cookieService.GetSessionToken(context); + + if (string.IsNullOrEmpty(sessionToken) is false) + { + var tokenHash = TokenHelper.HashToken(sessionToken); + var session = await sessionRepo.GetByTokenHashAsync(tokenHash, ct); + + if (session is not null && session.ExpiresAt > DateTime.UtcNow) + { + context.SetSession(session); + context.SetSessionToken(sessionToken); + + var user = await userRepo.GetByIdAsync(session.UserId, ct); + + if (user is not null) + { + context.SetUser(user); + + var org = await orgRepo.GetByIdAsync(user.OrganizationId, ct); + + if (org is not null) + { + context.SetOrganization(org); + } + } + } + } + + await _next(context); + } + +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Middleware/TenantResolutionMiddleware.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Middleware/TenantResolutionMiddleware.cs new file mode 100644 index 0000000..7781562 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Middleware/TenantResolutionMiddleware.cs @@ -0,0 +1,24 @@ +using ParagonPlayground.Api.Context; +using ParagonPlayground.Infrastructure.Data; + +namespace ParagonPlayground.Api.Middleware; + +internal class TenantResolutionMiddleware(RequestDelegate next) +{ + private readonly RequestDelegate _next = next; + + public async Task InvokeAsync(HttpContext context, OrganizationRepository orgRepo) + { + if (context.Request.Headers.TryGetValue("X-Organization-Slug", out var slug)) + { + var org = await orgRepo.GetBySlugAsync(slug.ToString(), context.RequestAborted); + + if (org is not null) + { + context.SetOrganization(org); + } + } + + await _next(context); + } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Options/MongoDbOptions.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Options/MongoDbOptions.cs new file mode 100644 index 0000000..c442edb --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Options/MongoDbOptions.cs @@ -0,0 +1,8 @@ +namespace ParagonPlayground.Api.Options; + +internal class MongoDbOptions +{ + public const string SectionName = "MongoDb"; + public string ConnectionString { get; set; } = "mongodb://localhost:27017"; + public string DatabaseName { get; set; } = "paragon_playground"; +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/ParagonPlayground.Api.csproj b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/ParagonPlayground.Api.csproj new file mode 100644 index 0000000..3ce35f6 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/ParagonPlayground.Api.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Program.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Program.cs new file mode 100644 index 0000000..b98036b --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Program.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.HttpOverrides; + +using ParagonPlayground.Api.Endpoints; +using ParagonPlayground.Api.Middleware; +using ParagonPlayground.Api.Options; +using ParagonPlayground.Infrastructure.Data; +using ParagonPlayground.Infrastructure.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.Configure(builder.Configuration.GetSection(MongoDbOptions.SectionName)); +builder.Services.AddSingleton(static sp => +{ + var opts = sp.GetRequiredService>().Value; + return new MongoDbContext(opts.ConnectionString, opts.DatabaseName); +}); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services.Configure(static options => +{ + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; +}); + +var app = builder.Build(); + +app.UseForwardedHeaders(); +app.UseMiddleware(); +app.UseMiddleware(); +app.MapGroup("/api/auth").MapAuthEndpoints(); + +app.Run(); \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Properties/launchSettings.json b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Properties/launchSettings.json new file mode 100644 index 0000000..f427616 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Api/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5116", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/.editorconfig b/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/.editorconfig new file mode 100644 index 0000000..41f37f8 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/.editorconfig @@ -0,0 +1,3 @@ +[*.{cs,vb}] +dotnet_diagnostic.CA2007.severity = none + diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/CliConfiguration.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/CliConfiguration.cs new file mode 100644 index 0000000..322e973 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/CliConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.Configuration; + +namespace ParagonPlayground.Cli; + +internal static class CliConfiguration +{ + public static (string ConnectionString, string DatabaseName) GetMongoDbConfig() + { + var config = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) + .AddEnvironmentVariables() + .Build(); + + var connString = config.GetSection("MongoDb")["ConnectionString"] + ?? Environment.GetEnvironmentVariable("MONGODB_CONNECTION") + ?? "mongodb://localhost:27017"; + + var dbName = config.GetSection("MongoDb")["DatabaseName"] + ?? Environment.GetEnvironmentVariable("MONGODB_DATABASE") + ?? "paragon_playground"; + + return (connString, dbName); + } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Commands/ProvisionOrgCommand.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Commands/ProvisionOrgCommand.cs new file mode 100644 index 0000000..7b1e066 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Commands/ProvisionOrgCommand.cs @@ -0,0 +1,50 @@ +using System.ComponentModel; + +using MongoDB.Bson; + +using ParagonPlayground.Domain.Entities; +using ParagonPlayground.Infrastructure.Data; + +using Spectre.Console; +using Spectre.Console.Cli; + +namespace ParagonPlayground.Cli.Commands; + +internal class ProvisionOrgCommand(OrganizationRepository orgRepo) : AsyncCommand +{ + internal class Settings : CommandSettings + { + [Description("Organization name")] + [CommandOption("-n|--name")] + public required string Name { get; set; } + + [Description("Organization slug (used as subdomain)")] + [CommandOption("-s|--slug")] + public required string Slug { get; set; } + } + + protected override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) + { + var existing = await orgRepo.GetBySlugAsync(settings.Slug, cancellationToken); + + if (existing is not null) + { + AnsiConsole.MarkupLine($"[yellow]Organization with slug '{settings.Slug}' already exists (Id: {existing.Id})[/]"); + return 0; + } + + var org = new Organization + { + Id = ObjectId.GenerateNewId().ToString(), + Name = settings.Name, + Slug = settings.Slug, + CreatedAt = DateTime.UtcNow, + }; + + await orgRepo.CreateAsync(org, cancellationToken); + + AnsiConsole.MarkupLine($"[green]Organization '{org.Name}' created with slug '{org.Slug}' (Id: {org.Id})[/]"); + + return 0; + } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Commands/ProvisionUserCommand.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Commands/ProvisionUserCommand.cs new file mode 100644 index 0000000..0f47dda --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Commands/ProvisionUserCommand.cs @@ -0,0 +1,72 @@ +using System.ComponentModel; + +using MongoDB.Bson; + +using ParagonPlayground.Domain.Entities; +using ParagonPlayground.Infrastructure.Data; +using ParagonPlayground.Infrastructure.Services; + +using Spectre.Console; +using Spectre.Console.Cli; + +namespace ParagonPlayground.Cli.Commands; + +internal class ProvisionUserCommand( + OrganizationRepository orgRepo, + UserRepository userRepo, + PasswordService passwordService) : AsyncCommand +{ + internal class Settings : CommandSettings + { + [Description("User email address")] + [CommandOption("-e|--email")] + public required string Email { get; set; } + + [Description("User password")] + [CommandOption("-p|--password")] + public required string Password { get; set; } + + [Description("Display name")] + [CommandOption("-n|--name")] + public required string Name { get; set; } + + [Description("Organization slug")] + [CommandOption("-o|--org-slug")] + public required string OrgSlug { get; set; } + } + + protected override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) + { + var org = await orgRepo.GetBySlugAsync(settings.OrgSlug, cancellationToken); + + if (org is null) + { + AnsiConsole.MarkupLine($"[red]Organization with slug '{settings.OrgSlug}' not found. Create it first with 'provision org'.[/]"); + return 1; + } + + var existing = await userRepo.GetByEmailAsync(settings.Email, cancellationToken); + + if (existing is not null) + { + AnsiConsole.MarkupLine($"[yellow]User with email '{settings.Email}' already exists (Id: {existing.Id})[/]"); + return 0; + } + + var user = new User + { + Id = ObjectId.GenerateNewId().ToString(), + Email = settings.Email, + DisplayName = settings.Name, + PasswordHash = passwordService.Hash(settings.Password), + OrganizationId = org.Id, + CreatedAt = DateTime.UtcNow, + }; + + await userRepo.CreateAsync(user, cancellationToken); + + AnsiConsole.MarkupLine($"[green]User '{user.Email}' created in org '{org.Name}' (Id: {user.Id})[/]"); + + return 0; + } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Commands/SeedCommand.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Commands/SeedCommand.cs new file mode 100644 index 0000000..5c0ea9f --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Commands/SeedCommand.cs @@ -0,0 +1,78 @@ +using MongoDB.Bson; + +using ParagonPlayground.Domain.Entities; +using ParagonPlayground.Infrastructure.Data; +using ParagonPlayground.Infrastructure.Services; + +using Spectre.Console; +using Spectre.Console.Cli; + +namespace ParagonPlayground.Cli.Commands; + +internal class SeedCommand( + OrganizationRepository orgRepo, + UserRepository userRepo, + PasswordService passwordService) : AsyncCommand +{ + internal class Settings : CommandSettings { } + + protected override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) + { + var org = await orgRepo.GetBySlugAsync("acme", cancellationToken); + + if (org is null) + { + org = new Organization + { + Id = ObjectId.GenerateNewId().ToString(), + Name = "Acme Corp", + Slug = "acme", + CreatedAt = DateTime.UtcNow, + }; + + await orgRepo.CreateAsync(org, cancellationToken); + + AnsiConsole.MarkupLine($"[green]Created org: {org.Name} (slug: {org.Slug})[/]"); + } + else + { + AnsiConsole.MarkupLine($"[yellow]Org 'acme' already exists[/]"); + } + + var users = new[] + { + (Email: "alice@acme.com", Name: "Alice", Password: "password123"), + (Email: "bob@acme.com", Name: "Bob", Password: "password123"), + }; + + foreach (var (email, name, password) in users) + { + var existing = await userRepo.GetByEmailAsync(email, cancellationToken); + + if (existing is null) + { + var user = new User + { + Id = ObjectId.GenerateNewId().ToString(), + Email = email, + DisplayName = name, + PasswordHash = passwordService.Hash(password), + OrganizationId = org.Id, + CreatedAt = DateTime.UtcNow, + }; + + await userRepo.CreateAsync(user, cancellationToken); + + AnsiConsole.MarkupLine($"[green]Created user: {user.Email} ({user.DisplayName})[/]"); + } + else + { + AnsiConsole.MarkupLine($"[yellow]User '{email}' already exists[/]"); + } + } + + AnsiConsole.MarkupLine("[bold green]Seed complete![/]"); + + return 0; + } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Infrastructure/TypeRegistrar.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Infrastructure/TypeRegistrar.cs new file mode 100644 index 0000000..370509c --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Infrastructure/TypeRegistrar.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.DependencyInjection; + +using Spectre.Console.Cli; + +namespace ParagonPlayground.Cli.Infrastructure; + +internal sealed class TypeRegistrar(IServiceCollection services) : ITypeRegistrar +{ + public ITypeResolver Build() + { + return new TypeResolver(services.BuildServiceProvider()); + } + + public void Register(Type service, Type implementation) + { + _ = services.AddSingleton(service, implementation); + } + + public void RegisterInstance(Type service, object implementation) + { + _ = services.AddSingleton(service, implementation); + } + + public void RegisterLazy(Type service, Func factory) + { + _ = services.AddSingleton(service, _ => factory()); + } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Infrastructure/TypeResolver.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Infrastructure/TypeResolver.cs new file mode 100644 index 0000000..e026524 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Infrastructure/TypeResolver.cs @@ -0,0 +1,11 @@ +using Spectre.Console.Cli; + +namespace ParagonPlayground.Cli.Infrastructure; + +internal sealed class TypeResolver(IServiceProvider provider) : ITypeResolver +{ + public object? Resolve(Type? type) + { + return type is null ? null : provider.GetService(type); + } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/ParagonPlayground.Cli.csproj b/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/ParagonPlayground.Cli.csproj new file mode 100644 index 0000000..aa57444 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/ParagonPlayground.Cli.csproj @@ -0,0 +1,26 @@ + + + + Exe + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Program.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Program.cs new file mode 100644 index 0000000..7dc5706 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Cli/Program.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.DependencyInjection; + +using ParagonPlayground.Cli; +using ParagonPlayground.Cli.Commands; +using ParagonPlayground.Cli.Infrastructure; +using ParagonPlayground.Infrastructure.Data; +using ParagonPlayground.Infrastructure.Services; + +using Spectre.Console.Cli; + +var services = new ServiceCollection(); + +var (connString, dbName) = CliConfiguration.GetMongoDbConfig(); +services.AddSingleton(_ => new MongoDbContext(connString, dbName)); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); + +var app = new CommandApp(new TypeRegistrar(services)); + +app.Configure(static config => +{ + _ = config.AddBranch("provision", static provision => + { + _ = provision.AddCommand("org"); + _ = provision.AddCommand("user"); + _ = provision.AddCommand("seed"); + }); +}); + +return app.Run(args); \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/LoginRequest.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/LoginRequest.cs new file mode 100644 index 0000000..4811001 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/LoginRequest.cs @@ -0,0 +1,11 @@ +namespace ParagonPlayground.Domain.DTOs; + +/// Login credentials submitted by the user. +public class LoginRequest +{ + /// User's email address. + public string Email { get; set; } = string.Empty; + + /// User's plain-text password. + public string Password { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/UserResponse.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/UserResponse.cs new file mode 100644 index 0000000..318eb66 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/DTOs/UserResponse.cs @@ -0,0 +1,23 @@ +namespace ParagonPlayground.Domain.DTOs; + +/// Public user profile returned by the API. +public class UserResponse +{ + /// Unique identifier of the user. + public string Id { get; set; } = string.Empty; + + /// User's email address. + public string Email { get; set; } = string.Empty; + + /// Display name shown in the UI. + public string DisplayName { get; set; } = string.Empty; + + /// Identifier of the user's organization. + public string OrganizationId { get; set; } = string.Empty; + + /// Display name of the user's organization. + public string OrganizationName { get; set; } = string.Empty; + + /// URL-friendly slug of the user's organization. + public string OrganizationSlug { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/Organization.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/Organization.cs new file mode 100644 index 0000000..fd7f103 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/Organization.cs @@ -0,0 +1,17 @@ +namespace ParagonPlayground.Domain.Entities; + +/// Represents a tenant organization in the system. +public class Organization +{ + /// Unique identifier (MongoDB ObjectId). + public string Id { get; set; } = string.Empty; + + /// Display name of the organization. + public string Name { get; set; } = string.Empty; + + /// URL-friendly slug used for subdomain routing. + public string Slug { get; set; } = string.Empty; + + /// Timestamp when the organization was created. + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/Session.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/Session.cs new file mode 100644 index 0000000..35de207 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/Session.cs @@ -0,0 +1,20 @@ +namespace ParagonPlayground.Domain.Entities; + +/// Represents an authenticated user session. +public class Session +{ + /// Unique identifier (MongoDB ObjectId). + public string Id { get; set; } = string.Empty; + + /// Identifier of the user this session belongs to. + public string UserId { get; set; } = string.Empty; + + /// Hashed authentication token. + public string TokenHash { get; set; } = string.Empty; + + /// Timestamp when the session was created. + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// Timestamp when the session expires and becomes invalid. + public DateTime ExpiresAt { get; set; } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/User.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/User.cs new file mode 100644 index 0000000..6374e67 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/Entities/User.cs @@ -0,0 +1,23 @@ +namespace ParagonPlayground.Domain.Entities; + +/// Represents a registered user within an organization. +public class User +{ + /// Unique identifier (MongoDB ObjectId). + public string Id { get; set; } = string.Empty; + + /// User's email address (used as login). + public string Email { get; set; } = string.Empty; + + /// Display name shown in the UI. + public string DisplayName { get; set; } = string.Empty; + + /// BCrypt hash of the user's password. + public string PasswordHash { get; set; } = string.Empty; + + /// Identifier of the organization this user belongs to. + public string OrganizationId { get; set; } = string.Empty; + + /// Timestamp when the user was created. + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/ParagonPlayground.Domain.csproj b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/ParagonPlayground.Domain.csproj new file mode 100644 index 0000000..c632161 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Domain/ParagonPlayground.Domain.csproj @@ -0,0 +1,3 @@ + + + diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/MongoDbContext.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/MongoDbContext.cs new file mode 100644 index 0000000..4e3d833 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/MongoDbContext.cs @@ -0,0 +1,39 @@ +using MongoDB.Driver; + +using ParagonPlayground.Domain.Entities; + +namespace ParagonPlayground.Infrastructure.Data; + +/// MongoDB data access context. +public sealed class MongoDbContext : IDisposable +{ + private readonly MongoClient _client; + private readonly IMongoDatabase _database; + + /// Organizations collection. + public IMongoCollection Organizations => + _database.GetCollection("Organizations"); + + /// Users collection. + public IMongoCollection Users => + _database.GetCollection("Users"); + + /// Sessions collection. + public IMongoCollection Sessions => + _database.GetCollection("Sessions"); + + /// Initializes a new MongoDbContext and connects to the specified database. + public MongoDbContext(string connectionString, string databaseName) + { + _client = new MongoClient(connectionString); + _database = _client.GetDatabase(databaseName); + } + + /// Releases the underlying MongoClient. + public void Dispose() + { + _client.Dispose(); + GC.SuppressFinalize(this); + } + +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/OrganizationRepository.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/OrganizationRepository.cs new file mode 100644 index 0000000..9754c6c --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/OrganizationRepository.cs @@ -0,0 +1,29 @@ +using MongoDB.Driver; + +using ParagonPlayground.Domain.Entities; + +namespace ParagonPlayground.Infrastructure.Data; + +/// Repository for organization data access. +public class OrganizationRepository(MongoDbContext context) +{ + private readonly MongoDbContext _context = context; + + /// Finds an organization by its URL slug. + public async Task GetBySlugAsync(string slug, CancellationToken cancellationToken) + { + return await _context.Organizations.Find(o => o.Slug == slug).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + } + + /// Finds an organization by its unique identifier. + public async Task GetByIdAsync(string id, CancellationToken cancellationToken) + { + return await _context.Organizations.Find(o => o.Id == id).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + } + + /// Creates a new organization in the database. + public async Task CreateAsync(Organization organization, CancellationToken cancellationToken) + { + await _context.Organizations.InsertOneAsync(organization, cancellationToken: cancellationToken).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/SessionRepository.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/SessionRepository.cs new file mode 100644 index 0000000..ec5a358 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/SessionRepository.cs @@ -0,0 +1,35 @@ +using MongoDB.Driver; + +using ParagonPlayground.Domain.Entities; + +namespace ParagonPlayground.Infrastructure.Data; + +/// Repository for session data access. +public class SessionRepository(MongoDbContext context) +{ + private readonly MongoDbContext _context = context; + + /// Finds a session by its token hash. + public async Task GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken) + { + return await _context.Sessions.Find(s => s.TokenHash == tokenHash).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + } + + /// Creates a new session in the database. + public async Task CreateAsync(Session session, CancellationToken cancellationToken) + { + await _context.Sessions.InsertOneAsync(session, new InsertOneOptions(), cancellationToken).ConfigureAwait(false); + } + + /// Deletes a session by its unique identifier. + public async Task DeleteAsync(string id, CancellationToken cancellationToken) + { + _ = await _context.Sessions.DeleteOneAsync(s => s.Id == id, cancellationToken).ConfigureAwait(false); + } + + /// Deletes all sessions for a given user. + public async Task DeleteByUserIdAsync(string userId, CancellationToken cancellationToken) + { + _ = await _context.Sessions.DeleteManyAsync(s => s.UserId == userId, cancellationToken).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/UserRepository.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/UserRepository.cs new file mode 100644 index 0000000..340065b --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Data/UserRepository.cs @@ -0,0 +1,29 @@ +using MongoDB.Driver; + +using ParagonPlayground.Domain.Entities; + +namespace ParagonPlayground.Infrastructure.Data; + +/// Repository for user data access. +public class UserRepository(MongoDbContext context) +{ + private readonly MongoDbContext _context = context; + + /// Finds a user by their email address. + public async Task GetByEmailAsync(string email, CancellationToken cancellationToken) + { + return await _context.Users.Find(u => u.Email == email).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + } + + /// Finds a user by their unique identifier. + public async Task GetByIdAsync(string id, CancellationToken cancellationToken) + { + return await _context.Users.Find(u => u.Id == id).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + } + + /// Creates a new user in the database. + public async Task CreateAsync(User user, CancellationToken cancellationToken) + { + await _context.Users.InsertOneAsync(user, new InsertOneOptions(), cancellationToken).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/ParagonPlayground.Infrastructure.csproj b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/ParagonPlayground.Infrastructure.csproj new file mode 100644 index 0000000..d1514a9 --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/ParagonPlayground.Infrastructure.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Services/CookieService.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Services/CookieService.cs new file mode 100644 index 0000000..2910f4c --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Services/CookieService.cs @@ -0,0 +1,94 @@ +using Microsoft.AspNetCore.Http; + +namespace ParagonPlayground.Infrastructure.Services; + +/// Manages session and XSRF cookies. +public class CookieService +{ + private const string SessionCookieName = "session"; + private const string XsrfCookieName = "XSRF-TOKEN"; + private const string XsrfHeaderName = "X-XSRF-Token"; + + /// Sets the HttpOnly session cookie. + public void SetSessionCookie(HttpContext context, string token, TimeSpan expiresIn) + { + ArgumentNullException.ThrowIfNull(context); + + context.Response.Cookies.Append(SessionCookieName, token, new CookieOptions + { + HttpOnly = true, + Secure = context.Request.IsHttps, + SameSite = SameSiteMode.Strict, + Path = "/", + MaxAge = expiresIn, + }); + } + + /// Sets the client-accessible XSRF token cookie. + public void SetXsrfCookie(HttpContext context, string token, TimeSpan expiresIn) + { + ArgumentNullException.ThrowIfNull(context); + + context.Response.Cookies.Append(XsrfCookieName, token, new CookieOptions + { + HttpOnly = false, + Secure = context.Request.IsHttps, + SameSite = SameSiteMode.Strict, + Path = "/", + MaxAge = expiresIn, + }); + } + + /// Clears the session cookie. + public void ClearSessionCookie(HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + + context.Response.Cookies.Delete(SessionCookieName); + } + + /// Clears the XSRF token cookie. + public void ClearXsrfCookie(HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + + context.Response.Cookies.Delete(XsrfCookieName); + } + + /// Reads the session token from the request cookie. + public string? GetSessionToken(HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + + return context.Request.Cookies[SessionCookieName]; + } + + /// Reads the XSRF token from the request cookie. + public string? GetXsrfCookieValue(HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + + return context.Request.Cookies[XsrfCookieName]; + } + + /// Reads the XSRF token from the request header. + public string? GetXsrfHeaderValue(HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + + return context.Request.Headers[XsrfHeaderName]; + } + + /// Validates that the XSRF cookie matches the XSRF header. + public bool ValidateXsrf(HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var cookieToken = GetXsrfCookieValue(context); + var headerToken = GetXsrfHeaderValue(context); + + return string.IsNullOrEmpty(cookieToken) is false + && string.IsNullOrEmpty(headerToken) is false + && cookieToken == headerToken; + } +} diff --git a/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Services/PasswordService.cs b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Services/PasswordService.cs new file mode 100644 index 0000000..4e8a7be --- /dev/null +++ b/src/ParagonPlayground/backend/src/ParagonPlayground.Infrastructure/Services/PasswordService.cs @@ -0,0 +1,17 @@ +namespace ParagonPlayground.Infrastructure.Services; + +/// Provides password hashing and verification using BCrypt. +public class PasswordService +{ + /// Hashes a plain-text password. + public string Hash(string password) + { + return BCrypt.Net.BCrypt.HashPassword(password); + } + + /// Verifies a plain-text password against a BCrypt hash. + public bool Verify(string password, string hash) + { + return BCrypt.Net.BCrypt.Verify(password, hash); + } +}